Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 5 additions & 19 deletions backend/druks/apps/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
from druks.agents import Agent
from druks.doctor import CheckResult
from druks.durable.datastructures import Subject
from druks.durable.schemas import SubjectActivity
from druks.ui.page import PageRoute
from druks.workflows import Workflow

Expand Down Expand Up @@ -574,7 +573,7 @@ async def upload(file: UploadFile) -> FileSummary:
def _get_subject_routes(
cls, subject_class: "type[Subject] | type[StoredSubject]"
) -> "APIRouter":
"""The board and one subject (header + status + timeline + activity), each with a
"""The board and one subject (header + status + timeline + phase), each with a
point-in-time read and a ``/stream`` that pushes the whole snapshot on change.
Mounted at ``/api/<name>/<subject_type>`` for every subject the app's
workflows declare. Every read here is keyed by identity, so an app that
Expand Down Expand Up @@ -606,15 +605,10 @@ async def board(account_id: str | None) -> SubjectList:
)

async def subject_response(subject_id: str) -> SubjectResponse | None:
subject = await subject_class.get_for_subject_id(subject_id)
if subject is None:
return
return await reads.get_subject_response(
subject_type,
subject_id,
summary=subject.get_summary(),
activity=await cls.get_subject_activity(subject),
)
if subject := await subject_class.get_for_subject_id(subject_id):
return await reads.get_subject_response(
subject_type, subject_id, summary=subject.get_summary()
)

@router.get("", response_model=SubjectList, response_model_by_alias=True)
async def list_subjects() -> SubjectList:
Expand Down Expand Up @@ -684,11 +678,3 @@ async def record_event(
payload=payload,
app=cls.name,
)

@classmethod
async def get_subject_activity(
cls, subject: "Subject | StoredSubject"
) -> "SubjectActivity | None":
"""The subject's live sub-phase, if any (e.g. "Provisioning sandbox VM…"). Optional —
override to surface a transient signal the running run pushes."""
return
14 changes: 0 additions & 14 deletions backend/druks/contrib/software_factory/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,11 @@
from druks.contrib.software_factory.ticketing.jira import Jira
from druks.contrib.software_factory.ticketing.linear import Linear
from druks.core import services
from druks.db import StoredSubject
from druks.doctor import CheckResult
from druks.services import ServiceNotConnectedError
from druks.workflows import SubjectActivity

from .services import GithubReviewer

# Only what the timeline can't already show. A running agent has an agent call
# to name it, so the phase that clears provisioning maps to nothing.
_PHASE_META: dict[str, SubjectActivity] = {
"provisioning_vm": SubjectActivity(label="Provisioning sandbox VM…", kind="infra"),
"sandbox_building": SubjectActivity(label="Building sandbox…", kind="infra"),
}


