From 663b48d04b37b60e57ea317471e624cf1f829add Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Fri, 28 Aug 2026 13:49:14 +0200 Subject: [PATCH 1/2] Add design doc for cross-repo workflow grouping A single piece of work often spans sessions across several repositories. Today the only link between them is the operator's memory; there is no object that says 'these sessions are one workflow' and no shared context that survives a session's context being cleared. The design introduces a first-class Workflow entity (Alternative B from the research): it groups sessions across repos and carries a shared WorkflowBrief. It reuses the existing HandoffBrief shape (minus sessionId and writtenAtCommit, which have no meaning above a single session/tree) and the single-writer state model. A session belongs to at most one workflow; a fork offers same-workflow / new-workflow / no-workflow, with the brief linked rather than copied into the seed. An rloop review corrected one critical error: forkedFrom is dead code (never set, SessionManager explicitly says 'No lineage is recorded'), so workflowId inheritance was moved to prepareSuccessorSession where the successor is actually built. Verification: design-only, no code changed. The rloop ran in-session because both subagent models (deepseek and GLM-5.2) aborted on every spawn. Co-Authored-By: Claude --- docs/plans/workflow-grouping-design.md | 247 +++++++++++++++++++ docs/plans/workflow-grouping-rloop-report.md | 40 +++ 2 files changed, 287 insertions(+) create mode 100644 docs/plans/workflow-grouping-design.md create mode 100644 docs/plans/workflow-grouping-rloop-report.md diff --git a/docs/plans/workflow-grouping-design.md b/docs/plans/workflow-grouping-design.md new file mode 100644 index 00000000..fb4c1e73 --- /dev/null +++ b/docs/plans/workflow-grouping-design.md @@ -0,0 +1,247 @@ +# Workflow grouping — design + +## Problem + +A single piece of work — a feature, a refactor, a bug hunt, an investigation — +often spans several agent sessions across several repositories. Today the only +things linking those sessions are the operator's memory and, within one +lineage, the `forkedFrom` chain. There is no object that says "these four +sessions across three repos are one workflow," and no shared context that +survives a session's context being cleared. + +## Goal + +A first-class **Workflow** that groups related sessions across repositories and +carries a shared brief belonging to the workflow, not to any one session. + +## Non-goals + +- Not a multi-repo workspace manager (no bundling of repos, no linked-worktree + creation). A workflow groups *sessions*, which already live in worktrees. +- Not shared memory / knowledge graph (Alternative C from the research). The + shared artifact is a brief, the same shape as the per-session `HandoffBrief`. +- Not tags/labels (Alternative A). A session belongs to at most one workflow. + +## Entity: `Workflow` + +``` +record Workflow( + WorkflowId id, + String title, // human name; not blank + WorkflowStatus status, // OPEN | ARCHIVED + Instant createdAt, + Instant lastOpenedAt, // bumped when any member session is opened + Optional brief // the shared context; empty until first written +) +``` + +- `WorkflowId` is a stable app-assigned id, the same pattern as + `ManagedSessionId`. +- `status` is `OPEN` by default; `ARCHIVED` hides the workflow from the default + rail without deleting it. Archiving is reversible. A workflow with no member + sessions is not auto-deleted (the human may re-add sessions). +- `lastOpenedAt` is the workflow-level analogue of + `ManagedAgentSession.lastOpenedAt`: the rail sorts workflows by it so the + active one rises to the top. + +### Why the brief is workflow-scoped, not session-scoped + +`HandoffBrief` today is keyed by `ManagedSessionId` and replaced wholesale on +every write. It is the testimony of *one* session to its *successor* in the +same worktree. A workflow's brief is different: it is the running narrative of +the whole piece of work, written by whichever member session is active and read +by the next one opened in *any* repo. Promoting it to workflow scope is what +makes a cross-repo handoff work — a session in repo B picks up where the +session in repo A left off without A and B ever sharing a worktree or a +lineage. + +### Relationship to `forkedFrom` and the per-session brief + +- `forkedFrom` is a *single-parent lineage* field on `ManagedAgentSession` — + session X was handed off to session Y in the same worktree. It is about + replacing one agent with another on the same tree. **It is currently + unused**: `SessionManager.prepareSuccessorSession` does not set it, and its + own comment states "No lineage is recorded. `outgoing` is deleted as part of + the same handoff, so a `forkedFrom` pointing at it would resolve to nothing." + The field stays in the record (it is persisted and decoded), but this design + does not lean on it — see the fork section below for where workflow + affiliation is actually inherited. +- The per-session `HandoffBrief` stays too: it is still what a session writes + for its in-place successor, and it is still the seed a fork is launched with + (`HandoffSeed.compose` reads the outgoing session's brief). +- `Workflow.brief` is the *cross-session, cross-repo* layer above both. A + member session may write the workflow brief (the `session_handoff` tool gains + a workflow target) in addition to, or instead of, its own. + +The rule of thumb: `forkedFrom` (when populated) would answer "who had this +worktree before me?"; the per-session brief answers "what did the last agent +on this tree say?"; the workflow brief answers "what is the state of the whole +effort?". + +### Fork / handoff workflow destination + +A fork (in-place handoff to a different agent) today keeps the successor in +the same worktree and seeds it with the outgoing session's per-session brief. +The workflow adds a choice at fork time of which workflow the successor +belongs to: + +- **Same workflow** (default when the source session is affiliated) — the + successor inherits the source's `workflowId`. Inheritance happens in + `SessionManager.prepareSuccessorSession`, which already copies a fixed set + of fields from the outgoing session (displayName, namePinned, worktreeRoot, + branchCreatedHere, evalMode); `workflowId` joins that set. It does **not** + ride `forkedFrom`, which is currently never set (see above). The workflow + brief is **linked**, not copied into the seed: the successor can read it (it + is surfaced in the successor's context area, labelled as the workflow brief) + but the seed text stays the per-session brief only. Linking avoids + duplicating a document the successor can already see and that another + member session may update while the fork is running. +- **New workflow** — the fork creates a fresh workflow (human names it), the + successor is its first member, and the source session's `workflowId` is + unchanged. This is the path when a fork diverges into a separate effort. +- **No workflow** — the successor is unaffiliated (`workflowId` empty), + regardless of the source's affiliation. This is the path when the fork is + a one-off that does not belong to any effort. + +The per-session brief seed is unchanged in all three cases: it is still the +outgoing session's `HandoffBrief`. Only the workflow affiliation differs. + +## `ManagedAgentSession` change + +Add one optional field: + +``` +Optional workflowId // empty = unaffiliated +``` + +- A session belongs to **at most one** workflow. This keeps the workflow brief + unambiguous (one narrative, not a merge of several) and matches the mental + model: "this session is part of *the* billing workflow." +- Setting `workflowId` is a normal mutation via `toBuilder()`, not set-once. + A session can be moved between workflows or unaffiliated. (Moving it does + not move its per-session brief — that stays with the session.) +- `forkedFrom` is unchanged (and currently unused — see "Relationship to + forkedFrom" above). Workflow affiliation is inherited in + `SessionManager.prepareSuccessorSession`, which copies `workflowId` from + the outgoing session alongside the fields it already copies + (displayName, namePinned, worktreeRoot, branchCreatedHere, evalMode). This + is a one-line addition to that method. + +## `ApplicationState` change + +``` +record ApplicationState( + List repositories, + List sessions, + List workflows, // NEW + WorkspaceUiState ui, + List handoffBriefs +) +``` + +- `workflows` is a new top-level list, same persistence cadence as + `repositories` and `sessions` — one writer, the existing + `ApplicationStateRepository` single-owner model. No new writer. +- `empty()` adds `List.of()` for workflows; the four `with*` methods each pass + `workflows` through, plus a new `withWorkflows`. + +## `WorkflowBrief` + +Same fields as `HandoffBrief` minus `sessionId` (it is workflow-scoped, not +session-scoped) and minus `writtenAtCommit` (dropped below), keeping `author` +and `writtenAt`: + +``` +record WorkflowBrief( + String goal, + String nextStep, + Optional approach, + Optional decisions, + Optional ruledOut, + Optional corrections, + Instant writtenAt, + Author author // AGENT | HUMAN, reused from HandoffBrief +) +``` + +`writtenAtCommit` is **dropped** — a workflow spans multiple repos and +branches, so a single `HEAD` is meaningless. Staleness for a workflow brief is +expressed by `lastOpenedAt` / `writtenAt` (elapsed time since anyone touched +it), not by commits-since. + +## UI + +- The session rail gains a **Workflow** grouping above the per-repo session + list. A workflow row is collapsible and shows its member sessions (across + repos) underneath. Unaffiliated sessions stay listed under their repo as + today. +- Selecting a workflow row shows its brief (read) and an Edit affordance + (write, same dialog as the per-session brief edit). +- Opening any member session bumps the workflow's `lastOpenedAt` and surfaces + the workflow brief in the session's context area (the same surface that + shows the per-session brief today), labelled as the workflow brief. +- Archived workflows are hidden by default and reachable via a new "show + archived" filter toggle. No such toggle exists in the app today, so this is + a new control, not a reuse of an existing pattern. + +## Lifecycle + +- **Create**: human creates a workflow from the rail (title). Optionally seeds + it with one or more existing sessions. +- **Add/remove members**: drag sessions in/out, or a per-session "part of + workflow" picker. Adding a session sets its `workflowId`. +- **Write brief**: human edits the brief in the dialog, or an agent writes it + via the `session_handoff` MCP tool with a workflow target. +- **Archive**: sets status `ARCHIVED`. Member sessions keep their `workflowId` + (so un-archiving restores the grouping); they are just hidden under the + archived workflow. A session may be unaffiliated from an archived workflow + individually. +- **Delete**: a workflow may be deleted outright. Deleting a workflow clears + `workflowId` on all its members (they become unaffiliated); it never deletes + sessions. + +## MCP surface + +The `session_handoff` tool today writes the per-session brief. It gains an +optional target: + +- `session_handoff` (no target) — per-session brief, as today. +- `session_handoff --workflow` — writes the calling session's *workflow* brief + instead. Requires the session to be a workflow member. + +No new tool for creating workflows from an agent: workflow creation is a +human act (naming the effort), like naming a session. An agent may write the +brief of an existing workflow it belongs to. + +## Persistence & migration + +- `workflows` is a new JSON array in the state file. The existing + lenient-decode rule applies: a missing or malformed `workflows` array is + skipped (recovered to empty), never a reason to declare the state file + corrupt — exactly the discipline already used for cosmetic UI fields. +- `ManagedAgentSession.workflowId` is a new optional JSON field; old state + without it deserializes to `Optional.empty()`. +- No on-disk migration step. The first load after upgrade simply has no + workflows; everything else is unchanged. + +## What this does not change + +- `forkedFrom` lineage, per-session `HandoffBrief`, the single-writer state + model, the one-session-per-worktree invariant, worktree creation, PR + linking, eval mode. +- A workflow owns no repos and no worktrees. It is purely a grouping of + sessions plus a shared brief. + +## Decisions + +1. **Shared brief: single authored document.** The workflow brief is one + free-text document, written and replaced wholesale, same model as the + per-session `HandoffBrief`. No roll-up of member briefs. +2. **At most one workflow per session.** A session carries at most one + `workflowId`. To belong to a second effort, fork the session into the + second workflow (see the fork destination choice above). +3. **Fork workflow destination: three-way choice, brief linked not copied.** + A fork offers same-workflow / new-workflow / no-workflow. In the same-workflow + case the workflow brief is *linked* (the successor reads it from the + workflow, it is not appended to the seed), so the seed stays the per-session + brief and the workflow brief stays the single shared narrative. diff --git a/docs/plans/workflow-grouping-rloop-report.md b/docs/plans/workflow-grouping-rloop-report.md new file mode 100644 index 00000000..1f4e595f --- /dev/null +++ b/docs/plans/workflow-grouping-rloop-report.md @@ -0,0 +1,40 @@ +# RLoop final report — Workflow grouping design doc + +Target: `docs/plans/workflow-grouping-design.md` +Iterations: 2 (stopped early — no remaining findings) +Reviewer: in-session (subagent models `baseten/deepseek-ai/DeepSeek-V4-Flash-0731` and `baseten/zai-org/GLM-5.2` both aborted on every spawn; review done in-session against the codebase) + +## Iteration 1 + +### CRITICAL + +**C1 — `forkedFrom` is dead code; doc treated it as active lineage.** +The doc described `forkedFrom` as a single-parent lineage that workflowId inheritance rides on. Evidence: `SessionManager.java:386-388` states "No lineage is recorded"; `prepareSuccessorSession` (line 389-395) does not set it; `newSessionMetadata` (line 1267) comment: "forkedFrom defaults to empty; nothing ever sets it any more"; grep for `withForkedFrom` callers: none. +**Fix applied:** Rewrote "Relationship to forkedFrom" to state it is currently unused; grounded workflowId inheritance in `prepareSuccessorSession` (which copies a fixed field set from `outgoing`), not in `forkedFrom`. + +### HIGH + +**H1 — No "show archived" toggle pattern exists.** +Doc claimed "the same pattern as any other 'show archived' toggle." Grep for "archived" across `app/src/main/java`: zero hits. +**Fix applied:** Rewrote to state this is a new control, not a reuse. + +### MEDIUM + +**M1 — WorkflowBrief field list: "minus sessionId" was incomplete.** +Doc said "same fields minus sessionId" but also drops `writtenAtCommit` — internal contradiction with the next paragraph. +**Fix applied:** Phrasing now names both dropped fields up front. + +**M2 — `prepareSuccessorSession` not identified as affected module.** +Doc claimed fork inherits workflowId "at creation time" without naming where. +**Fix applied:** Named `prepareSuccessorSession` explicitly with its current copied-field set. + +### LOW + +**L1 — `ApplicationState` "four with* methods" verified accurate.** No fix needed. + +## Iteration 2 +No new CRITICAL/HIGH/MEDIUM findings. Fixes verified consistent. Doc clean. + +## Totals +- Fixed: 4 | Rebutted: 0 | Deferred: 0 +- Final findings: 0C / 0H / 0M / 0L From febf69ab28134823f024e860d1a6cb9f2262e40f Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Fri, 28 Aug 2026 13:49:23 +0200 Subject: [PATCH 2/2] Add Workflow entity for cross-repo session grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workflow groups related sessions across repositories and carries a shared brief — the running narrative of the whole effort, written by whichever member session is active and read by the next one opened in any repo. This is the layer above forkedFrom (dead code, never set) and the per-session HandoffBrief (one session's testimony to its in-place successor). Domain: new WorkflowId, WorkflowStatus, WorkflowBrief, and Workflow records. ManagedAgentSession gains an optional workflowId (14th field, at-most-one). ApplicationState gains a workflows list. All follow the existing record/id/with* patterns. Persistence: ApplicationStateCodec encodes/decodes the workflows array and the session workflowId leniently — a missing or malformed workflows array recovers to empty, a missing workflowId recovers to Optional.empty(). No schema version bump, same discipline as forkedFrom/evalMode when they were added. Service: SessionManager gains workflow CRUD (create, rename, archive, delete, write brief, affiliate session, touch). prepareSuccessorSession copies workflowId from the outgoing session alongside the fields it already copies (displayName, namePinned, worktreeRoot, branchCreatedHere, evalMode) — a one-line addition, not riding forkedFrom. MCP: session_handoff gains an optional 'workflow': true flag that writes the calling session's workflow brief instead of its per-session brief. Refused if the session is not a workflow member. No agent-driven workflow creation — naming the effort is a human act, like naming a session. Verification: compileJava + compileTestJava pass. 6 new tests pass (4 codec round-trip: workflow, session-with-workflowId, old-state- without-workflows, old-session-without-workflowId; 2 MCP: workflow- target-writes-workflow-brief, absent-flag-writes-per-session-brief). 1 pre-existing test failure (adoptingARemoteOnlyBranchMintsATrackingLocalBranchAndReportsBothNames) fails on main at 9a1b84b — a git-worktree-add environment issue, unrelated to this change. Not yet implemented: the UI layer (workflow grouping in the rail, archived toggle, brief edit dialog). Co-Authored-By: Claude --- .../java/app/drydock/DrydockApplication.java | 3 +- .../java/app/drydock/app/SessionManager.java | 148 +++++++++++++++++- .../drydock/app/UnknownWorkflowException.java | 17 ++ .../app/drydock/domain/ApplicationState.java | 16 +- .../drydock/domain/ManagedAgentSession.java | 43 ++++- .../java/app/drydock/domain/Workflow.java | 70 +++++++++ .../app/drydock/domain/WorkflowBrief.java | 56 +++++++ .../java/app/drydock/domain/WorkflowId.java | 31 ++++ .../app/drydock/domain/WorkflowStatus.java | 13 ++ .../app/drydock/mcp/McpSessionContext.java | 12 ++ .../java/app/drydock/mcp/McpToolRouter.java | 36 +++-- .../mcp/WorkspaceMcpSessionContext.java | 13 +- .../drydock/state/ApplicationStateCodec.java | 123 ++++++++++++++- .../java/app/drydock/ui/MainWorkspace.java | 26 +++ .../drydock/mcp/FakeMcpSessionContext.java | 23 +++ .../mcp/McpToolRouterSessionHandoffTest.java | 19 +++ .../mcp/WorkspaceMcpSessionContextTest.java | 2 + .../state/ApplicationStateCodecTest.java | 111 ++++++++++++- .../JsonApplicationStateRepositoryTest.java | 8 +- 19 files changed, 735 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/app/drydock/app/UnknownWorkflowException.java create mode 100644 app/src/main/java/app/drydock/domain/Workflow.java create mode 100644 app/src/main/java/app/drydock/domain/WorkflowBrief.java create mode 100644 app/src/main/java/app/drydock/domain/WorkflowId.java create mode 100644 app/src/main/java/app/drydock/domain/WorkflowStatus.java diff --git a/app/src/main/java/app/drydock/DrydockApplication.java b/app/src/main/java/app/drydock/DrydockApplication.java index 169c5add..11b85a9f 100644 --- a/app/src/main/java/app/drydock/DrydockApplication.java +++ b/app/src/main/java/app/drydock/DrydockApplication.java @@ -1347,7 +1347,8 @@ private void startMcpServer(Path stateDirectory) { (worktree, prompt) -> mainWorkspace.startAgentSession(worktree, prompt), mainWorkspace::renameSessionFromAgent, mainWorkspace::reclaimConversationFromAgent, - mainWorkspace::writeHandoffFromAgent); + mainWorkspace::writeHandoffFromAgent, + mainWorkspace::writeWorkflowHandoffFromAgent); McpServer server = new McpServer(registry, new McpToolRouter(context, registry), mcpActivityLog); // Published before start() so a shutdown racing startup still reaches // it. Publication alone would not be enough -- a close() that wins the diff --git a/app/src/main/java/app/drydock/app/SessionManager.java b/app/src/main/java/app/drydock/app/SessionManager.java index 9e6cae2c..a64adb92 100644 --- a/app/src/main/java/app/drydock/app/SessionManager.java +++ b/app/src/main/java/app/drydock/app/SessionManager.java @@ -22,6 +22,10 @@ import app.drydock.domain.SessionStatus; import app.drydock.domain.SessionWorkspace; import app.drydock.domain.SshRemote; +import app.drydock.domain.Workflow; +import app.drydock.domain.WorkflowBrief; +import app.drydock.domain.WorkflowId; +import app.drydock.domain.WorkflowStatus; import app.drydock.mcp.McpConfigWriter; import app.drydock.mcp.McpSessionContext; import app.drydock.mcp.McpSessionContext.RenameKind; @@ -392,7 +396,8 @@ public ManagedAgentSession prepareSuccessorSession(Repository repository, Manage return newSessionMetadata(repository, outgoing.displayName(), agentKind, outgoing.worktreeRoot(), outgoing.branchCreatedHere()) .withNamePinned(outgoing.namePinned()) - .withEvalMode(outgoing.evalMode()); + .withEvalMode(outgoing.evalMode()) + .withWorkflowId(outgoing.workflowId()); } /** @@ -1276,6 +1281,147 @@ public boolean mayDeleteBranchOf(Path worktreeRoot) { return BranchOwnership.mayDeleteBranchOf(sessions(), worktreeRoot); } + // ---- workflows ----------------------------------------------------------- + + /** Every persisted workflow, in persisted order. */ + public List workflows() { + return stateStore.state().workflows(); + } + + /** The workflow with this id, if any. */ + public Optional workflow(WorkflowId id) { + return stateStore.state().workflows().stream() + .filter(w -> w.id().equals(id)) + .findFirst(); + } + + /** + * Creates a workflow. The human names the effort, like naming a session; + * there is no agent-driven creation path. + */ + public Workflow createWorkflow(String title) { + if (title.isBlank()) { + throw new IllegalArgumentException("Workflow title must not be blank"); + } + Workflow[] created = new Workflow[1]; + stateStore.update(state -> { + Workflow workflow = Workflow.create(WorkflowId.newId(), title.strip(), Instant.now()); + created[0] = workflow; + List updated = new ArrayList<>(state.workflows()); + updated.add(workflow); + return state.withWorkflows(updated); + }); + return created[0]; + } + + /** Renames a workflow. */ + public Workflow renameWorkflow(WorkflowId id, String newTitle) { + if (newTitle.isBlank()) { + throw new IllegalArgumentException("Workflow title must not be blank"); + } + Workflow[] result = new Workflow[1]; + stateStore.update(state -> { + Workflow workflow = requireWorkflow(state, id); + result[0] = workflow.withTitle(newTitle.strip()); + return state.withWorkflows(replaceWorkflow(state, result[0])); + }); + return result[0]; + } + + /** Sets a workflow's status (OPEN or ARCHIVED). Archiving never touches member sessions. */ + public Workflow setWorkflowStatus(WorkflowId id, WorkflowStatus status) { + Workflow[] result = new Workflow[1]; + stateStore.update(state -> { + Workflow workflow = requireWorkflow(state, id); + result[0] = workflow.withStatus(status); + return state.withWorkflows(replaceWorkflow(state, result[0])); + }); + return result[0]; + } + + /** Writes a workflow's brief (human-authored, from the Edit dialog). Replaced wholesale. */ + public Workflow writeWorkflowBrief(WorkflowId id, WorkflowBrief brief) { + Workflow[] result = new Workflow[1]; + stateStore.update(state -> { + Workflow workflow = requireWorkflow(state, id); + result[0] = workflow.withBrief(Optional.of(brief)); + return state.withWorkflows(replaceWorkflow(state, result[0])); + }); + return result[0]; + } + + /** Writes a workflow's brief (agent-authored, from {@code session_handoff --workflow}). */ + public Workflow writeWorkflowBriefAgent(WorkflowId id, WorkflowBrief brief) { + return writeWorkflowBrief(id, brief); + } + + /** + * Writes the workflow brief for the workflow the caller session belongs + * to. Throws if the session is not a workflow member. + */ + public Workflow applyAgentWorkflowHandoff(ManagedSessionId sessionId, McpSessionContext.HandoffDraft draft) { + Workflow[] result = new Workflow[1]; + stateStore.update(state -> { + ManagedAgentSession session = state.sessions().stream() + .filter(existing -> existing.id().equals(sessionId)) + .findFirst() + .orElseThrow(() -> new UnknownSessionException(sessionId)); + WorkflowId workflowId = session.workflowId() + .orElseThrow(() -> new IllegalStateException("Session " + sessionId + + " is not part of a workflow; cannot write a workflow brief.")); + Workflow workflow = requireWorkflow(state, workflowId); + WorkflowBrief brief = new WorkflowBrief(draft.goal(), draft.nextStep(), draft.approach(), + draft.decisions(), draft.ruledOut(), draft.corrections(), Instant.now(), + HandoffBrief.Author.AGENT); + result[0] = workflow.withBrief(Optional.of(brief)); + return state.withWorkflows(replaceWorkflow(state, result[0])); + }); + return result[0]; + } + + /** + * Deletes a workflow outright. Clears {@code workflowId} on all member + * sessions (they become unaffiliated); never deletes sessions. + */ + public void deleteWorkflow(WorkflowId id) { + stateStore.update(state -> { + List remaining = state.workflows().stream() + .filter(w -> !w.id().equals(id)) + .toList(); + List sessions = state.sessions().stream() + .map(s -> s.workflowId().map(w -> s.withWorkflowId(Optional.empty())).orElse(s)) + .toList(); + return state.withWorkflows(remaining).withSessions(sessions); + }); + } + + /** Affiliates a session with a workflow (sets its {@code workflowId}). */ + public ManagedAgentSession setSessionWorkflow(ManagedSessionId sessionId, Optional workflowId) { + return updateSession(sessionId, session -> session.withWorkflowId(workflowId)); + } + + /** Bumps a workflow's {@code lastOpenedAt} when a member session is opened. */ + public void touchWorkflow(WorkflowId id) { + stateStore.update(state -> { + Workflow workflow = requireWorkflow(state, id); + Workflow touched = workflow.withLastOpenedAt(Instant.now()); + return state.withWorkflows(replaceWorkflow(state, touched)); + }); + } + + private static Workflow requireWorkflow(ApplicationState state, WorkflowId id) { + return state.workflows().stream() + .filter(w -> w.id().equals(id)) + .findFirst() + .orElseThrow(() -> new UnknownWorkflowException(id)); + } + + private static List replaceWorkflow(ApplicationState state, Workflow updated) { + return state.workflows().stream() + .map(w -> w.id().equals(updated.id()) ? updated : w) + .toList(); + } + private void persistNewSession(ManagedAgentSession session) { stateStore.update(state -> { List updated = new ArrayList<>(state.sessions()); diff --git a/app/src/main/java/app/drydock/app/UnknownWorkflowException.java b/app/src/main/java/app/drydock/app/UnknownWorkflowException.java new file mode 100644 index 00000000..f60f6692 --- /dev/null +++ b/app/src/main/java/app/drydock/app/UnknownWorkflowException.java @@ -0,0 +1,17 @@ +package app.drydock.app; + +import app.drydock.domain.WorkflowId; + +/** + * A {@link SessionManager} operation was asked to act on a {@link + * WorkflowId} that is not present in the persisted {@code + * ApplicationState}. Kept as its own specific type rather than a generic + * {@code IllegalArgumentException} or {@code NoSuchElementException} per + * plan section 20 ("never a generic 'something went wrong'"). + */ +public final class UnknownWorkflowException extends RuntimeException { + + public UnknownWorkflowException(WorkflowId workflowId) { + super("No workflow with id " + workflowId); + } +} diff --git a/app/src/main/java/app/drydock/domain/ApplicationState.java b/app/src/main/java/app/drydock/domain/ApplicationState.java index ccebe06c..0b8d82c1 100644 --- a/app/src/main/java/app/drydock/domain/ApplicationState.java +++ b/app/src/main/java/app/drydock/domain/ApplicationState.java @@ -22,6 +22,7 @@ public record ApplicationState( List repositories, List sessions, + List workflows, WorkspaceUiState ui, List handoffBriefs ) { @@ -29,27 +30,32 @@ public record ApplicationState( public ApplicationState { repositories = List.copyOf(Objects.requireNonNull(repositories, "repositories")); sessions = List.copyOf(Objects.requireNonNull(sessions, "sessions")); + workflows = List.copyOf(Objects.requireNonNull(workflows, "workflows")); Objects.requireNonNull(ui, "ui"); handoffBriefs = List.copyOf(Objects.requireNonNull(handoffBriefs, "handoffBriefs")); } public static ApplicationState empty() { - return new ApplicationState(List.of(), List.of(), WorkspaceUiState.empty(), List.of()); + return new ApplicationState(List.of(), List.of(), List.of(), WorkspaceUiState.empty(), List.of()); } public ApplicationState withRepositories(List newRepositories) { - return new ApplicationState(newRepositories, sessions, ui, handoffBriefs); + return new ApplicationState(newRepositories, sessions, workflows, ui, handoffBriefs); } public ApplicationState withSessions(List newSessions) { - return new ApplicationState(repositories, newSessions, ui, handoffBriefs); + return new ApplicationState(repositories, newSessions, workflows, ui, handoffBriefs); + } + + public ApplicationState withWorkflows(List newWorkflows) { + return new ApplicationState(repositories, sessions, newWorkflows, ui, handoffBriefs); } public ApplicationState withUi(WorkspaceUiState newUi) { - return new ApplicationState(repositories, sessions, newUi, handoffBriefs); + return new ApplicationState(repositories, sessions, workflows, newUi, handoffBriefs); } public ApplicationState withHandoffBriefs(List newHandoffBriefs) { - return new ApplicationState(repositories, sessions, ui, newHandoffBriefs); + return new ApplicationState(repositories, sessions, workflows, ui, newHandoffBriefs); } } diff --git a/app/src/main/java/app/drydock/domain/ManagedAgentSession.java b/app/src/main/java/app/drydock/domain/ManagedAgentSession.java index 8fa320bd..7ec53570 100644 --- a/app/src/main/java/app/drydock/domain/ManagedAgentSession.java +++ b/app/src/main/java/app/drydock/domain/ManagedAgentSession.java @@ -92,7 +92,8 @@ public record ManagedAgentSession( PrLink pr, boolean namePinned, Optional forkedFrom, - boolean evalMode + boolean evalMode, + Optional workflowId ) { public ManagedAgentSession { @@ -107,6 +108,7 @@ public record ManagedAgentSession( Objects.requireNonNull(lastExitCode, "lastExitCode"); Objects.requireNonNull(pr, "pr"); Objects.requireNonNull(forkedFrom, "forkedFrom"); + Objects.requireNonNull(workflowId, "workflowId"); if (displayName.isBlank()) { throw new IllegalArgumentException("ManagedAgentSession displayName must not be blank"); @@ -114,15 +116,29 @@ public record ManagedAgentSession( } /** - * As the canonical constructor with {@code evalMode = false}; for callers - * (and tests) that do not participate in eval mode. + * As the canonical constructor with {@code evalMode = false} and + * {@code workflowId = Optional.empty()}; for callers (and tests) that + * do not participate in eval mode or workflow grouping. */ public ManagedAgentSession(ManagedSessionId id, RepositoryId repositoryId, String displayName, AgentBinding binding, SessionWorkspace workspace, SessionStatus status, Instant createdAt, Instant lastOpenedAt, Optional lastExitCode, PrLink pr, boolean namePinned, Optional forkedFrom) { this(id, repositoryId, displayName, binding, workspace, status, createdAt, lastOpenedAt, lastExitCode, - pr, namePinned, forkedFrom, false); + pr, namePinned, forkedFrom, false, Optional.empty()); + } + + /** + * As the canonical constructor with {@code workflowId = Optional.empty()}; + * for callers (and tests) that do not participate in workflow grouping. + */ + public ManagedAgentSession(ManagedSessionId id, RepositoryId repositoryId, String displayName, + AgentBinding binding, SessionWorkspace workspace, SessionStatus status, + Instant createdAt, Instant lastOpenedAt, Optional lastExitCode, + PrLink pr, boolean namePinned, Optional forkedFrom, + boolean evalMode) { + this(id, repositoryId, displayName, binding, workspace, status, createdAt, lastOpenedAt, lastExitCode, + pr, namePinned, forkedFrom, evalMode, Optional.empty()); } // ---- leaf accessors, delegating into the groups -------------------------- @@ -219,6 +235,15 @@ public ManagedAgentSession withEvalMode(boolean newEvalMode) { return toBuilder().evalMode(newEvalMode).build(); } + /** + * Sets which workflow this session belongs to. Not set-once: a session can + * be moved between workflows or unaffiliated. Moving it does not move its + * per-session brief -- that stays with the session. + */ + public ManagedAgentSession withWorkflowId(Optional newWorkflowId) { + return toBuilder().workflowId(newWorkflowId).build(); + } + /** * The single construction path. Adding a component touches this class in * one place instead of once per {@code with*} method, which is the whole @@ -239,6 +264,7 @@ public static final class Builder { private boolean namePinned; private Optional forkedFrom; private boolean evalMode; + private Optional workflowId; private Builder(ManagedAgentSession from) { this.id = from.id; @@ -254,6 +280,7 @@ private Builder(ManagedAgentSession from) { this.namePinned = from.namePinned; this.forkedFrom = from.forkedFrom; this.evalMode = from.evalMode; + this.workflowId = from.workflowId; } /** @@ -277,6 +304,7 @@ public Builder(ManagedSessionId id, RepositoryId repositoryId, String displayNam this.namePinned = false; this.forkedFrom = Optional.empty(); this.evalMode = false; + this.workflowId = Optional.empty(); } public Builder id(ManagedSessionId value) { @@ -344,9 +372,14 @@ public Builder evalMode(boolean value) { return this; } + public Builder workflowId(Optional value) { + this.workflowId = value; + return this; + } + public ManagedAgentSession build() { return new ManagedAgentSession(id, repositoryId, displayName, binding, workspace, status, - createdAt, lastOpenedAt, lastExitCode, pr, namePinned, forkedFrom, evalMode); + createdAt, lastOpenedAt, lastExitCode, pr, namePinned, forkedFrom, evalMode, workflowId); } } } diff --git a/app/src/main/java/app/drydock/domain/Workflow.java b/app/src/main/java/app/drydock/domain/Workflow.java new file mode 100644 index 00000000..33e124e7 --- /dev/null +++ b/app/src/main/java/app/drydock/domain/Workflow.java @@ -0,0 +1,70 @@ +package app.drydock.domain; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * A first-class grouping of related {@link ManagedAgentSession}s across + * repositories, plus a shared {@link WorkflowBrief}. + * + *

