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"); 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