async def check_tracker_identity() -> CheckResult:
"""Whether the selected tracker's identity is connected. Trackerless is a
Expand Down Expand Up @@ -196,8 +187,3 @@ async def get_tracker(cls, source: str | None = None) -> Tracker | None:
prompt="software_factory/review/review_pull_request.md",
contract=ReviewReport,
)

@classmethod
async def get_subject_activity(cls, subject: StoredSubject) -> SubjectActivity | None:
phase = await subject.get_phase()
return _PHASE_META.get(phase or "")
3 changes: 1 addition & 2 deletions backend/druks/durable/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from .enums import AgentCallStatus, RunState
from .exceptions import FatalError, WorkflowError
from .models import AgentCall, Run
from .schemas import AgentCallResponse, SubjectActivity, SubjectSummary
from .schemas import AgentCallResponse, SubjectSummary

# The durable-execution engine. Internal — authors never import druks.durable; the
# doors are druks.workflows (Workflow, Gate, step + these records) and druks.agents
Expand All @@ -15,7 +15,6 @@
"FatalError",
"Run",
"RunState",
"SubjectActivity",
"SubjectSummary",
"WorkflowError",
"get_run_phase",
Expand Down
9 changes: 2 additions & 7 deletions backend/druks/durable/reads.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
ArtifactDescriptor,
ArtifactFile,
RunResponse,
SubjectActivity,
SubjectResponse,
SubjectStatus,
SubjectSummary,
Expand Down Expand Up @@ -100,11 +99,7 @@ async def get_subject_phase(subject_type: str, subject_id: str) -> str | None:


async def get_subject_response(
subject_type: str,
subject_id: str,
*,
summary: SubjectSummary,
activity: SubjectActivity | None = None,
subject_type: str, subject_id: str, *, summary: SubjectSummary
) -> SubjectResponse:
# list_for_subject is newest-first, so runs[0] is the driving run the status
# reads — the same row get_latest_for_subject would return, its calls already
Expand All @@ -115,7 +110,7 @@ async def get_subject_response(
summary=summary,
status=await _status(latest),
timeline=await _timeline(runs),
activity=activity,
phase=await get_run_phase(latest.id) if latest and latest.is_running else None,
)


Expand Down
11 changes: 3 additions & 8 deletions backend/druks/durable/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,19 +196,14 @@ class SubjectList(Schema):
rows: list[SubjectRow] = Field(default_factory=list)


class SubjectActivity(Schema):
# The running sub-phase the timeline can't show ("Provisioning sandbox VM…"), supplied
# by the app; ``kind`` groups it for display ("infra" | "agent").
label: str
kind: str


class SubjectResponse(Schema):
summary: SerializeAsAny[SubjectSummary]
status: SubjectStatus
# The subject's runs, oldest first, each with its agent calls — the timeline.
timeline: list[RunResponse] = Field(default_factory=list)
activity: SubjectActivity | None = None
# The step the driving run is on ("provisioning_vm") while it runs; the app's
# UI owns the words.
phase: str | None = None


class TranscriptChunk(Schema):
Expand Down
2 changes: 0 additions & 2 deletions backend/druks/workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@
from druks.durable.schemas import (
AgentCallResponse,
RunResponse,
SubjectActivity,
SubjectStatus,
SubjectSummary,
)
Expand Down Expand Up @@ -74,7 +73,6 @@
"OperatorReply",
"RunResponse",
"Subject",
"SubjectActivity",
"SubjectStatus",
"SubjectSummary",
"Workflow",
Expand Down
35 changes: 11 additions & 24 deletions backend/tests/software_factory/test_api_work_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import pytest
from druks.contrib.software_factory.models import WorkItem
from druks.durable.reads import list_subject_timeline
from druks.testing import asgi_client, seed_call
from druks.testing import asgi_client, configure_app_for_test, make_settings, seed_call
from fastapi.testclient import TestClient

from software_factory.factories import make_test_work_item, seed_build_run
Expand All @@ -17,16 +17,11 @@
}


def _build_app(tmp_path):
from druks.testing import configure_app_for_test, make_settings

settings = make_settings(tmp_path)
return configure_app_for_test(settings=settings)


@pytest.fixture
async def client(tmp_path: Path, druks_db):
async with asgi_client(_build_app(tmp_path)) as client:
app = configure_app_for_test(settings=make_settings(tmp_path))

async with asgi_client(app) as client:
yield client


Expand Down Expand Up @@ -85,7 +80,7 @@ async def test_subject_list_shows_active_and_excludes_resolved(client: TestClien
assert "building" in rows
assert "merged one" not in rows
assert rows["building"]["status"]["state"] == "running"
assert rows["building"]["summary"]["resolution"] is None
assert not rows["building"]["summary"]["resolution"]


async def test_subject_detail_composes_summary_status_and_timeline(client: TestClient, druks_db):
Expand Down Expand Up @@ -244,29 +239,21 @@ async def test_timeline_shows_every_build_attempt(druks_db):
assert any(e.failure == "boom" for e in entries)


async def test_subject_activity_surfaces_running_phase(druks_db, monkeypatch):
# A running build run pushes a transient phase; the detail view's live activity
# surfaces it ("Provisioning sandbox VM…") — finer than the lifecycle status.
from druks.contrib.software_factory import app as software_factory_app

async def test_detail_carries_the_running_phase(client: TestClient, druks_db, monkeypatch):
item = await make_test_work_item(repo="ClawHaven/acme-app", title="x")
await seed_build_run(druks_db, work_item_id=item.id, state="running")

async def phase(_run_id):
return "provisioning_vm"

monkeypatch.setattr("druks.durable.reads.get_run_phase", phase)
activity = await software_factory_app.SoftwareFactory.get_subject_activity(item)
assert activity is not None
assert activity.label == "Provisioning sandbox VM…"
assert activity.kind == "infra"

detail = (await client.get(f"/api/software_factory/work_item/{item.id}")).json()
assert detail["phase"] == "provisioning_vm"

async def test_subject_activity_none_when_not_running(druks_db):
# A run parked on a gate isn't working — no live sub-phase.
from druks.contrib.software_factory import app as software_factory_app

async def test_detail_has_no_phase_when_not_running(client: TestClient, druks_db):
item = await make_test_work_item(repo="ClawHaven/acme-app", title="x")
await seed_build_run(druks_db, work_item_id=item.id, state="parked", input_gate="review_plan")

assert await software_factory_app.SoftwareFactory.get_subject_activity(item) is None
detail = (await client.get(f"/api/software_factory/work_item/{item.id}")).json()
assert not detail["phase"]
1 change: 0 additions & 1 deletion backend/tests/test_author_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
"OperatorReply",
"RunResponse",
"Subject",
"SubjectActivity",
"SubjectStatus",
"SubjectSummary",
"Workflow",
Expand Down
6 changes: 0 additions & 6 deletions backend/tests/test_declared_sandboxes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import druks.agents as agent_module
import druks.workflows as workflow_module
import pytest
from druks.contrib.software_factory.app import _PHASE_META
from druks.sandbox import datastructures, templates
from druks.sandbox.client import Client
from druks.sandbox.datastructures import Sandbox
Expand Down Expand Up @@ -60,11 +59,6 @@ class Second:
assert declared == {hashlib.sha256(b"setup").hexdigest(): other}


def test_software_factory_maps_the_sandbox_building_phase():
assert _PHASE_META["sandbox_building"].label == "Building sandbox…"
assert _PHASE_META["sandbox_building"].kind == "infra"


async def test_prepare_sandbox_templates_requests_each_declaration(monkeypatch):
sandbox = Sandbox(setup="sandboxes/setup.sh")
object.__setattr__(sandbox, "module", "druks_notes.workflows")
Expand Down
6 changes: 3 additions & 3 deletions docs/writing-an-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -734,9 +734,9 @@ method.
Druks serves the same `/api/night_watch/repository` surface for both subject
types. This surface contains a board, detail pages, and a live stream. Druks
mounts it for each declared subject. Each response contains your summary, run
status, timeline, agent calls, artifacts, and active question. Override
`get_subject_activity()` only to add transient app detail, such as
"Building sandbox VM…".
status, timeline, agent calls, artifacts, and active question. While a run is
running, the detail response also carries its `phase`, the step it is on. Your
frontend maps it to words.

Pass the subject instance to each component that requires one. This includes a
workflow start, gate answer, or event:
Expand Down
12 changes: 3 additions & 9 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,6 @@ export interface SubjectStatus {
accountUsername: string | null
}

// The live sub-phase a running run pushes ("Provisioning sandbox VM…", "Working…") —
// finer than the lifecycle status; null unless something is actively running.
export interface SubjectActivity {
label: string
kind: string
}

export interface TokenUsage {
inputTokens: number
outputTokens: number
Expand Down Expand Up @@ -160,12 +153,13 @@ export interface SubjectRow<S extends SubjectSummary = SubjectSummary> {

// A subject's full read view: domain summary, status, the platform timeline
// (the subject's runs, oldest first, each with its agent calls), and the
// app's optional live activity (the running sub-phase).
// driving run's phase while it runs.
export interface SubjectResponse<S extends SubjectSummary = SubjectSummary> {
summary: S
status: SubjectStatus
timeline: RunSummary[]
activity?: SubjectActivity | null
// The step the driving run is on ("provisioning_vm"); the app owns the words.
phase?: string | null
}

export interface ArtifactFile {
Expand Down
Loading