A workflow names a single piece of work -- a feature, a refactor, a bug + * hunt, an investigation -- and lets sessions in different repos carry one + * shared narrative instead of each relying on the operator's memory. A + * session belongs to at most one workflow (see {@link + * ManagedAgentSession#workflowId()}); the affiliation is set on the session, + * not stored as a member list here, so the source of truth is one place.

+ * + *

{@link #status()} is {@link WorkflowStatus#OPEN} by default; + * {@link WorkflowStatus#ARCHIVED} hides the workflow from the default rail + * without deleting it. Archiving is reversible and never touches member + * sessions. {@link #lastOpenedAt()} is bumped when any member session is + * opened, so the rail sorts workflows with the active one on top -- the + * workflow-level analogue of {@link ManagedAgentSession#lastOpenedAt()}.

+ * + *

{@link #brief()} is empty until first written. A workflow with no member + * sessions is not auto-deleted: the human may re-add sessions.

+ */ +public record Workflow( + WorkflowId id, + String title, + WorkflowStatus status, + Instant createdAt, + Instant lastOpenedAt, + Optional brief +) { + + public Workflow { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(createdAt, "createdAt"); + Objects.requireNonNull(lastOpenedAt, "lastOpenedAt"); + Objects.requireNonNull(brief, "brief"); + + if (title.isBlank()) { + throw new IllegalArgumentException("Workflow title must not be blank"); + } + } + + /** A freshly created workflow: OPEN, no brief, timestamps at {@code now}. */ + public static Workflow create(WorkflowId id, String title, Instant now) { + return new Workflow(id, title, WorkflowStatus.OPEN, now, now, Optional.empty()); + } + + public Workflow withTitle(String newTitle) { + return new Workflow(id, newTitle, status, createdAt, lastOpenedAt, brief); + } + + public Workflow withStatus(WorkflowStatus newStatus) { + return new Workflow(id, title, newStatus, createdAt, lastOpenedAt, brief); + } + + public Workflow withLastOpenedAt(Instant newLastOpenedAt) { + return new Workflow(id, title, status, createdAt, newLastOpenedAt, brief); + } + + public Workflow withBrief(Optional newBrief) { + return new Workflow(id, title, status, createdAt, lastOpenedAt, newBrief); + } +} diff --git a/app/src/main/java/app/drydock/domain/WorkflowBrief.java b/app/src/main/java/app/drydock/domain/WorkflowBrief.java new file mode 100644 index 00000000..3fd24cc0 --- /dev/null +++ b/app/src/main/java/app/drydock/domain/WorkflowBrief.java @@ -0,0 +1,56 @@ +package app.drydock.domain; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * The shared brief of a {@link Workflow}: the running narrative of the whole + * piece of work, written by whichever member session is active and read by + * the next one opened in any repository. + * + *

This is the workflow-scoped counterpart of {@link HandoffBrief}, minus + * the fields that have no meaning above a single session/tree:

+ *
    + *
  • {@code sessionId} -- a workflow brief belongs to the workflow, not to + * any one session;
  • + *
  • {@code writtenAtCommit} -- a workflow spans multiple repos and + * branches, so a single {@code HEAD} is meaningless. Staleness is + * expressed by {@link Workflow#lastOpenedAt()} / {@link #writtenAt()} + * (elapsed time since anyone touched it), not by commits-since.
  • + *
+ * + *

Like {@link HandoffBrief}, this is replaced wholesale on every write: + * an omitted optional slot is cleared, not preserved. {@link #author()} + * reuses {@link HandoffBrief.Author} so an agent-written brief is labelled + * as testimony and a human-written one is not.

+ */ +public record WorkflowBrief( + String goal, + String nextStep, + Optional approach, + Optional decisions, + Optional ruledOut, + Optional corrections, + Instant writtenAt, + HandoffBrief.Author author +) { + + public WorkflowBrief { + Objects.requireNonNull(goal, "goal"); + Objects.requireNonNull(nextStep, "nextStep"); + Objects.requireNonNull(approach, "approach"); + Objects.requireNonNull(decisions, "decisions"); + Objects.requireNonNull(ruledOut, "ruledOut"); + Objects.requireNonNull(corrections, "corrections"); + Objects.requireNonNull(writtenAt, "writtenAt"); + Objects.requireNonNull(author, "author"); + + if (goal.isBlank()) { + throw new IllegalArgumentException("WorkflowBrief goal must not be blank"); + } + if (nextStep.isBlank()) { + throw new IllegalArgumentException("WorkflowBrief nextStep must not be blank"); + } + } +} diff --git a/app/src/main/java/app/drydock/domain/WorkflowId.java b/app/src/main/java/app/drydock/domain/WorkflowId.java new file mode 100644 index 00000000..5f2635e4 --- /dev/null +++ b/app/src/main/java/app/drydock/domain/WorkflowId.java @@ -0,0 +1,31 @@ +package app.drydock.domain; + +import java.util.Objects; +import java.util.UUID; + +/** + * Identity of a {@link Workflow}, mirroring {@link ManagedSessionId} and + * {@link RepositoryId}. + * + *

Stable for the lifetime of the workflow, app-assigned, and deliberately + * distinct from any session or repository identifier.

+ */ +public record WorkflowId(UUID value) { + + public WorkflowId { + Objects.requireNonNull(value, "value"); + } + + public static WorkflowId newId() { + return new WorkflowId(UUID.randomUUID()); + } + + public static WorkflowId of(String uuidText) { + return new WorkflowId(UUID.fromString(uuidText)); + } + + @Override + public String toString() { + return value.toString(); + } +} diff --git a/app/src/main/java/app/drydock/domain/WorkflowStatus.java b/app/src/main/java/app/drydock/domain/WorkflowStatus.java new file mode 100644 index 00000000..db70b3ca --- /dev/null +++ b/app/src/main/java/app/drydock/domain/WorkflowStatus.java @@ -0,0 +1,13 @@ +package app.drydock.domain; + +/** + * Lifecycle status of a {@link Workflow}. + * + *

{@link #OPEN} workflows are shown in the default rail; {@link #ARCHIVED} + * workflows are hidden unless a "show archived" filter is on. Archiving is + * reversible and never deletes member sessions.

+ */ +public enum WorkflowStatus { + OPEN, + ARCHIVED +} diff --git a/app/src/main/java/app/drydock/mcp/McpSessionContext.java b/app/src/main/java/app/drydock/mcp/McpSessionContext.java index c53847e6..f7378fe8 100644 --- a/app/src/main/java/app/drydock/mcp/McpSessionContext.java +++ b/app/src/main/java/app/drydock/mcp/McpSessionContext.java @@ -2,6 +2,7 @@ import app.drydock.domain.HandoffBrief; import app.drydock.domain.ManagedSessionId; +import app.drydock.domain.Workflow; import app.drydock.git.UnifiedDiff; import app.drydock.review.ReviewAnnotation; import app.drydock.review.ReviewIntent; @@ -236,6 +237,17 @@ record HandoffDraft(String goal, String nextStep, Optional approach, Opt */ HandoffBrief writeHandoff(ManagedSessionId caller, HandoffDraft draft) throws McpToolException; + /** + * Replaces the caller's workflow brief with {@code draft} and + * persists it, stamping {@code writtenAt} and {@code author = AGENT}. No + * {@code writtenAtCommit}: a workflow spans multiple repos and branches, + * so a single {@code HEAD} is meaningless. + * + *

Refuses if the caller is not a workflow member. Otherwise the same + * wholesale-replacement semantics as {@link #writeHandoff}.

+ */ + Workflow writeWorkflowHandoff(ManagedSessionId caller, HandoffDraft draft) throws McpToolException; + /** * Renames the caller's own session to an already-validated title. * diff --git a/app/src/main/java/app/drydock/mcp/McpToolRouter.java b/app/src/main/java/app/drydock/mcp/McpToolRouter.java index 3adcccff..9b7a3172 100644 --- a/app/src/main/java/app/drydock/mcp/McpToolRouter.java +++ b/app/src/main/java/app/drydock/mcp/McpToolRouter.java @@ -2,6 +2,8 @@ import app.drydock.domain.HandoffBrief; import app.drydock.domain.ManagedSessionId; +import app.drydock.domain.Workflow; +import app.drydock.domain.WorkflowBrief; import app.drydock.git.DiffScope; import app.drydock.mcp.AnnotationLines.LineRef; import app.drydock.mcp.McpSessionContext.RenameOutcome; @@ -168,7 +170,9 @@ public List toolDescriptors() { "Records what this session would tell a successor, so the human can hand the work " + "to a different agent at any moment. Keep it current as you work -- you " + "are writing for whoever picks this up, not for the human. Every call " - + "REPLACES the whole brief: an omitted optional slot is cleared, not kept.", + + "REPLACES the whole brief: an omitted optional slot is cleared, not kept. " + + "Pass \"workflow\": true to write the calling session's workflow brief " + + "instead (requires the session to be a workflow member).", JsonObject.empty() .put("goal", schemaString("What this session is trying to achieve.")) .put("nextStep", schemaString("What the successor should do first.")) @@ -177,7 +181,9 @@ public List toolDescriptors() { .put("ruledOut", schemaString("What was tried or considered and rejected, " + "with the reason -- the part a successor cannot reconstruct " + "from the code.")) - .put("corrections", schemaString("What the human pushed back on.")), + .put("corrections", schemaString("What the human pushed back on.")) + .put("workflow", schemaBoolean("When true, write the calling session's " + + "workflow brief instead of its per-session brief.")), "goal", "nextStep"), descriptor("repos_list", "Lists every repository registered in Drydock, with git state for local repositories.", @@ -674,6 +680,7 @@ private static Path realPathOrSelf(Path path) { private JsonValue sessionHandoff(ManagedSessionId caller, JsonValue arguments) throws McpToolException { requireLiveSession(caller); JsonObject args = asObject(arguments); + boolean workflowTarget = args.get("workflow") instanceof JsonBoolean wb && wb.value(); // Validate before charging: a malformed brief is the agent's mistake // to fix, not a spend (same rule as session_rename). @@ -701,10 +708,25 @@ private JsonValue sessionHandoff(ManagedSessionId caller, JsonValue arguments) t throw new McpToolException(e.getMessage()); } - HandoffBrief written; + McpSessionContext.HandoffDraft draft = new McpSessionContext.HandoffDraft( + goal, nextStep, approach, decisions, ruledOut, corrections); + try { - written = context.writeHandoff(caller, new McpSessionContext.HandoffDraft( - goal, nextStep, approach, decisions, ruledOut, corrections)); + if (workflowTarget) { + Workflow written = context.writeWorkflowHandoff(caller, draft); + return JsonObject.empty() + .put("outcome", new JsonString("written")) + .put("target", new JsonString("workflow")) + .put("workflowId", new JsonString(written.id().value().toString())) + .put("writtenAt", new JsonString(written.brief() + .map(WorkflowBrief::writtenAt) + .map(Instant::toString) + .orElse(""))); + } + HandoffBrief written = context.writeHandoff(caller, draft); + return JsonObject.empty() + .put("outcome", new JsonString("written")) + .put("writtenAt", new JsonString(written.writtenAt().toString())); } catch (McpToolException | RuntimeException e) { // Only an outright failure is refunded -- but "outright" includes // an unchecked one. The context reaches git and the FX thread, and @@ -713,10 +735,6 @@ private JsonValue sessionHandoff(ManagedSessionId caller, JsonValue arguments) t registry.refundHandoff(caller); throw e; } - - return JsonObject.empty() - .put("outcome", new JsonString("written")) - .put("writtenAt", new JsonString(written.writtenAt().toString())); } /** diff --git a/app/src/main/java/app/drydock/mcp/WorkspaceMcpSessionContext.java b/app/src/main/java/app/drydock/mcp/WorkspaceMcpSessionContext.java index a951f474..24334d1a 100644 --- a/app/src/main/java/app/drydock/mcp/WorkspaceMcpSessionContext.java +++ b/app/src/main/java/app/drydock/mcp/WorkspaceMcpSessionContext.java @@ -6,6 +6,7 @@ import app.drydock.domain.ManagedSessionId; import app.drydock.domain.Repository; import app.drydock.domain.SessionStatus; +import app.drydock.domain.Workflow; import app.drydock.git.BranchCatalog; import app.drydock.git.BranchCheckout; import app.drydock.git.BranchNameRules; @@ -151,6 +152,7 @@ public final class WorkspaceMcpSessionContext implements McpSessionContext { private final BiFunction> sessionRenamer; private final BiFunction> sessionReclaimer; private final BiFunction> handoffWriter; + private final BiFunction> workflowHandoffWriter; /** * @param sessionCatalog every managed session, e.g. {@code SessionManager::sessions} @@ -161,6 +163,7 @@ public final class WorkspaceMcpSessionContext implements McpSessionContext { * @param sessionRenamer bound to {@code MainWorkspace.renameSessionFromAgent} * @param sessionReclaimer bound to {@code MainWorkspace.reclaimConversationFromAgent} * @param handoffWriter bound to {@code MainWorkspace.writeHandoffFromAgent} + * @param workflowHandoffWriter bound to {@code MainWorkspace.writeWorkflowHandoffFromAgent} */ public WorkspaceMcpSessionContext(Supplier> sessionCatalog, Supplier> repositoryCatalog, @@ -179,7 +182,9 @@ public WorkspaceMcpSessionContext(Supplier> sessionCat BiFunction> sessionReclaimer, BiFunction> handoffWriter) { + CompletableFuture> handoffWriter, + BiFunction> workflowHandoffWriter) { this.sessionCatalog = Objects.requireNonNull(sessionCatalog, "sessionCatalog"); this.repositoryCatalog = Objects.requireNonNull(repositoryCatalog, "repositoryCatalog"); this.annotationStore = Objects.requireNonNull(annotationStore, "annotationStore"); @@ -194,6 +199,7 @@ public WorkspaceMcpSessionContext(Supplier> sessionCat this.sessionRenamer = Objects.requireNonNull(sessionRenamer, "sessionRenamer"); this.sessionReclaimer = Objects.requireNonNull(sessionReclaimer, "sessionReclaimer"); this.handoffWriter = Objects.requireNonNull(handoffWriter, "handoffWriter"); + this.workflowHandoffWriter = Objects.requireNonNull(workflowHandoffWriter, "workflowHandoffWriter"); } // ---- caller lookup ------------------------------------------------------ @@ -662,6 +668,11 @@ public HandoffBrief writeHandoff(ManagedSessionId caller, HandoffDraft draft) th return join(handoffWriter.apply(caller, draft), HANDOFF_TIMEOUT_SECONDS); } + @Override + public Workflow writeWorkflowHandoff(ManagedSessionId caller, HandoffDraft draft) throws McpToolException { + return join(workflowHandoffWriter.apply(caller, draft), HANDOFF_TIMEOUT_SECONDS); + } + // ---- shared helpers ----------------------------------------------------- /** Empty when the path no longer exists -- never a fabricated lexical path. */ diff --git a/app/src/main/java/app/drydock/state/ApplicationStateCodec.java b/app/src/main/java/app/drydock/state/ApplicationStateCodec.java index f8e22f35..90e4d736 100644 --- a/app/src/main/java/app/drydock/state/ApplicationStateCodec.java +++ b/app/src/main/java/app/drydock/state/ApplicationStateCodec.java @@ -15,6 +15,10 @@ import app.drydock.domain.SessionWorkspace; import app.drydock.domain.SshRemote; import app.drydock.domain.UiTheme; +import app.drydock.domain.Workflow; +import app.drydock.domain.WorkflowBrief; +import app.drydock.domain.WorkflowId; +import app.drydock.domain.WorkflowStatus; import app.drydock.domain.WorkspaceUiState; import app.drydock.review.SessionReviewScopes; import app.drydock.state.json.JsonValue; @@ -198,6 +202,12 @@ public static JsonValue toJson(ApplicationState state) { root.put("ui", uiToJson(state.ui())); + List workflows = new ArrayList<>(); + for (Workflow workflow : state.workflows()) { + workflows.add(workflowToJson(workflow)); + } + root.put("workflows", new JsonArray(workflows)); + List briefs = new ArrayList<>(); for (HandoffBrief brief : state.handoffBriefs()) { briefs.add(handoffBriefToJson(brief)); @@ -254,6 +264,9 @@ private static JsonValue sessionToJson(ManagedAgentSession session) { .map(parent -> new JsonString(parent.value().toString())) .orElse(JsonValue.JsonNull.INSTANCE)); obj.put("evalMode", new JsonBoolean(session.evalMode())); + obj.put("workflowId", session.workflowId() + .map(w -> new JsonString(w.value().toString())) + .orElse(JsonValue.JsonNull.INSTANCE)); return obj; } @@ -272,6 +285,32 @@ private static JsonValue handoffBriefToJson(HandoffBrief brief) { return obj; } + private static JsonValue workflowToJson(Workflow workflow) { + JsonObject obj = JsonObject.empty(); + obj.put("id", new JsonString(workflow.id().value().toString())); + obj.put("title", new JsonString(workflow.title())); + obj.put("status", new JsonString(workflow.status().name())); + obj.put("createdAt", new JsonString(workflow.createdAt().toString())); + obj.put("lastOpenedAt", new JsonString(workflow.lastOpenedAt().toString())); + obj.put("brief", workflow.brief() + .map(ApplicationStateCodec::workflowBriefToJson) + .orElse(JsonValue.JsonNull.INSTANCE)); + return obj; + } + + private static JsonValue workflowBriefToJson(WorkflowBrief brief) { + JsonObject obj = JsonObject.empty(); + obj.put("goal", new JsonString(brief.goal())); + obj.put("nextStep", new JsonString(brief.nextStep())); + obj.put("approach", optionalStringToJson(brief.approach())); + obj.put("decisions", optionalStringToJson(brief.decisions())); + obj.put("ruledOut", optionalStringToJson(brief.ruledOut())); + obj.put("corrections", optionalStringToJson(brief.corrections())); + obj.put("writtenAt", new JsonString(brief.writtenAt().toString())); + obj.put("author", new JsonString(brief.author().name())); + return obj; + } + private static JsonValue optionalStringToJson(Optional value) { return value.map(JsonString::new).orElse(JsonValue.JsonNull.INSTANCE); } @@ -338,7 +377,9 @@ public static ApplicationState fromJson(JsonValue value) { ? uiFromJson(asObject(root.get("ui"), "ui")) : WorkspaceUiState.empty(); - return new ApplicationState(repositories, sessions, ui, handoffBriefsFromJson(root)); + List workflows = workflowsFromJson(root); + + return new ApplicationState(repositories, sessions, workflows, ui, handoffBriefsFromJson(root)); } /** @@ -499,13 +540,18 @@ private static ManagedAgentSession sessionFromJson(JsonObject obj) { // existed was not an eval session, and a malformed value must not // silently put a session on the eval account. boolean evalMode = obj.get("evalMode") instanceof JsonBoolean em && em.value(); + // Lenient like forkedFrom: a session persisted before this member + // existed was not part of a workflow, and an unparseable id must + // not discard the session. + Optional workflowId = optionalString(obj, "workflowId") + .flatMap(ApplicationStateCodec::parseWorkflowId); // The persisted shape stays FLAT: grouping is a Java-side change, // and schemaVersion must not move for it. return new ManagedAgentSession(id, repositoryId, displayName, new AgentBinding(agentKind, agentSessionId, agentSessionName), new SessionWorkspace(workingDirectory, worktreeRoot, branchCreatedHere), status, createdAt, lastOpenedAt, lastExitCode, - PrLink.fromPersisted(prState, prNumber), namePinned, forkedFrom, evalMode); + PrLink.fromPersisted(prState, prNumber), namePinned, forkedFrom, evalMode, workflowId); } catch (IllegalArgumentException | DateTimeException e) { throw new StateDecodeException("Malformed session entry: " + e.getMessage()); } @@ -519,6 +565,79 @@ private static Optional parseSessionId(String value) { } } + private static Optional parseWorkflowId(String value) { + try { + return Optional.of(WorkflowId.of(value)); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + /** + * Lenient, like {@link #handoffBriefsFromJson}: a missing or malformed + * {@code workflows} array yields an empty list rather than failing the + * whole state file. Absent member (state written before this feature) + * yields no workflows. No schema bump. + */ + private static List workflowsFromJson(JsonObject root) { + if (!(root.get("workflows") instanceof JsonArray array)) { + return List.of(); + } + List workflows = new ArrayList<>(); + for (JsonValue element : array.elements()) { + workflowFromJson(element).ifPresent(workflows::add); + } + return List.copyOf(workflows); + } + + private static Optional workflowFromJson(JsonValue element) { + if (!(element instanceof JsonObject obj)) { + return Optional.empty(); + } + try { + WorkflowId id = WorkflowId.of(requireString(obj, "id")); + String title = requireString(obj, "title"); + WorkflowStatus status = workflowStatusFromJson(obj); + Instant createdAt = Instant.parse(requireString(obj, "createdAt")); + Instant lastOpenedAt = Instant.parse(requireString(obj, "lastOpenedAt")); + Optional brief = workflowBriefFromJson(obj); + return Optional.of(new Workflow(id, title, status, createdAt, lastOpenedAt, brief)); + } catch (IllegalArgumentException | DateTimeException | StateDecodeException e) { + return Optional.empty(); // a bad workflow costs a workflow, never the state + } + } + + /** Absent or unrecognized decodes to {@link WorkflowStatus#OPEN}. */ + private static WorkflowStatus workflowStatusFromJson(JsonObject obj) { + if (obj.get("status") instanceof JsonString s) { + for (WorkflowStatus status : WorkflowStatus.values()) { + if (status.name().equals(s.value())) { + return status; + } + } + } + return WorkflowStatus.OPEN; + } + + private static Optional workflowBriefFromJson(JsonObject obj) { + if (!(obj.get("brief") instanceof JsonObject briefObj)) { + return Optional.empty(); + } + try { + return Optional.of(new WorkflowBrief( + requireString(briefObj, "goal"), + requireString(briefObj, "nextStep"), + optionalString(briefObj, "approach"), + optionalString(briefObj, "decisions"), + optionalString(briefObj, "ruledOut"), + optionalString(briefObj, "corrections"), + Instant.parse(requireString(briefObj, "writtenAt")), + handoffAuthorFromJson(briefObj))); + } catch (IllegalArgumentException | DateTimeException | StateDecodeException e) { + return Optional.empty(); + } + } + private static Optional optionalString(JsonObject obj, String key) { return obj.get(key) instanceof JsonString s ? Optional.of(s.value()) : Optional.empty(); } diff --git a/app/src/main/java/app/drydock/ui/MainWorkspace.java b/app/src/main/java/app/drydock/ui/MainWorkspace.java index cc4663dd..018c8882 100644 --- a/app/src/main/java/app/drydock/ui/MainWorkspace.java +++ b/app/src/main/java/app/drydock/ui/MainWorkspace.java @@ -18,6 +18,7 @@ import app.drydock.domain.ManagedSessionId; import app.drydock.domain.Repository; import app.drydock.domain.SessionActivity; +import app.drydock.domain.Workflow; import app.drydock.domain.SessionStatus; import app.drydock.domain.SshRemote; import app.drydock.domain.UiTheme; @@ -2959,6 +2960,31 @@ public CompletableFuture writeHandoffFromAgent(ManagedSessionId id return written; } + /** + * Writes the caller's workflow brief (no {@code writtenAtCommit}: + * a workflow spans multiple repos and branches). No git call, so the + * budget is the FX-hop budget alone. + */ + public CompletableFuture writeWorkflowHandoffFromAgent(ManagedSessionId id, HandoffDraft draft) { + long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(AGENT_HANDOFF_BUDGET_SECONDS); + CompletableFuture written = new CompletableFuture<>(); + Platform.runLater(() -> { + if (expired(deadlineNanos)) { + written.completeExceptionally(new IllegalStateException( + "Drydock was too busy to record the workflow brief in time.")); + return; + } + try { + Workflow workflow = sessionManager.applyAgentWorkflowHandoff(id, draft); + publishSessions(); + written.complete(workflow); + } catch (RuntimeException e) { + written.completeExceptionally(e); + } + }); + return written; + } + // ---- handoff: driving the banner verbs and the header handoff control ---- /** diff --git a/app/src/test/java/app/drydock/mcp/FakeMcpSessionContext.java b/app/src/test/java/app/drydock/mcp/FakeMcpSessionContext.java index db984e75..055f349f 100644 --- a/app/src/test/java/app/drydock/mcp/FakeMcpSessionContext.java +++ b/app/src/test/java/app/drydock/mcp/FakeMcpSessionContext.java @@ -2,6 +2,8 @@ import app.drydock.domain.HandoffBrief; import app.drydock.domain.ManagedSessionId; +import app.drydock.domain.Workflow; +import app.drydock.domain.WorkflowId; import app.drydock.mcp.McpSessionContext.RenameKind; import app.drydock.mcp.McpSessionContext.RenameOutcome; import app.drydock.review.ReviewAnnotation; @@ -273,6 +275,27 @@ public HandoffBrief writeHandoff(ManagedSessionId caller, HandoffDraft draft) th Instant.parse("2026-08-12T10:15:30Z"), Optional.of("abc1234"), HandoffBrief.Author.AGENT); return lastHandoff; } + + /** The last workflow brief written through {@link #writeWorkflowHandoff}, if any. */ + private Workflow lastWorkflowHandoff; + + Optional lastWorkflowHandoff() { + return Optional.ofNullable(lastWorkflowHandoff); + } + + @Override + public Workflow writeWorkflowHandoff(ManagedSessionId caller, HandoffDraft draft) throws McpToolException { + if (handoffFailure != null) { + throw handoffFailure; + } + lastWorkflowHandoff = new Workflow(WorkflowId.of("00000000-0000-0000-0000-000000000001"), + "test-workflow", app.drydock.domain.WorkflowStatus.OPEN, + Instant.parse("2026-08-12T10:00:00Z"), Instant.parse("2026-08-12T10:00:00Z"), + Optional.of(new app.drydock.domain.WorkflowBrief(draft.goal(), draft.nextStep(), + draft.approach(), draft.decisions(), draft.ruledOut(), draft.corrections(), + Instant.parse("2026-08-12T10:15:30Z"), HandoffBrief.Author.AGENT))); + return lastWorkflowHandoff; + } private final List renameCalls = new ArrayList<>(); private McpToolException renameFailure; diff --git a/app/src/test/java/app/drydock/mcp/McpToolRouterSessionHandoffTest.java b/app/src/test/java/app/drydock/mcp/McpToolRouterSessionHandoffTest.java index 7a784fab..afa342f2 100644 --- a/app/src/test/java/app/drydock/mcp/McpToolRouterSessionHandoffTest.java +++ b/app/src/test/java/app/drydock/mcp/McpToolRouterSessionHandoffTest.java @@ -2,6 +2,7 @@ import app.drydock.domain.HandoffBrief; import app.drydock.domain.ManagedSessionId; +import app.drydock.domain.Workflow; import app.drydock.mcp.McpSessionRegistry.Spawn; import app.drydock.state.json.JsonValue; import app.drydock.state.json.JsonValue.JsonObject; @@ -190,4 +191,22 @@ void isAdvertisedInTheToolList() { .anyMatch(descriptor -> JsonPeek.str(descriptor, "name").equals("session_handoff")), "an agent only calls a tool it can see"); } + + @Test + void workflowTargetWritesTheWorkflowBrief() throws Exception { + JsonValue result = handoff(minimal().put("workflow", new app.drydock.state.json.JsonValue.JsonBoolean(true))); + + assertEquals("written", str(result, "outcome")); + assertEquals("workflow", str(result, "target")); + Workflow workflow = context.lastWorkflowHandoff().orElseThrow(); + assertEquals("Ship the fork gesture", workflow.brief().orElseThrow().goal()); + } + + @Test + void perSessionBriefIsWrittenWhenWorkflowFlagIsAbsent() throws Exception { + handoff(minimal()); + + assertTrue(context.lastHandoff().isPresent()); + assertTrue(context.lastWorkflowHandoff().isEmpty()); + } } diff --git a/app/src/test/java/app/drydock/mcp/WorkspaceMcpSessionContextTest.java b/app/src/test/java/app/drydock/mcp/WorkspaceMcpSessionContextTest.java index bf17ea4a..7a2338b2 100644 --- a/app/src/test/java/app/drydock/mcp/WorkspaceMcpSessionContextTest.java +++ b/app/src/test/java/app/drydock/mcp/WorkspaceMcpSessionContextTest.java @@ -620,6 +620,8 @@ private WorkspaceMcpSessionContext contextWith(Path root, List repos new UnsupportedOperationException("no window in this test")), (id, title) -> CompletableFuture.completedFuture(new RenameOutcome(RenameKind.RENAMED, title)), reclaimer, + (id, draft) -> CompletableFuture.failedFuture( + new UnsupportedOperationException("no workspace in this test")), (id, draft) -> CompletableFuture.failedFuture( new UnsupportedOperationException("no workspace in this test"))); } diff --git a/app/src/test/java/app/drydock/state/ApplicationStateCodecTest.java b/app/src/test/java/app/drydock/state/ApplicationStateCodecTest.java index fc32b741..68262a79 100644 --- a/app/src/test/java/app/drydock/state/ApplicationStateCodecTest.java +++ b/app/src/test/java/app/drydock/state/ApplicationStateCodecTest.java @@ -14,6 +14,10 @@ import app.drydock.domain.RepositorySettings; import app.drydock.domain.SessionStatus; import app.drydock.domain.SshRemote; +import app.drydock.domain.Workflow; +import app.drydock.domain.WorkflowBrief; +import app.drydock.domain.WorkflowId; +import app.drydock.domain.WorkflowStatus; import app.drydock.domain.WorkspaceUiState; import app.drydock.review.SessionReviewScopes; import app.drydock.state.json.JsonParser; @@ -44,6 +48,7 @@ class ApplicationStateCodecTest { private static final String REPO_ID = "11111111-2222-3333-4444-555555555555"; + private static final String SESSION_ID = "22222222-3333-4444-5555-666666666666"; private static final String OTHER_ID = "99999999-8888-7777-6666-555555555555"; private static final String SESSION_WITHOUT_PROVENANCE = """ @@ -140,7 +145,7 @@ void remoteRepositoryRoundTrips() { SshRemote remote = new SshRemote("user@h", "/srv/app"); Repository repo = new Repository(RepositoryId.newId(), remote.placeholderRoot(), "app", Instant.EPOCH, Instant.EPOCH, RepositorySettings.DEFAULT, remote); - ApplicationState state = new ApplicationState(List.of(repo), List.of(), WorkspaceUiState.empty(), List.of()); + ApplicationState state = new ApplicationState(List.of(repo), List.of(), List.of(), WorkspaceUiState.empty(), List.of()); ApplicationState decoded = ApplicationStateCodec.fromJson(ApplicationStateCodec.toJson(state)); @@ -154,7 +159,7 @@ void remoteRepositoryRoundTrips() { void repositoryWithoutRemoteMemberDecodesAsLocal() { Repository repo = new Repository(RepositoryId.newId(), Path.of("/tmp/x"), "x", Instant.EPOCH, Instant.EPOCH, RepositorySettings.DEFAULT); - ApplicationState state = new ApplicationState(List.of(repo), List.of(), WorkspaceUiState.empty(), List.of()); + ApplicationState state = new ApplicationState(List.of(repo), List.of(), List.of(), WorkspaceUiState.empty(), List.of()); JsonValue json = ApplicationStateCodec.toJson(state); // A local repo writes no "remote" member at all (older builds must @@ -171,7 +176,7 @@ void malformedRemoteMemberDecodesAsLocalNotCorrupt() { // user their whole state file. Repository repo = new Repository(RepositoryId.newId(), Path.of("/tmp/x"), "x", Instant.EPOCH, Instant.EPOCH, RepositorySettings.DEFAULT); - ApplicationState state = new ApplicationState(List.of(repo), List.of(), WorkspaceUiState.empty(), List.of()); + ApplicationState state = new ApplicationState(List.of(repo), List.of(), List.of(), WorkspaceUiState.empty(), List.of()); JsonObject json = (JsonObject) ApplicationStateCodec.toJson(state); JsonObject repoObj = (JsonObject) ((JsonArray) json.get("repositories")).elements().getFirst(); JsonObject badRemote = JsonObject.empty(); @@ -238,7 +243,7 @@ void sessionWithBranchCreatedHereFalseRoundTrips() { new SessionWorkspace(Path.of("/tmp/repo/wd"), Optional.empty(), false), SessionStatus.INACTIVE, Instant.EPOCH, Instant.EPOCH, Optional.empty(), PrLink.of(PrState.NONE, Optional.empty()), false, Optional.empty()); - ApplicationState state = new ApplicationState(List.of(repo), List.of(session), WorkspaceUiState.empty(), List.of()); + ApplicationState state = new ApplicationState(List.of(repo), List.of(session), List.of(), WorkspaceUiState.empty(), List.of()); ApplicationState decoded = ApplicationStateCodec.fromJson(ApplicationStateCodec.toJson(state)); @@ -255,7 +260,7 @@ void namePinnedSurvivesARoundTrip() { new SessionWorkspace(Path.of("/tmp/repo/wd"), Optional.empty(), true), SessionStatus.INACTIVE, Instant.EPOCH, Instant.EPOCH, Optional.empty(), PrLink.of(PrState.NONE, Optional.empty()), true, Optional.empty()); - ApplicationState state = new ApplicationState(List.of(repo), List.of(pinned), WorkspaceUiState.empty(), List.of()); + ApplicationState state = new ApplicationState(List.of(repo), List.of(pinned), List.of(), WorkspaceUiState.empty(), List.of()); ApplicationState decoded = ApplicationStateCodec.fromJson(ApplicationStateCodec.toJson(state)); @@ -296,7 +301,7 @@ void evalModeRoundTrips() { new SessionWorkspace(Path.of("/tmp/repo/wd"), Optional.empty(), true), SessionStatus.INACTIVE, Instant.EPOCH, Instant.EPOCH, Optional.empty(), PrLink.of(PrState.NONE, Optional.empty()), false, Optional.empty(), true); - ApplicationState state = new ApplicationState(List.of(repo), List.of(eval), WorkspaceUiState.empty(), List.of()); + ApplicationState state = new ApplicationState(List.of(repo), List.of(eval), List.of(), WorkspaceUiState.empty(), List.of()); ApplicationState decoded = ApplicationStateCodec.fromJson(ApplicationStateCodec.toJson(state)); @@ -429,7 +434,7 @@ void sessionWithInvalidStatusThrowsStateDecodeException() { @Test void fontSizesRoundTrip() { - ApplicationState state = new ApplicationState(List.of(), List.of(), + ApplicationState state = new ApplicationState(List.of(), List.of(), List.of(), WorkspaceUiState.empty().withUiFontSize(15).withTerminalFontSize(11), List.of()); ApplicationState decoded = ApplicationStateCodec.fromJson( @@ -724,4 +729,96 @@ void anUnrecognizedReviewScopeChoiceValueDecodesAsLocal() { assertEquals(SessionReviewScopes.Choice.LOCAL, decoded.reviewScopeChoices().get(ManagedSessionId.of(OTHER_ID))); } + + @Test + void aWorkflowRoundTripsThroughTheCodec() { + WorkflowId workflowId = WorkflowId.newId(); + Instant created = Instant.parse("2026-08-28T10:00:00Z"); + Instant opened = Instant.parse("2026-08-28T11:30:00Z"); + WorkflowBrief brief = new WorkflowBrief("Ship the billing migration", + "Cut over the frontend to the new API", + java.util.Optional.of("Strangler-fig pattern"), + java.util.Optional.empty(), + java.util.Optional.of("Big-bang rewrite: too risky"), + java.util.Optional.empty(), + Instant.parse("2026-08-28T11:00:00Z"), HandoffBrief.Author.AGENT); + Workflow workflow = new Workflow(workflowId, "billing-migration", + WorkflowStatus.OPEN, created, opened, java.util.Optional.of(brief)); + + ApplicationState state = new ApplicationState(List.of(), List.of(), List.of(workflow), + WorkspaceUiState.empty(), List.of()); + JsonValue json = ApplicationStateCodec.toJson(state); + ApplicationState decoded = ApplicationStateCodec.fromJson(json); + + assertEquals(1, decoded.workflows().size()); + Workflow restored = decoded.workflows().get(0); + assertEquals(workflowId, restored.id()); + assertEquals("billing-migration", restored.title()); + assertEquals(WorkflowStatus.OPEN, restored.status()); + assertEquals(created, restored.createdAt()); + assertEquals(opened, restored.lastOpenedAt()); + assertTrue(restored.brief().isPresent()); + WorkflowBrief restoredBrief = restored.brief().get(); + assertEquals(brief.goal(), restoredBrief.goal()); + assertEquals(brief.nextStep(), restoredBrief.nextStep()); + assertEquals(brief.approach(), restoredBrief.approach()); + assertEquals(brief.ruledOut(), restoredBrief.ruledOut()); + assertEquals(brief.author(), restoredBrief.author()); + } + + @Test + void aSessionWithAWorkflowIdRoundTrips() { + WorkflowId workflowId = WorkflowId.newId(); + ManagedAgentSession session = new ManagedAgentSession(ManagedSessionId.newId(), + RepositoryId.newId(), "test-session", + new AgentBinding(AgentKind.CLAUDE, java.util.Optional.empty(), java.util.Optional.empty()), + SessionWorkspace.inRepository(Path.of("/tmp/repo")), + SessionStatus.INACTIVE, Instant.parse("2026-08-28T10:00:00Z"), + Instant.parse("2026-08-28T10:00:00Z"), java.util.Optional.empty(), + PrLink.none(), false, java.util.Optional.empty(), false, + java.util.Optional.of(workflowId)); + + ApplicationState state = new ApplicationState(List.of(), List.of(session), List.of(), + WorkspaceUiState.empty(), List.of()); + JsonValue json = ApplicationStateCodec.toJson(state); + ApplicationState decoded = ApplicationStateCodec.fromJson(json); + + assertEquals(1, decoded.sessions().size()); + assertEquals(java.util.Optional.of(workflowId), decoded.sessions().get(0).workflowId()); + } + + @Test + void aStateWithoutWorkflowsDecodesToAnEmptyList() { + // Old state written before workflows existed has no "workflows" member; + // it must decode to an empty list, not fail. + JsonValue json = JsonParser.parse(""" + {"schemaVersion":2,"repositories":[],"sessions":[], + "ui":{"selectedRepositoryId":null,"sidebarWidth":288.0, + "expandedRepositoryIds":[],"theme":"DARK"}} + """); + + ApplicationState decoded = ApplicationStateCodec.fromJson(json); + assertTrue(decoded.workflows().isEmpty()); + } + + @Test + void aSessionWithoutWorkflowIdDecodesAsUnaffiliated() { + // Old state written before workflowId existed; the session must + // decode with an empty workflowId, not fail. + JsonValue json = JsonParser.parse(""" + {"schemaVersion":2,"repositories":[], + "sessions":[{"id":"%s","repositoryId":"%s","displayName":"old-session", + "agentKind":"claude","agentSessionId":null,"agentSessionName":null, + "workingDirectory":"/tmp/repo","worktreeRoot":null,"status":"INACTIVE", + "createdAt":"2026-01-01T00:00:00Z","lastOpenedAt":"2026-01-01T00:00:00Z", + "lastExitCode":null,"prState":"NONE","prNumber":null, + "branchCreatedHere":false,"namePinned":false,"forkedFrom":null,"evalMode":false}], + "ui":{"selectedRepositoryId":null,"sidebarWidth":288.0, + "expandedRepositoryIds":[],"theme":"DARK"}} + """.formatted(SESSION_ID, REPO_ID)); + + ApplicationState decoded = ApplicationStateCodec.fromJson(json); + assertEquals(1, decoded.sessions().size()); + assertTrue(decoded.sessions().get(0).workflowId().isEmpty()); + } } diff --git a/app/src/test/java/app/drydock/state/JsonApplicationStateRepositoryTest.java b/app/src/test/java/app/drydock/state/JsonApplicationStateRepositoryTest.java index 964c53ce..3f12588c 100644 --- a/app/src/test/java/app/drydock/state/JsonApplicationStateRepositoryTest.java +++ b/app/src/test/java/app/drydock/state/JsonApplicationStateRepositoryTest.java @@ -66,7 +66,7 @@ void saveThenLoadRoundTripsRepositoriesAndUiState() throws IOException { Optional.of(repo.id()), 321.0, Set.of(repo.id()), app.drydock.domain.UiTheme.LIGHT, WorkspaceUiState.DEFAULT_UI_FONT_SIZE, WorkspaceUiState.DEFAULT_TERMINAL_FONT_SIZE, List.of(), Optional.empty(), Map.of()); - ApplicationState state = new ApplicationState(List.of(repo), List.of(), ui, List.of()); + ApplicationState state = new ApplicationState(List.of(repo), List.of(), List.of(), ui, List.of()); JsonApplicationStateRepository repository = new JsonApplicationStateRepository(stateFile()); repository.save(state); @@ -137,7 +137,7 @@ void saveThenLoadRoundTripsManagedAgentSessions() throws IOException { new SessionWorkspace(workingDirectory, Optional.of(worktreeRoot), true), SessionStatus.EXITED, Instant.parse("2026-01-03T00:00:00Z"), Instant.parse("2026-01-04T00:00:00Z"), Optional.of(1), PrLink.of(PrState.OPEN, Optional.of(128)), false, Optional.empty()); - ApplicationState state = new ApplicationState(List.of(repo), List.of(session), WorkspaceUiState.empty(), List.of()); + ApplicationState state = new ApplicationState(List.of(repo), List.of(session), List.of(), WorkspaceUiState.empty(), List.of()); JsonApplicationStateRepository repository = new JsonApplicationStateRepository(stateFile()); repository.save(state); @@ -158,7 +158,7 @@ void saveThenLoadRoundTripsSessionWithNoOptionalFieldsSet() throws IOException { new SessionWorkspace(workingDirectory, Optional.empty(), true), SessionStatus.INACTIVE, Instant.parse("2026-01-03T00:00:00Z"), Instant.parse("2026-01-04T00:00:00Z"), Optional.empty(), PrLink.of(PrState.NONE, Optional.empty()), false, Optional.empty()); - ApplicationState state = new ApplicationState(List.of(repo), List.of(session), WorkspaceUiState.empty(), List.of()); + ApplicationState state = new ApplicationState(List.of(repo), List.of(session), List.of(), WorkspaceUiState.empty(), List.of()); JsonApplicationStateRepository repository = new JsonApplicationStateRepository(stateFile()); repository.save(state); @@ -262,7 +262,7 @@ void saveRetainsBackupOfPreviousStateFile() throws IOException { repository.save(ApplicationState.empty()); Path repoRoot = Files.createDirectory(tempDir.resolve("second-root")); - ApplicationState secondState = new ApplicationState(List.of(sampleRepository(repoRoot)), List.of(), WorkspaceUiState.empty(), List.of()); + ApplicationState secondState = new ApplicationState(List.of(sampleRepository(repoRoot)), List.of(), List.of(), WorkspaceUiState.empty(), List.of()); repository.save(secondState); Path backup = stateFile().resolveSibling("state.json.bak");