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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/src/main/java/app/drydock/DrydockApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
148 changes: 147 additions & 1 deletion app/src/main/java/app/drydock/app/SessionManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}

/**
Expand Down Expand Up @@ -1276,6 +1281,147 @@ public boolean mayDeleteBranchOf(Path worktreeRoot) {
return BranchOwnership.mayDeleteBranchOf(sessions(), worktreeRoot);
}

// ---- workflows -----------------------------------------------------------

/** Every persisted workflow, in persisted order. */
public List<Workflow> workflows() {
return stateStore.state().workflows();
}

/** The workflow with this id, if any. */
public Optional<Workflow> 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<Workflow> 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<Workflow> remaining = state.workflows().stream()
.filter(w -> !w.id().equals(id))
.toList();
List<ManagedAgentSession> 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> 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<Workflow> 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<ManagedAgentSession> updated = new ArrayList<>(state.sessions());
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/app/drydock/app/UnknownWorkflowException.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
16 changes: 11 additions & 5 deletions app/src/main/java/app/drydock/domain/ApplicationState.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,34 +22,40 @@
public record ApplicationState(
List<Repository> repositories,
List<ManagedAgentSession> sessions,
List<Workflow> workflows,
WorkspaceUiState ui,
List<HandoffBrief> handoffBriefs
) {

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<Repository> newRepositories) {
return new ApplicationState(newRepositories, sessions, ui, handoffBriefs);
return new ApplicationState(newRepositories, sessions, workflows, ui, handoffBriefs);
}

public ApplicationState withSessions(List<ManagedAgentSession> newSessions) {
return new ApplicationState(repositories, newSessions, ui, handoffBriefs);
return new ApplicationState(repositories, newSessions, workflows, ui, handoffBriefs);
}

public ApplicationState withWorkflows(List<Workflow> 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<HandoffBrief> newHandoffBriefs) {
return new ApplicationState(repositories, sessions, ui, newHandoffBriefs);
return new ApplicationState(repositories, sessions, workflows, ui, newHandoffBriefs);
}
}
43 changes: 38 additions & 5 deletions app/src/main/java/app/drydock/domain/ManagedAgentSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ public record ManagedAgentSession(
PrLink pr,
boolean namePinned,
Optional<ManagedSessionId> forkedFrom,
boolean evalMode
boolean evalMode,
Optional<WorkflowId> workflowId
) {

public ManagedAgentSession {
Expand All @@ -107,22 +108,37 @@ 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");
}
}

/**
* 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<Integer> lastExitCode,
PrLink pr, boolean namePinned, Optional<ManagedSessionId> 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<Integer> lastExitCode,
PrLink pr, boolean namePinned, Optional<ManagedSessionId> 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 --------------------------
Expand Down Expand Up @@ -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<WorkflowId> 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
Expand All @@ -239,6 +264,7 @@ public static final class Builder {
private boolean namePinned;
private Optional<ManagedSessionId> forkedFrom;
private boolean evalMode;
private Optional<WorkflowId> workflowId;

private Builder(ManagedAgentSession from) {
this.id = from.id;
Expand All @@ -254,6 +280,7 @@ private Builder(ManagedAgentSession from) {
this.namePinned = from.namePinned;
this.forkedFrom = from.forkedFrom;
this.evalMode = from.evalMode;
this.workflowId = from.workflowId;
}

/**
Expand All @@ -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) {
Expand Down Expand Up @@ -344,9 +372,14 @@ public Builder evalMode(boolean value) {
return this;
}

public Builder workflowId(Optional<WorkflowId> 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);
}
}
}
Loading
Loading