From bb9bcbb5762a9a9fde21bc944a9c7c887d3ae489 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 13 Sep 2026 14:14:37 +0000 Subject: [PATCH 1/3] feat(application): generate plans from project goals and continue ChangeSet apply start-planning fills the plan from the project objective and acceptance instead of mockPlanFixture. confirm-plan can use that generated plan when no published graph is supplied. partially_applied ChangeSets get a same-session continue apply that reports partial failure honestly. Co-authored-by: NiceChen --- packages/application/src/index.ts | 4 + .../src/use-cases/authoring/authoring.ts | 358 +++++++++++++----- .../src/use-cases/authoring/index.ts | 2 + .../src/use-cases/authoring/lifecycle.ts | 5 + .../src/use-cases/planning/generate-plan.ts | 194 ++++++++++ .../src/use-cases/planning/index.ts | 7 +- .../src/use-cases/planning/mock-plan.ts | 6 +- .../use-cases/planning/workflow-version.ts | 46 +++ .../src/use-cases/projects/projects.ts | 96 ++++- .../src/use-cases/projects/service.ts | 3 + .../src/use-cases/projects/store.ts | 2 + 11 files changed, 602 insertions(+), 121 deletions(-) create mode 100644 packages/application/src/use-cases/planning/generate-plan.ts diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 69ca183..c9d72c5 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -11,11 +11,13 @@ export { confirmPlan as confirmSoftwarePlan, startPlanner, acceptPlannerArtifact, + generateSoftwareDevelopmentPlan, mockPlanFixture, MOCK_PLAN_DOCUMENT, parsePlanArtifact, describeWorkflowVersion, entryNodeIds, + planToExecutionGraph, HARD_MAX_DEPTH, HARD_MAX_TASKS, PLANNER_OUTPUT_SLOT, @@ -30,6 +32,8 @@ export type { ConfirmPlanCommand, ConfirmPlanResult, ConfirmedWorkflowVersion, + GenerateSoftwareDevelopmentPlanInput, + PlanAcceptanceInput, PlanApprovalRecord, PlanArtifact, PlanArtifactRecord, diff --git a/packages/application/src/use-cases/authoring/authoring.ts b/packages/application/src/use-cases/authoring/authoring.ts index 4af3921..05ae7b3 100644 --- a/packages/application/src/use-cases/authoring/authoring.ts +++ b/packages/application/src/use-cases/authoring/authoring.ts @@ -64,6 +64,15 @@ export interface ApplyAuthoringChangeSetInput { taskPatches?: readonly AuthoringTaskPatch[]; } +export interface ContinueAuthoringChangeSetInput { + operationId: string; + idempotencyKey: string; + changeSetId: string; + workflowDrafts?: readonly WorkflowDraftDto[]; + teamDrafts?: readonly TeamDraftDto[]; + taskPatches?: readonly AuthoringTaskPatch[]; +} + export interface StartAuthoringInput { operationId: string; idempotencyKey: string; @@ -1835,122 +1844,99 @@ export async function applyAuthoringChangeSet( ) { throw validationFailed(`authoring change set cannot apply from ${stored.status}`); } - const workflowDrafts = new Map( - (input.workflowDrafts ?? []).map((draft) => [ - draft.workflowId, - parseWorkflowDraft(draft), - ]), - ); - const teamDrafts = new Map( - (input.teamDrafts ?? []).map((draft) => [draft.teamId, parseTeamDraft(draft)]), - ); - const taskPatches = new Map( - (input.taskPatches ?? []).map((patch) => [patch.taskId, parseAuthoringTaskPatch(patch)]), - ); - if ( - workflowDrafts.size !== (input.workflowDrafts ?? []).length || - teamDrafts.size !== (input.teamDrafts ?? []).length || - taskPatches.size !== (input.taskPatches ?? []).length - ) { - throw validationFailed("authoring draft targets must be unique"); - } + const drafts = indexAuthoringDrafts(input); + assertUniqueAuthoringDrafts(input, drafts); const pendingSteps = stored.steps.filter((step) => step.status !== "applied"); for (const step of pendingSteps) { if (step.status !== "pending" && step.status !== "applying" && step.status !== "failed") { throw validationFailed("authoring change set steps must be pending before apply"); } - if (step.targetType === "task") { - if (!taskPatches.get(step.targetId)) { - throw validationFailed(`missing task draft for ${step.targetId}`); - } - continue; - } - if (step.targetType === "worker") { - throw validationFailed( - "authoring worker drafts land through chat confirmation, not change-set apply", - ); - } - const draft = - step.targetType === "workflow" - ? workflowDrafts.get(step.targetId) - : teamDrafts.get(step.targetId); - if (!draft) { - throw validationFailed(`missing ${step.targetType} draft for ${step.targetId}`); - } - assertDraftRevision( - ctx, - step.targetType, - step.targetId, - draft.revision, - step.expectedRevision, - ); - if ( - (step.targetType === "workflow" && ctx.world.workflowDrafts.has(draft.id)) || - (step.targetType === "team" && ctx.world.teamDrafts.has(draft.id)) - ) { - throw validationFailed(`authoring draft ${draft.id} already exists`); - } + assertAuthoringStepReady(ctx, step, drafts); } - const now = ctx.world.nowIso(); - const steps = stored.steps.map((step) => ({ ...step })); - let halted: { code: string; message: string } | undefined; - for (const step of steps) { - if (step.status === "applied") continue; - if (halted) continue; - try { - const resultRevision = applyAuthoringStep(ctx, { - step, - workflowDrafts, - teamDrafts, - taskPatches, - now, - }); - step.status = "applied"; - step.resultRevision = resultRevision; - step.completedAt = now; - delete step.failure; - } catch (error) { - const failure = toStepFailure(error); - step.status = "failed"; - step.failure = failure; - step.completedAt = now; - halted = failure; + const applied = persistAppliedChangeSet(ctx, stored, { + drafts, + now: ctx.world.nowIso(), + resetFailed: false, + }); + await appendChangeSetApplyEvent(ctx, tx, input.operationId, applied); + return applied; + }, + ); + return { reused: result.reused, changeSet: result.value }; + }); +} + +/** + * Continues a `partially_applied` ChangeSet in the same session: remaining + * (failed/pending) steps are retried in order. Already-applied steps stay. + * The result is `applied` only when every step is applied. + */ +export async function continueAuthoringChangeSet( + ctx: AppContext, + input: ContinueAuthoringChangeSetInput, +): Promise<{ reused: boolean; changeSet: AuthoringChangeSetDto }> { + return ctx.world.uow.withTransaction(async (tx) => { + const result = await withIdempotency( + ctx.world, + tx, + { + operationId: input.operationId, + digest: digestOf({ + changeSetId: input.changeSetId, + workflowDrafts: input.workflowDrafts ?? [], + teamDrafts: input.teamDrafts ?? [], + taskPatches: input.taskPatches ?? [], + }), + scope: { + principalId: ctx.principalId, + clientId: ctx.clientId, + canonicalOperation: "authoring.continue-change-set", + resource: `authoring_change_set:${input.changeSetId}`, + idempotencyKey: input.idempotencyKey, + }, + }, + async () => { + const stored = ctx.world.authoringChangeSets.get(input.changeSetId); + if (!stored) { + throw notFound("authoring change set", input.changeSetId); + } + if (stored.status !== "partially_applied" && stored.status !== "applying") { + throw validationFailed(`authoring change set cannot continue from ${stored.status}`); + } + const remaining = stored.steps.filter((step) => step.status !== "applied"); + if (remaining.length === 0) { + throw validationFailed("authoring change set has no remaining steps to apply"); + } + const project = requireProject(ctx, stored.projectId); + if (project.organizationId !== stored.organizationId) { + throw validationFailed("authoring change set organization does not match project"); + } + const sourceRun = ctx.world.runs.get(stored.sourceRunId); + if (!sourceRun) { + throw notFound("source run", stored.sourceRunId); + } + if (sourceRun.projectId !== stored.projectId) { + throw validationFailed("authoring source run does not belong to the project"); + } + const drafts = indexAuthoringDrafts(input); + assertUniqueAuthoringDrafts(input, drafts); + for (const step of remaining) { + if ( + step.status !== "pending" && + step.status !== "applying" && + step.status !== "failed" + ) { + throw validationFailed("authoring change set steps must be pending before apply"); } } - - const appliedCount = steps.filter((step) => step.status === "applied").length; - const failedCount = steps.filter((step) => step.status === "failed").length; - const status = - halted === undefined ? "applied" : appliedCount > 0 ? "partially_applied" : "failed"; - const applied = parseAuthoringChangeSet({ - ...stored, - status, - updatedAt: now, - steps, - ...(halted && status === "failed" ? { failure: halted } : {}), - }); - ctx.world.authoringChangeSets.set(applied.id, applied); - await appendEvent(ctx.world, tx, { - type: - status === "applied" - ? "workflow.authoring.applied" - : status === "partially_applied" - ? "workflow.authoring.partially_applied" - : "workflow.authoring.failed", - subjectType: "authoring_change_set", - subjectId: applied.id, - projectId: applied.projectId, - runId: applied.sourceRunId, - correlationId: input.operationId, - data: { - status: applied.status, - stepCount: applied.steps.length, - appliedCount, - failedCount, - }, + const applied = persistAppliedChangeSet(ctx, stored, { + drafts, + now: ctx.world.nowIso(), + resetFailed: true, }); + await appendChangeSetApplyEvent(ctx, tx, input.operationId, applied); return applied; }, ); @@ -2015,6 +2001,168 @@ function changeSetIdentityDigest(changeSet: AuthoringChangeSetDto): string { }); } +interface AuthoringDraftIndex { + workflowDrafts: Map; + teamDrafts: Map; + taskPatches: Map; +} + +function indexAuthoringDrafts(input: { + workflowDrafts?: readonly WorkflowDraftDto[]; + teamDrafts?: readonly TeamDraftDto[]; + taskPatches?: readonly AuthoringTaskPatch[]; +}): AuthoringDraftIndex { + return { + workflowDrafts: new Map( + (input.workflowDrafts ?? []).map((draft) => [draft.workflowId, parseWorkflowDraft(draft)]), + ), + teamDrafts: new Map( + (input.teamDrafts ?? []).map((draft) => [draft.teamId, parseTeamDraft(draft)]), + ), + taskPatches: new Map( + (input.taskPatches ?? []).map((patch) => [patch.taskId, parseAuthoringTaskPatch(patch)]), + ), + }; +} + +function assertUniqueAuthoringDrafts( + input: { + workflowDrafts?: readonly WorkflowDraftDto[]; + teamDrafts?: readonly TeamDraftDto[]; + taskPatches?: readonly AuthoringTaskPatch[]; + }, + drafts: AuthoringDraftIndex, +): void { + if ( + drafts.workflowDrafts.size !== (input.workflowDrafts ?? []).length || + drafts.teamDrafts.size !== (input.teamDrafts ?? []).length || + drafts.taskPatches.size !== (input.taskPatches ?? []).length + ) { + throw validationFailed("authoring draft targets must be unique"); + } +} + +function assertAuthoringStepReady( + ctx: AppContext, + step: AuthoringChangeSetDto["steps"][number], + drafts: AuthoringDraftIndex, +): void { + if (step.targetType === "task") { + if (!drafts.taskPatches.get(step.targetId)) { + throw validationFailed(`missing task draft for ${step.targetId}`); + } + return; + } + if (step.targetType === "worker") { + throw validationFailed( + "authoring worker drafts land through chat confirmation, not change-set apply", + ); + } + const draft = + step.targetType === "workflow" + ? drafts.workflowDrafts.get(step.targetId) + : drafts.teamDrafts.get(step.targetId); + if (!draft) { + throw validationFailed(`missing ${step.targetType} draft for ${step.targetId}`); + } + assertDraftRevision(ctx, step.targetType, step.targetId, draft.revision, step.expectedRevision); + if ( + (step.targetType === "workflow" && ctx.world.workflowDrafts.has(draft.id)) || + (step.targetType === "team" && ctx.world.teamDrafts.has(draft.id)) + ) { + throw validationFailed(`authoring draft ${draft.id} already exists`); + } +} + +function persistAppliedChangeSet( + ctx: AppContext, + stored: AuthoringChangeSetDto, + input: { drafts: AuthoringDraftIndex; now: string; resetFailed: boolean }, +): AuthoringChangeSetDto { + const steps = stored.steps.map((step) => { + if (step.status === "applied") return { ...step }; + if (!input.resetFailed) return { ...step }; + const { + failure: _failure, + startedAt: _started, + completedAt: _completed, + resultRevision: _result, + ...rest + } = step; + return { ...rest, status: "pending" as const }; + }); + let halted: { code: string; message: string } | undefined; + for (const step of steps) { + if (step.status === "applied") continue; + if (halted) continue; + try { + assertAuthoringStepReady(ctx, step, input.drafts); + const resultRevision = applyAuthoringStep(ctx, { + step, + workflowDrafts: input.drafts.workflowDrafts, + teamDrafts: input.drafts.teamDrafts, + taskPatches: input.drafts.taskPatches, + now: input.now, + }); + step.status = "applied"; + step.resultRevision = resultRevision; + step.completedAt = input.now; + delete step.failure; + } catch (error) { + const failure = toStepFailure(error); + step.status = "failed"; + step.failure = failure; + step.completedAt = input.now; + halted = failure; + } + } + const appliedCount = steps.filter((step) => step.status === "applied").length; + const status = + appliedCount === steps.length + ? "applied" + : appliedCount > 0 + ? "partially_applied" + : "failed"; + const { failure: _storedFailure, ...rest } = stored; + return parseAuthoringChangeSet({ + ...rest, + status, + updatedAt: input.now, + steps, + ...(status === "failed" && halted ? { failure: halted } : {}), + }); +} + +async function appendChangeSetApplyEvent( + ctx: AppContext, + tx: Tx, + operationId: string, + applied: AuthoringChangeSetDto, +): Promise { + const appliedCount = applied.steps.filter((step) => step.status === "applied").length; + const failedCount = applied.steps.filter((step) => step.status === "failed").length; + ctx.world.authoringChangeSets.set(applied.id, applied); + await appendEvent(ctx.world, tx, { + type: + applied.status === "applied" + ? "workflow.authoring.applied" + : applied.status === "partially_applied" + ? "workflow.authoring.partially_applied" + : "workflow.authoring.failed", + subjectType: "authoring_change_set", + subjectId: applied.id, + projectId: applied.projectId, + runId: applied.sourceRunId, + correlationId: operationId, + data: { + status: applied.status, + stepCount: applied.steps.length, + appliedCount, + failedCount, + }, + }); +} + function applyAuthoringStep( ctx: AppContext, input: { diff --git a/packages/application/src/use-cases/authoring/index.ts b/packages/application/src/use-cases/authoring/index.ts index c72e692..99e2c3f 100644 --- a/packages/application/src/use-cases/authoring/index.ts +++ b/packages/application/src/use-cases/authoring/index.ts @@ -1,6 +1,7 @@ export { adaptTeamDraftRepository, applyAuthoringChangeSet, + continueAuthoringChangeSet, confirmAuthoringChatProposal, parseAuthoringTaskPatch, recordAuthoringProposal, @@ -8,6 +9,7 @@ export { startAuthoring, validateAuthoringChangeSet, type ApplyAuthoringChangeSetInput, + type ContinueAuthoringChangeSetInput, type AuthoringChatProposalResolver, type AuthoringChatBindingProof, type AuthoringChatPatchBinding, diff --git a/packages/application/src/use-cases/authoring/lifecycle.ts b/packages/application/src/use-cases/authoring/lifecycle.ts index 486b3e0..a77d618 100644 --- a/packages/application/src/use-cases/authoring/lifecycle.ts +++ b/packages/application/src/use-cases/authoring/lifecycle.ts @@ -203,6 +203,11 @@ export async function cancelAuthoringChangeSet( }); } +/** + * Retry remaining steps: reset failed/pending to `pending` and move the + * ChangeSet to `applying`. Call `continueAuthoringChangeSet` (same session) + * to actually apply those steps; this command does not pretend they landed. + */ export async function retryAuthoringChangeSet( ctx: AppContext, input: RetryAuthoringChangeSetInput, diff --git a/packages/application/src/use-cases/planning/generate-plan.ts b/packages/application/src/use-cases/planning/generate-plan.ts new file mode 100644 index 0000000..eaa487c --- /dev/null +++ b/packages/application/src/use-cases/planning/generate-plan.ts @@ -0,0 +1,194 @@ +import type { AcceptanceCriterion } from "@workforce/protocol"; + +import { fnv1a64Hex } from "./digest.js"; +import { parsePlanArtifact } from "./schema.js"; +import { + PLAN_PROTOCOL, + PLAN_PROTOCOL_VERSION, + SOFTWARE_DEVELOPMENT_TEAM_BOUNDS, + SOFTWARE_DEVELOPMENT_TEAM_TEMPLATE_ID, + type PlanArtifact, +} from "./types.js"; + +const TEMPLATE_VERSION = "0.1.0"; +const POLICY_REF = "software-development-team.default@0.1.0"; +const WORKFLOW_ID = "software-development-team.feature-delivery"; + +const DEFAULT_CODE_OUTPUT = { + id: "out_code_change", + name: "Code changes", + kind: "code" as const, + required: true, + cardinality: { min: 1, max: 1 }, + mediaTypes: ["application/vnd.workforce.patch"], + schema: { type: "git_diff", baseRef: "immutable-base" }, +}; + +const DEFAULT_TEST_OUTPUT = { + id: "out_test_result", + name: "Slice test result", + kind: "evaluation" as const, + required: true, + mediaTypes: ["application/vnd.workforce.test-result+json"], +}; + +const DEFAULT_REVIEW_OUTPUT = { + id: "out_review_report", + name: "Code review report", + kind: "evaluation" as const, + required: true, + cardinality: { min: 1, max: 1 }, + mediaTypes: ["application/vnd.workforce.review+json"], +}; + +export type PlanAcceptanceInput = AcceptanceCriterion | string; + +export interface GenerateSoftwareDevelopmentPlanInput { + objective: string; + /** Project-level acceptance. Empty/absent still yields schema+test/review criteria. */ + acceptanceCriteria?: readonly PlanAcceptanceInput[]; + baseSha?: string; +} + +/** + * Builds a software-development-team Plan Artifact from the project objective + * and acceptance criteria. This is the start-planning source of truth; + * callers must not substitute `mockPlanFixture` for an empty body. + */ +export function generateSoftwareDevelopmentPlan( + input: GenerateSoftwareDevelopmentPlanInput, +): PlanArtifact { + const objective = input.objective.trim(); + if (objective === "") { + throw new Error("planner objective is required"); + } + const acceptance = normalizeAcceptance(input.acceptanceCriteria); + const developerAcceptance: AcceptanceCriterion[] = [ + { + id: "ac_patch_schema", + type: "schema", + schemaRef: "https://workforce.local/protocols/v0.1/expected-output.schema.json", + }, + { id: "ac_tests", type: "test", commandRef: "test.default" }, + ...acceptance, + ]; + const reviewerAcceptance: AcceptanceCriterion[] = [ + { id: "ac_review", type: "review" }, + { id: "ac_digest_match", type: "rule", commandRef: "artifact.digest.match" }, + ...acceptance, + ]; + const document = { + protocol: PLAN_PROTOCOL, + protocolVersion: PLAN_PROTOCOL_VERSION, + templateId: SOFTWARE_DEVELOPMENT_TEAM_TEMPLATE_ID, + templateVersion: TEMPLATE_VERSION, + workflowId: WORKFLOW_ID, + objective, + baseSha: input.baseSha ?? placeholderBaseSha(objective), + policyRef: POLICY_REF, + runtime: { adapterId: "mock", protocolVersion: "0.1" }, + bounds: { ...SOFTWARE_DEVELOPMENT_TEAM_BOUNDS }, + nodes: [ + { + id: "dev_alpha", + kind: "task", + role: "developer", + workerRef: "developer", + title: "Implement slice Alpha", + objective: `Deliver the Alpha slice for: ${objective}`, + expectedOutputs: [DEFAULT_CODE_OUTPUT, DEFAULT_TEST_OUTPUT], + acceptanceCriteria: developerAcceptance, + maxAttempts: SOFTWARE_DEVELOPMENT_TEAM_BOUNDS.maxAttempts, + maxReworkCycles: SOFTWARE_DEVELOPMENT_TEAM_BOUNDS.maxReworkCycles, + }, + { + id: "dev_bravo", + kind: "task", + role: "developer", + workerRef: "developer", + title: "Implement slice Bravo", + objective: `Deliver the Bravo slice for: ${objective}`, + expectedOutputs: [DEFAULT_CODE_OUTPUT, DEFAULT_TEST_OUTPUT], + acceptanceCriteria: developerAcceptance, + maxAttempts: SOFTWARE_DEVELOPMENT_TEAM_BOUNDS.maxAttempts, + maxReworkCycles: SOFTWARE_DEVELOPMENT_TEAM_BOUNDS.maxReworkCycles, + }, + { + id: "review_integration", + kind: "task", + role: "reviewer", + workerRef: "reviewer", + title: "Review integrated delivery", + objective: `Review the integrated worktree for: ${objective}`, + expectedOutputs: [DEFAULT_REVIEW_OUTPUT], + acceptanceCriteria: reviewerAcceptance, + maxAttempts: SOFTWARE_DEVELOPMENT_TEAM_BOUNDS.maxAttempts, + maxReworkCycles: SOFTWARE_DEVELOPMENT_TEAM_BOUNDS.maxReworkCycles, + }, + { + id: "approve_delivery", + kind: "approval", + role: "human", + gate: "artifact", + title: "Accept integrated delivery", + objective: `Human acceptance of the integrated digest for: ${objective}`, + }, + ], + edges: [ + { + id: "e_alpha_review", + from: "dev_alpha", + to: "review_integration", + onUpstream: "outputs_ready", + }, + { + id: "e_bravo_review", + from: "dev_bravo", + to: "review_integration", + onUpstream: "outputs_ready", + }, + { + id: "e_review_approve", + from: "review_integration", + to: "approve_delivery", + onUpstream: "outputs_ready", + }, + ], + integration: { + strategy: "stable_node_id_order", + worktree: "dedicated", + contributorNodeIds: ["dev_alpha", "dev_bravo"], + bindDigestTo: ["review_integration", "approve_delivery"], + onConflict: "human", + }, + }; + const parsed = parsePlanArtifact(document); + if (!parsed.ok) { + throw new Error(`generated plan is invalid: ${parsed.error.message}`); + } + return parsed.plan; +} + +function normalizeAcceptance( + input: readonly PlanAcceptanceInput[] | undefined, +): AcceptanceCriterion[] { + if (!input || input.length === 0) { + return [{ id: "ac_objective", type: "rule", commandRef: "project.objective" }]; + } + return input.map((item, index) => { + if (typeof item === "string") { + const text = item.trim(); + return { + id: `ac_project_${index + 1}`, + type: "rule", + commandRef: text.length > 0 ? text : "project.acceptance", + }; + } + return item; + }); +} + +function placeholderBaseSha(objective: string): string { + const hex = fnv1a64Hex(objective); + return `${hex}${hex}${hex}`.slice(0, 40); +} diff --git a/packages/application/src/use-cases/planning/index.ts b/packages/application/src/use-cases/planning/index.ts index 51dae71..4cb5778 100644 --- a/packages/application/src/use-cases/planning/index.ts +++ b/packages/application/src/use-cases/planning/index.ts @@ -1,9 +1,14 @@ export { acceptPlannerArtifact } from "./accept-plan.js"; export { confirmPlan } from "./confirm-plan.js"; +export { + generateSoftwareDevelopmentPlan, + type GenerateSoftwareDevelopmentPlanInput, + type PlanAcceptanceInput, +} from "./generate-plan.js"; export { mockPlanFixture, MOCK_PLAN_DOCUMENT } from "./mock-plan.js"; export { parsePlanArtifact } from "./schema.js"; export { startPlanner } from "./start-planner.js"; -export { describeWorkflowVersion, entryNodeIds } from "./workflow-version.js"; +export { describeWorkflowVersion, entryNodeIds, planToExecutionGraph } from "./workflow-version.js"; export type { AcceptPlannerArtifactDeps, ConfirmPlanDeps, diff --git a/packages/application/src/use-cases/planning/mock-plan.ts b/packages/application/src/use-cases/planning/mock-plan.ts index d8cfb71..0c12f9e 100644 --- a/packages/application/src/use-cases/planning/mock-plan.ts +++ b/packages/application/src/use-cases/planning/mock-plan.ts @@ -1,7 +1,11 @@ import { parsePlanArtifact } from "./schema.js"; import type { PlanArtifact } from "./types.js"; -/** Fixed sample Plan Artifact shape. Production planning uses startPlanner → Run output. */ +/** + * Test-only sample Plan Artifact. Production `:start-planning` generates from + * the project objective via `generateSoftwareDevelopmentPlan`; it must not + * fill an empty body with this fixture. + */ export const MOCK_PLAN_DOCUMENT = { protocol: "workforce.plan", protocolVersion: "0.1", diff --git a/packages/application/src/use-cases/planning/workflow-version.ts b/packages/application/src/use-cases/planning/workflow-version.ts index 6b13b1e..65efb55 100644 --- a/packages/application/src/use-cases/planning/workflow-version.ts +++ b/packages/application/src/use-cases/planning/workflow-version.ts @@ -1,3 +1,4 @@ +import type { WorkflowGraph, WorkflowNodeDefinition } from "../projects/engine-port.js"; import type { PlanArtifact, ConfirmedWorkflowEdge, @@ -46,6 +47,51 @@ export function toWorkflowEdges(plan: PlanArtifact): ConfirmedWorkflowEdge[] { })); } +/** + * Execution graph for confirm-plan when the caller does not supply a + * published WorkflowVersion graph. Approval nodes stay on the Plan Artifact; + * Runtime DAG nodes are tasks only. + */ +export function planToExecutionGraph(plan: PlanArtifact, graphId: string): WorkflowGraph { + const taskNodes = plan.nodes.filter((node) => node.kind === "task"); + const taskIds = new Set(taskNodes.map((node) => node.id)); + const entries = entryNodeIds(plan).filter((id) => taskIds.has(id)); + const nodes: WorkflowNodeDefinition[] = taskNodes.map((node) => { + const definition: WorkflowNodeDefinition = { + id: node.id, + kind: "task", + role: node.role, + requiresReview: node.role === "reviewer", + expectedOutputIds: node.expectedOutputs + .filter((output) => output.required) + .map((output) => output.id), + maxAttempts: node.maxAttempts, + maxReworkCycles: node.maxReworkCycles, + priority: node.role === "reviewer" ? 10 : 80, + }; + if (!entries.includes(node.id)) { + definition.joinPolicy = "all_success"; + } + return definition; + }); + return { + id: graphId, + workflowId: plan.workflowId, + version: 1, + entryNodeIds: entries, + terminalNodeIds: taskNodes.filter((node) => node.role === "reviewer").map((node) => node.id), + nodes, + edges: plan.edges + .filter((edge) => taskIds.has(edge.from) && taskIds.has(edge.to)) + .map((edge) => ({ + id: edge.id, + from: edge.from, + to: edge.to, + waitFor: edge.onUpstream, + })), + }; +} + export function describeWorkflowVersion(input: { workflowVersionId: string; version: number; diff --git a/packages/application/src/use-cases/projects/projects.ts b/packages/application/src/use-cases/projects/projects.ts index b96c655..55d34a0 100644 --- a/packages/application/src/use-cases/projects/projects.ts +++ b/packages/application/src/use-cases/projects/projects.ts @@ -1,5 +1,13 @@ import { DEFAULT_ORCHESTRATION_MODE, type OrchestrationMode } from "@workforce/protocol"; import { DEFAULT_PLACEMENT_INTENT } from "../runs/placement.js"; +import { contentDigest } from "../planning/digest.js"; +import { + generateSoftwareDevelopmentPlan, + type PlanAcceptanceInput, +} from "../planning/generate-plan.js"; +import { parsePlanArtifact } from "../planning/schema.js"; +import type { PlanArtifact } from "../planning/types.js"; +import { planToExecutionGraph } from "../planning/workflow-version.js"; import { assertExecutionBinding, @@ -40,7 +48,12 @@ export interface StartPlanningInput { executionNodeId: string; runtimeInstallationId: string; workspaceInstanceId: string; - planDigest: string; + /** + * Optional caller digest. Ignored as a plan source: the plan is generated + * from the project objective. Kept so existing callers may still pass it. + */ + planDigest?: string; + acceptanceCriteria?: readonly PlanAcceptanceInput[]; } export interface ConfirmPlanInput { @@ -49,7 +62,11 @@ export interface ConfirmPlanInput { projectId: string; approvalId: string; expectedStateRevision?: number; - graph: WorkflowGraph; + /** + * Published execution graph from the caller (Daemon). When omitted, confirm + * uses the plan generated at start-planning. + */ + graph?: WorkflowGraph; } export interface StartExecutionInput { @@ -122,8 +139,30 @@ export async function createProject( export async function startPlanning( ctx: AppContext, input: StartPlanningInput, -): Promise<{ reused: boolean; project: ProjectRecord; approvalId: string }> { +): Promise<{ + reused: boolean; + project: ProjectRecord; + approvalId: string; + plan: PlanArtifact; + planDigest: string; +}> { return ctx.world.uow.withTransaction(async (tx) => { + const preview = requireProject(ctx, input.projectId); + if (preview.objective.trim() === "") { + throw validationFailed("planner objective is required"); + } + let generated: PlanArtifact; + try { + generated = generateSoftwareDevelopmentPlan({ + objective: preview.objective, + ...(input.acceptanceCriteria ? { acceptanceCriteria: input.acceptanceCriteria } : {}), + }); + } catch (error) { + throw validationFailed( + error instanceof Error ? error.message : "plan generation failed", + ); + } + const generatedDigest = contentDigest(generated); return withIdempotency( ctx.world, tx, @@ -135,7 +174,9 @@ export async function startPlanning( teamVersionId: input.teamVersionId, runtimeId: input.runtimeId, budgetId: input.budgetId, - planDigest: input.planDigest, + objective: preview.objective, + acceptanceCriteria: input.acceptanceCriteria ?? [], + planDigest: generatedDigest, }), scope: { principalId: ctx.principalId, @@ -170,8 +211,9 @@ export async function startPlanning( artifactVersionId: planArtifactId, projectId: project.id, slotId: "plan", - digest: input.planDigest, + digest: generatedDigest, status: "available", + body: generated, }); project.planArtifactVersionId = planArtifactId; @@ -182,7 +224,7 @@ export async function startPlanning( gate: "plan", status: "pending", stateRevision: 1, - actionDigest: input.planDigest, + actionDigest: generatedDigest, resource: `artifactVersion:${planArtifactId}`, artifactVersionId: planArtifactId, createdAt: now, @@ -206,14 +248,16 @@ export async function startPlanning( subjectId: project.id, projectId: project.id, correlationId: input.operationId, - data: { to: next }, + data: { to: next, planDigest: generatedDigest }, }); - return { project, approvalId }; + return { project, approvalId, plan: generated, planDigest: generatedDigest }; }, ).then((result) => ({ reused: result.reused, project: result.value.project, approvalId: result.value.approvalId, + plan: result.value.plan, + planDigest: result.value.planDigest, })); }); } @@ -231,7 +275,7 @@ export async function confirmPlan( digest: digestOf({ projectId: input.projectId, approvalId: input.approvalId, - graphId: input.graph.id, + graphId: input.graph?.id ?? `plan:${input.projectId}`, }), scope: { principalId: ctx.principalId, @@ -265,11 +309,7 @@ export async function confirmPlan( throw validationFailed("confirm-plan requires a published team version"); } - const graph = resolvePublishedExecutionGraph( - ctx.world.workflowVersions, - input.graph.id, - input.graph, - ); + const graph = resolveConfirmPlanGraph(ctx, project, input.graph); const dag = ctx.engine.validateWorkflowGraph(graph); if (!dag.ok) { throw validationFailed(dag.reason); @@ -703,3 +743,31 @@ function executionSnapshotContentHash(value: unknown): string { // keeps a canonical-content fingerprint until that source is wired through. return `canonical-json:${digestOf(value)}`; } + +function resolveConfirmPlanGraph( + ctx: AppContext, + project: ProjectRecord, + supplied?: WorkflowGraph, +): WorkflowGraph { + if (supplied) { + return resolvePublishedExecutionGraph(ctx.world.workflowVersions, supplied.id, supplied); + } + const artifact = project.planArtifactVersionId + ? ctx.world.artifacts.get(project.planArtifactVersionId) + : undefined; + const parsed = parsePlanArtifact(artifact?.body); + if (!parsed.ok) { + throw validationFailed( + "confirm-plan requires a workflow graph or a plan generated at start-planning", + ); + } + if (parsed.plan.objective.trim() !== project.objective.trim()) { + throw validationFailed("generated plan objective does not match the project"); + } + const graphId = `wfv_plan_${project.planArtifactVersionId ?? project.id}`; + return resolvePublishedExecutionGraph( + ctx.world.workflowVersions, + graphId, + planToExecutionGraph(parsed.plan, graphId), + ); +} diff --git a/packages/application/src/use-cases/projects/service.ts b/packages/application/src/use-cases/projects/service.ts index ea5440e..8074d4a 100644 --- a/packages/application/src/use-cases/projects/service.ts +++ b/packages/application/src/use-cases/projects/service.ts @@ -44,6 +44,7 @@ import { import { markRunUnknown, reconcile } from "../recovery/recovery.js"; import { applyAuthoringChangeSet, + continueAuthoringChangeSet, cancelAuthoringChangeSet, expireAuthoringChangeSet, failAuthoringChangeSet, @@ -124,6 +125,8 @@ export class WorkforceApp { markRunUnknown = (runId: string) => markRunUnknown(this.ctx, runId); applyAuthoringChangeSet = (input: Parameters[1]) => applyAuthoringChangeSet(this.ctx, input); + continueAuthoringChangeSet = (input: Parameters[1]) => + continueAuthoringChangeSet(this.ctx, input); startAuthoring = (input: Parameters[1]) => startAuthoring(this.ctx, input); recordAuthoringProposal = (input: Parameters[1]) => recordAuthoringProposal(this.ctx, input); diff --git a/packages/application/src/use-cases/projects/store.ts b/packages/application/src/use-cases/projects/store.ts index 565d7ec..8669015 100644 --- a/packages/application/src/use-cases/projects/store.ts +++ b/packages/application/src/use-cases/projects/store.ts @@ -152,6 +152,8 @@ export interface ArtifactRecord { slotId?: string; digest: string; status: ArtifactVersionStatus; + /** Plan body generated at start-planning. Not a Runtime output. */ + body?: unknown; } /** T08 evidence consumed by T09 completion. Not a second evaluation engine. */ From 0c9c6a93abb29ac01f4aea82b2e0a9ca2273a027 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 13 Sep 2026 14:24:50 +0000 Subject: [PATCH 2/3] feat(daemon): wire generated plans and continue ChangeSet Call Application startPlanning/confirmPlan without mock plan fallbacks, add continueAuthoringChangeSet HTTP on the same authoring session, and persist Desktop workspace grants for host git paths. Co-authored-by: NiceChen --- apps/daemon/src/api/body.ts | 19 + apps/daemon/src/api/routes.ts | 42 ++ apps/daemon/src/composition/app-services.ts | 483 ++++++++++++++++-- apps/daemon/src/composition/index.ts | 2 + apps/daemon/src/composition/worktree-host.ts | 154 +++++- apps/daemon/src/modules/dto.ts | 7 + apps/daemon/src/modules/fake-app-services.ts | 4 + apps/daemon/src/modules/index.ts | 8 + apps/desktop/src/main/ipc/allowlist.ts | 4 + apps/desktop/src/main/ipc/workspace-picker.ts | 44 ++ apps/desktop/src/main/start.ts | 7 +- 11 files changed, 729 insertions(+), 45 deletions(-) diff --git a/apps/daemon/src/api/body.ts b/apps/daemon/src/api/body.ts index 9497288..4fda8dc 100644 --- a/apps/daemon/src/api/body.ts +++ b/apps/daemon/src/api/body.ts @@ -126,6 +126,25 @@ export function queryString(raw: unknown): string | undefined { return typeof raw === "string" ? raw : String(raw); } +export function optionalObjectArray( + body: Record, + key: string, +): Record[] | undefined { + if (!(key in body)) { + return undefined; + } + const value = body[key]; + if (!Array.isArray(value)) { + throw new AppError("validation_failed", `${key} must be an array`); + } + return value.map((item, index) => { + if (item === null || typeof item !== "object" || Array.isArray(item)) { + throw new AppError("validation_failed", `${key}[${index}] must be an object`); + } + return item as Record; + }); +} + export function queryStringList(raw: unknown): string[] | undefined { if (raw === undefined) { return undefined; diff --git a/apps/daemon/src/api/routes.ts b/apps/daemon/src/api/routes.ts index c31eb87..81c3e93 100644 --- a/apps/daemon/src/api/routes.ts +++ b/apps/daemon/src/api/routes.ts @@ -27,6 +27,7 @@ import type { SessionRegistry } from "./auth.js"; import { asObject, optionalInt, + optionalObjectArray, optionalString, parseLimit, queryString, @@ -610,6 +611,47 @@ export function registerRoutes(app: FastifyInstance, deps: RouteDeps): void { ); } + app.post( + "/api/v1/authoring-sessions/:sessionId/change-sets/:changeSetId/_cmd/continue", + async (request, reply) => { + await cmd( + request, + reply, + { + canonicalOperation: + "POST /authoring-sessions/{sessionId}/change-sets/{changeSetId}:continue", + resource: (req) => `${param(req, "sessionId")}:${param(req, "changeSetId")}`, + requireIfMatch: true, + }, + (ctx, body) => { + rejectUnknownFields(body, [ + "operationId", + "workflowDrafts", + "teamDrafts", + "taskPatches", + ]); + const input: { + workflowDrafts?: Record[]; + teamDrafts?: Record[]; + taskPatches?: Record[]; + } = {}; + const workflowDrafts = optionalObjectArray(body, "workflowDrafts"); + const teamDrafts = optionalObjectArray(body, "teamDrafts"); + const taskPatches = optionalObjectArray(body, "taskPatches"); + if (workflowDrafts !== undefined) input.workflowDrafts = workflowDrafts; + if (teamDrafts !== undefined) input.teamDrafts = teamDrafts; + if (taskPatches !== undefined) input.taskPatches = taskPatches; + return deps.services.continueAuthoringChangeSet( + ctx, + param(request, "sessionId"), + param(request, "changeSetId"), + input, + ); + }, + ); + }, + ); + app.get("/api/v1/approvals", async (request) => deps.services.listApprovals(listQuery(request))); app.get("/api/v1/approvals/:id", async (request, reply) => { const approval = requireFound( diff --git a/apps/daemon/src/composition/app-services.ts b/apps/daemon/src/composition/app-services.ts index 67e6d53..07c0a80 100644 --- a/apps/daemon/src/composition/app-services.ts +++ b/apps/daemon/src/composition/app-services.ts @@ -5,8 +5,12 @@ import { CatalogService, adaptTeamDraftRepository, confirmAuthoringChatProposal, + failAuthoringTurn, HostCapabilityError, - MOCK_PLAN_DOCUMENT, + isExecutableWorkflowVersion, + retryAuthoringTurn, + storeAuthoringSessionBody, + toEngineGraph, UseCaseError, WorkforceApp, createWorkforceApp, @@ -17,11 +21,15 @@ import { settleRunCancel, workerCardFieldsFrom, type ApprovalRecord, + type AuthoringChatTurn, type AuthoringTaskPatch, + type AuthoringTurnLifecycleDeps, type ProjectRecord, type RunRecord, type TaskRecord, type ConfirmChatProposalDeps, + type WorkflowGraph, + type WorkflowVersionRecord, } from "@workforce/application"; import { ArtifactEvaluator, @@ -32,14 +40,17 @@ import { type RegistrableKind, type StoredArtifactVersion, } from "@workforce/artifacts"; -import { WorkforceSqlite, PersistenceError } from "@workforce/database"; +import { WorkforceSqlite, PersistenceError, sqliteDbOf } from "@workforce/database"; import { SqliteSubscriptionReader, type EventReadQuery } from "@workforce/events/subscriptions"; import type { CanonicalAction, InMemoryPolicyEngine } from "@workforce/policy"; import { DEFAULT_ORCHESTRATION_MODE, isSelectableWorkerVersion, + parseTeamDraft, parseTeamVersionWrite, parseWorkerDraftWrite, + parseWorkflowDraft, + type AuthoringChangeSetDto, type AuthoringProposalDto, type AuthoringSessionPageDto, type AuthoringSessionViewDto, @@ -54,6 +65,7 @@ import { type WorkforceEvent, } from "@workforce/protocol"; import { interpretMockAuthoringIntent } from "@workforce/runtime-mock"; +import { WorkspaceError } from "@workforce/workspace"; import { InvalidTransitionError, validateWorkflowGraph } from "@workforce/workflow-engine"; import { loadOrCreateClientId, loadOrCreatePrincipalId } from "../bootstrap/state-file.js"; @@ -99,6 +111,7 @@ import type { CreateWorkerInput, ChatClassifyInput, ChatClassifyResultDto, + ContinueAuthoringChangeSetInput, ForkWorkerVersionAcceptedDto, ListWorkersInput, PatchTeamInput, @@ -131,7 +144,6 @@ import { SOFTWARE_TEAM_VERSION, TEAM_VERSION_ID, isPresetPublishedTeamVersion, - mockPlanGraph, pageOf, seedPresetWorkerLibrary, unknownProjectBudget, @@ -346,7 +358,7 @@ export class ComposedAppServices implements AppServices { const clock = app.world.clock as unknown as { current: Date }; clock.current = new Date(); for (const workspace of services.workspaces.values()) { - await services.worktrees.bindProject(workspace.projectId); + await services.worktrees.bindProject(workspace.projectId, workspace.authorizationRef); } } await services.restoreArtifactAuthority(); @@ -601,7 +613,12 @@ export class ComposedAppServices implements AppServices { ): Promise> { return this.exclusive(async () => { const version = this.authoring.publishWorkflowVersion(id, versionId, ctx.ifMatch); + const graph = engineGraphFromCatalog(version); + if (graph) { + this.app.world.workflowVersions.set(graph.id, graph); + } await this.persistCatalogWorkflow(id); + this.persist(); return { status: 200, body: resolveWorkflowVersion(this.authoring, id, version.id)!, @@ -805,12 +822,21 @@ export class ComposedAppServices implements AppServices { const project = this.requireProject(id); this.assertMatch(project.stateRevision, ctx.ifMatch); const now = this.app.world.nowIso(); + const publicRef = publicAuthorizationRef(input.authorizationRef); + try { + await this.worktrees.bindProject(project.id, input.authorizationRef); + } catch (error) { + if (error instanceof WorkspaceError && error.message === "unknown authorizationRef") { + throw new AppError("forbidden", "Workspace authorization is unknown"); + } + throw error; + } const workspace: WorkspaceDto = { id: this.app.world.ids.ulid("wsp_"), projectId: project.id, status: "bound", kind: "local", - authorizationRef: publicAuthorizationRef(input.authorizationRef), + authorizationRef: publicRef, createdAt: now, }; this.workspaces.set(workspace.id, workspace); @@ -818,7 +844,6 @@ export class ComposedAppServices implements AppServices { project.workspaceInstanceId = this.app.world.ids.ulid("wsi_"); project.stateRevision += 1; project.updatedAt = now; - await this.worktrees.bindProject(project.id); this.persist(); return { status: 201, body: workspace, revision: project.stateRevision }; }); @@ -897,7 +922,6 @@ export class ComposedAppServices implements AppServices { const project = this.requireProject(id); this.assertMatch(project.stateRevision, ctx.ifMatch); const workspace = await this.workspaceFor(project); - const planDigest = sha256Hex(canonicalJson(MOCK_PLAN_DOCUMENT)); const teamVersionId = project.teamVersionId ?? TEAM_VERSION_ID; assertBindableTeamVersionId(this.authoring, teamVersionId); const started = await this.app.startPlanning({ @@ -911,12 +935,11 @@ export class ComposedAppServices implements AppServices { executionNodeId: LOCAL_NODE_ID, runtimeInstallationId: MOCK_RUNTIME_INSTALLATION_ID, workspaceInstanceId: project.workspaceInstanceId ?? this.app.world.ids.ulid("wsi_"), - planDigest, ...optionalRevision(ctx.ifMatch), }); const live = this.requireProject(id); if (live.planArtifactVersionId) { - const body = encoder.encode(`${JSON.stringify(MOCK_PLAN_DOCUMENT, null, 2)}\n`); + const body = encoder.encode(`${JSON.stringify(started.plan, null, 2)}\n`); await this.commitArtifact({ artifactId: live.planArtifactVersionId, aliasVersionId: live.planArtifactVersionId, @@ -965,12 +988,13 @@ export class ComposedAppServices implements AppServices { ...(approval.expiresAt !== undefined ? { expiresAt: approval.expiresAt } : {}), }); } + const graph = this.publishedExecutionGraph(project); const confirmed = await this.app.confirmPlan({ operationId: ctx.operationId, idempotencyKey: ctx.operationId, projectId: project.id, approvalId: approval.id, - graph: mockPlanGraph(), + ...(graph !== undefined ? { graph } : {}), ...optionalRevision(ctx.ifMatch), }); this.persist(); @@ -1469,16 +1493,20 @@ export class ComposedAppServices implements AppServices { const now = this.app.world.nowIso(); const messageId = this.app.world.ids.ulid("cam_"); const turnId = this.app.world.ids.ulid("cat_"); - const contentRef = `mem:authoring:${messageId}`; - this.authoringContent.set(contentRef, content); + const stored = storeAuthoringSessionBody(this.app.world, { + sessionId, + messageId, + content, + }); + this.authoringContent.set(stored.contentRef, content); await this.sqlite.uow.withTransaction(async (tx) => { this.sqlite.authoringMessages.create(tx, { id: messageId, sessionId, role: "user", - contentRef, - contentHash: sha256Hex(content), - redactedPreview: "[user message retained in process memory]", + contentRef: stored.contentRef, + contentHash: stored.contentHash, + redactedPreview: stored.redactedPreview, createdAt: now, }); this.sqlite.authoringTurns.create(tx, { @@ -1573,7 +1601,7 @@ export class ComposedAppServices implements AppServices { ): Promise> { return this.exclusive(async () => { if (action === "retry") { - throw new AppError("unsupported_capability", "authoring turn retry is not implemented"); + return this.retryAuthoringTurnAction(ctx, sessionId, turnId); } const turn = this.sqlite.authoringTurns.get(turnId); if (!turn || turn.sessionId !== sessionId) { @@ -1623,6 +1651,297 @@ export class ComposedAppServices implements AppServices { }); } + async continueAuthoringChangeSet( + ctx: CommandContext, + sessionId: string, + changeSetId: string, + input: ContinueAuthoringChangeSetInput, + ): Promise> { + return this.exclusive(async () => { + const session = this.sqlite.authoringSessions.get(sessionId); + if (!session) throw new AppError("not_found", "Authoring session not found"); + this.assertMatch(session.stateRevision, ctx.ifMatch); + const owned = this.sqlite.connection + .prepare( + `SELECT id FROM authoring_turns WHERE session_id = ? AND change_set_id = ? LIMIT 1`, + ) + .get(sessionId, changeSetId) as { id: string } | undefined; + if (!owned) { + throw new AppError("not_found", "Authoring change set not found in this session"); + } + const stored = this.app.world.authoringChangeSets.get(changeSetId); + if (!stored) { + throw new AppError("not_found", "Authoring change set not found"); + } + if (stored.projectId !== session.projectId) { + throw new AppError( + "validation_failed", + "authoring change set does not belong to this session", + ); + } + const workflowDrafts = this.parseContinueDrafts( + input.workflowDrafts, + parseWorkflowDraft, + "workflowDrafts", + ); + const teamDrafts = this.parseContinueDrafts(input.teamDrafts, parseTeamDraft, "teamDrafts"); + const taskPatches = this.parseContinueTaskPatches(input.taskPatches); + const continued = await this.app.continueAuthoringChangeSet({ + operationId: ctx.operationId, + idempotencyKey: ctx.operationId, + changeSetId, + ...(workflowDrafts !== undefined ? { workflowDrafts } : {}), + ...(teamDrafts !== undefined ? { teamDrafts } : {}), + ...(taskPatches !== undefined ? { taskPatches } : {}), + }); + for (const step of continued.changeSet.steps) { + if (step.status === "applied" && step.targetType === "team") { + this.syncTeamDraftIntoCatalog(step.targetId); + } + } + await this.persistDurably(); + const now = this.app.world.nowIso(); + this.sqlite.connection + .prepare( + `UPDATE authoring_sessions SET state_revision = state_revision + 1, updated_at = ? WHERE id = ?`, + ) + .run(now, sessionId); + const current = this.sqlite.authoringSessions.get(sessionId); + return { + status: 200, + body: continued.changeSet, + revision: current?.stateRevision ?? session.stateRevision + 1, + }; + }); + } + + private async retryAuthoringTurnAction( + ctx: CommandContext, + sessionId: string, + turnId: string, + ): Promise> { + const turn = this.sqlite.authoringTurns.get(turnId); + if (!turn || turn.sessionId !== sessionId) { + throw new AppError("not_found", "Authoring turn not found"); + } + if (ctx.ifMatch === undefined) { + throw new AppError("validation_failed", "If-Match is required"); + } + this.assertMatch(turn.stateRevision, ctx.ifMatch); + const contentRef = this.ensureAuthoringTurnBody(sessionId, turnId); + const retried = await retryAuthoringTurn( + { + operationId: ctx.operationId, + idempotencyKey: ctx.operationId, + sessionId, + turnId, + expectedRevision: ctx.ifMatch, + contentRef, + projectId: turn.projectId, + }, + this.authoringTurnLifecycleDeps(), + ); + void retried; + await this.persistDurably(); + const current = this.sqlite.authoringTurns.get(turnId); + return { + status: 200, + body: { + operationId: ctx.operationId, + acceptedAt: this.app.world.nowIso(), + sessionId, + turnId, + action: "retry", + revision: current?.stateRevision ?? turn.stateRevision + 1, + }, + revision: current?.stateRevision ?? turn.stateRevision, + }; + } + + private async failAuthoringTurnForRun(runId: string): Promise { + const chatTurn = this.chatTurnForRun(runId); + if (!chatTurn) { + return; + } + const turn = this.sqlite.authoringTurns.get(chatTurn.id); + if (!turn || turn.status === "failed" || turn.status === "completed" || turn.status === "closed" || turn.status === "cancelled") { + return; + } + await failAuthoringTurn( + { + operationId: `authoring-fail:${runId}`, + idempotencyKey: `authoring-fail:${runId}`, + sessionId: chatTurn.sessionId, + turnId: turn.id, + expectedRevision: turn.stateRevision, + failure: { code: "conflict", message: "authoring run failed" }, + }, + this.authoringTurnLifecycleDeps(), + ); + } + + private ensureAuthoringTurnBody(sessionId: string, turnId: string): string { + const view = this.authoringSessionView(sessionId); + const turnIndex = view?.turns.findIndex((item) => item.id === turnId) ?? -1; + const message = turnIndex >= 0 ? view?.turns[turnIndex]?.userMessage : undefined; + const messageRows = this.sqlite.connection + .prepare( + `SELECT id, content_ref FROM authoring_messages WHERE session_id = ? ORDER BY created_at ASC, id ASC`, + ) + .all(sessionId) as Array<{ id: string; content_ref: string | null }>; + const row = turnIndex >= 0 ? messageRows[turnIndex] : undefined; + const contentRef = row?.content_ref ?? undefined; + if (!contentRef) { + throw new AppError("validation_failed", "authoring intent is not recoverable after restart"); + } + const existing = this.app.world.authoringProtectedBodies.get(contentRef); + if (existing && existing.body.trim().length > 0) { + return contentRef; + } + const body = this.authoringContent.get(contentRef) ?? message?.content; + if (!body || (body.startsWith("[") && body.endsWith("]"))) { + throw new AppError("validation_failed", "authoring intent is not recoverable after restart"); + } + this.app.world.authoringProtectedBodies.set(contentRef, { + contentRef, + contentHash: sha256Hex(body), + redactedPreview: `[user message retained in process memory]`, + body, + kind: "session_message", + createdAt: this.app.world.nowIso(), + sessionId, + ...(row?.id ? { messageId: row.id } : {}), + }); + return contentRef; + } + + private publishedExecutionGraph(project: ProjectRecord): WorkflowGraph | undefined { + const bound = this.graphFromVersionId(project.workflowVersionId); + if (bound) { + return bound; + } + for (const scope of this.sqlite.workflowAuthoringScopes.listAll()) { + if (scope.projectId !== project.id) { + continue; + } + const workflow = this.authoring.catalog.workflows.get(scope.workflowId); + const fromActive = this.graphFromVersionId(workflow?.activeVersionId); + if (fromActive) { + return fromActive; + } + const published = this.authoring.catalog + .listWorkflowVersions(scope.workflowId) + .filter((version) => isExecutableWorkflowVersion(version) && version.nodes.length > 0) + .sort( + (left, right) => + (right.publishedAt ?? "").localeCompare(left.publishedAt ?? "") || + right.id.localeCompare(left.id), + )[0]; + const graph = published ? engineGraphFromCatalog(published) : undefined; + if (graph) { + this.app.world.workflowVersions.set(graph.id, graph); + return graph; + } + } + return undefined; + } + + private graphFromVersionId(versionId: string | undefined): WorkflowGraph | undefined { + if (!versionId) { + return undefined; + } + const live = this.app.world.workflowVersions.get(versionId); + if (live) { + return JSON.parse(JSON.stringify(live)) as WorkflowGraph; + } + const catalog = this.authoring.catalog.findWorkflowVersion(versionId); + const graph = catalog ? engineGraphFromCatalog(catalog) : undefined; + if (graph) { + this.app.world.workflowVersions.set(graph.id, graph); + } + return graph; + } + + private authoringTurnLifecycleDeps(): AuthoringTurnLifecycleDeps { + const services = this; + return { + clock: this.app.world.clock, + ids: this.app.world.ids, + uow: this.sqlite.uow, + events: this.sqlite.events, + receipts: this.sqlite.receipts, + sessions: this.sqlite.authoringSessions, + turns: { + getInTransaction: (tx, id) => { + const stored = this.sqlite.authoringTurns.getInTransaction(tx, id); + return stored ? toAuthoringChatTurn(stored) : null; + }, + transitionInTransaction: (tx, input) => { + const db = sqliteDbOf(tx); + const changed = db + .prepare( + `UPDATE authoring_turns + SET status = ?, + state_revision = state_revision + 1, + task_id = COALESCE(?, task_id), + run_id = COALESCE(?, run_id), + source_run_id = COALESCE(?, source_run_id), + updated_at = ? + WHERE id = ? AND state_revision = ?`, + ) + .run( + input.status, + input.taskId ?? null, + input.runId ?? null, + input.sourceRunId ?? null, + input.at, + input.turnId, + input.expectedStateRevision, + ); + if (Number(changed.changes) === 0) { + const current = this.sqlite.authoringTurns.getInTransaction(tx, input.turnId); + throw new UseCaseError("revision_conflict", "Authoring turn revision changed", { + details: { + expected: input.expectedStateRevision, + actual: current?.stateRevision, + }, + }); + } + const next = this.sqlite.authoringTurns.getInTransaction(tx, input.turnId); + if (!next) { + throw new AppError("not_found", "Authoring turn not found"); + } + return toAuthoringChatTurn(next); + }, + }, + protectedBodies: this.app.world, + principalId: this.app.ctx.principalId, + clientId: this.app.ctx.clientId, + startAuthoring: async (input) => { + services.host.setInitialInput(`${input.operationId}:run`, { + operationId: `${input.operationId}:input`, + text: input.intent, + }); + const started = await services.app.startAuthoring(input); + await services.persistDurably(); + const run = services.app.world.runs.get(started.runId); + if (run?.handleId) { + await services.host.sendInput(run.handleId, { + operationId: `${input.operationId}:input`, + text: input.intent, + }); + } + return started; + }, + cancelAuthoringRun: async (runId) => { + const run = services.app.world.runs.get(runId); + if (run?.handleId) { + await services.host.cancel(run.handleId, "authoring turn cancelled"); + } + }, + }; + } + private authoringSessionView(sessionId: string): AuthoringSessionViewDto | null { const session = this.sqlite.authoringSessions.get(sessionId); if (!session) return null; @@ -1644,7 +1963,7 @@ export class ComposedAppServices implements AppServices { const turnRows = this.sqlite.connection .prepare( `SELECT id, session_id, protocol_version, status, state_revision, - task_id, run_id, workflow_draft_id, created_at, updated_at + task_id, run_id, change_set_id, workflow_draft_id, created_at, updated_at FROM authoring_turns WHERE session_id = ? ORDER BY created_at ASC, id ASC`, ) .all(sessionId) as Record[]; @@ -1652,6 +1971,7 @@ export class ComposedAppServices implements AppServices { const refs: AuthoringTurnDto["refs"] = {}; if (row.task_id !== null) refs.taskId = String(row.task_id); if (row.run_id !== null) refs.runId = String(row.run_id); + if (row.change_set_id !== null) refs.changeSetId = String(row.change_set_id); if (row.workflow_draft_id !== null) refs.workflowDraftId = String(row.workflow_draft_id); const message = messages[index] ?? { id: `cam_missing_${String(row.id)}`, @@ -2036,6 +2356,7 @@ export class ComposedAppServices implements AppServices { await this.dispatchReadyTasks(run.projectId); } else if (event.status === "failed") { this.app.recordRunFailed(run.id); + await this.failAuthoringTurnForRun(run.id); } else if (event.status === "cancelled") { settleRunCancel(this.app.ctx, run.id); } @@ -2302,7 +2623,7 @@ export class ComposedAppServices implements AppServices { artifactVersionId: versionId, patch: new TextDecoder().decode(content.body), changedPaths: [], - baseSha: this.worktrees.baseSha, + baseSha: this.worktrees.baseShaFor(projectId), }); } const project = this.requireProject(projectId); @@ -2317,7 +2638,7 @@ export class ComposedAppServices implements AppServices { projectId, workspaceId: gitWorkspaceId, integrationTaskId: review?.id ?? `integrate_${projectId}`, - baseSha: this.worktrees.baseSha, + baseSha: this.worktrees.baseShaFor(projectId), workflowVersionId: project.workflowVersionId ?? "wfv_software", contributions, }, @@ -2340,7 +2661,7 @@ export class ComposedAppServices implements AppServices { parents: contributions.map((item) => item.artifactVersionId), metadata: { type: "git_diff", - baseSha: this.worktrees.baseSha, + baseSha: this.worktrees.baseShaFor(projectId), baseRef: "immutable-base", }, }); @@ -2476,14 +2797,14 @@ export class ComposedAppServices implements AppServices { if (project.workspaceId) { const existing = this.workspaces.get(project.workspaceId); if (existing) { - await this.worktrees.bindProject(project.id); + await this.worktrees.bindProject(project.id, existing.authorizationRef); return existing; } } const bound = [...this.workspaces.values()].find((item) => item.projectId === project.id); if (bound) { project.workspaceId = bound.id; - await this.worktrees.bindProject(project.id); + await this.worktrees.bindProject(project.id, bound.authorizationRef); return bound; } const workspace: WorkspaceDto = { @@ -2523,21 +2844,23 @@ export class ComposedAppServices implements AppServices { } private planDocumentFor(approval: ApprovalRecord): unknown { + const fromWorld = approval.artifactVersionId + ? this.app.world.artifacts.get(approval.artifactVersionId)?.body + : undefined; + if (fromWorld !== undefined) { + return fromWorld; + } const content = approval.artifactVersionId ? this.findContent(undefined, approval.artifactVersionId) : undefined; if (content?.body) { try { - const parsed: unknown = JSON.parse(new TextDecoder().decode(content.body)); - if (isCanonicalMockPlan(parsed)) { - return MOCK_PLAN_DOCUMENT; - } - return parsed; + return JSON.parse(new TextDecoder().decode(content.body)) as unknown; } catch { return { hash: content.hash }; } } - return MOCK_PLAN_DOCUMENT; + throw new AppError("validation_failed", "plan artifact is not available"); } private canonicalActionFor(approval: ApprovalRecord): CanonicalAction { @@ -2756,6 +3079,39 @@ export class ComposedAppServices implements AppServices { return readQuery; } + private parseContinueDrafts( + items: readonly Record[] | undefined, + parse: (input: unknown) => T, + field: string, + ): T[] | undefined { + if (items === undefined) { + return undefined; + } + try { + return items.map((item) => parse(item)); + } catch (error) { + const message = error instanceof Error ? error.message : `${field} is malformed`; + throw new AppError("validation_failed", message); + } + } + + private parseContinueTaskPatches( + items: readonly Record[] | undefined, + ): AuthoringTaskPatch[] | undefined { + if (items === undefined) { + return undefined; + } + try { + return items.map((item) => parseAuthoringTaskPatch(item as unknown as AuthoringTaskPatch)); + } catch (error) { + if (error instanceof UseCaseError) { + throw new AppError(error.code, error.message); + } + const message = error instanceof Error ? error.message : "taskPatches is malformed"; + throw new AppError("validation_failed", message); + } + } + private persist(): void { if (this.closed) { try { @@ -3414,19 +3770,6 @@ function chatProposalCreateTargets(proposal: AuthoringProposalDto): Array<{ return targets; } -function isCanonicalMockPlan(value: unknown): boolean { - if (value === null || typeof value !== "object" || Array.isArray(value)) { - return false; - } - const record = value as Record; - return ( - record.protocol === MOCK_PLAN_DOCUMENT.protocol && - record.protocolVersion === MOCK_PLAN_DOCUMENT.protocolVersion && - record.workflowId === MOCK_PLAN_DOCUMENT.workflowId && - record.templateId === MOCK_PLAN_DOCUMENT.templateId - ); -} - function toVersionDto(record: ArtifactContentRecord): ArtifactVersionDto { return { id: record.versionId, @@ -3480,6 +3823,58 @@ function syntheticOutput( }; } +function engineGraphFromCatalog(version: WorkflowVersionRecord): WorkflowGraph | undefined { + if (!isExecutableWorkflowVersion(version) || version.nodes.length === 0) { + return undefined; + } + const graphInput: Parameters[0] = { + id: version.id, + workflowId: version.workflowId, + versionLabel: version.version, + nodes: version.nodes, + edges: version.edges, + }; + if (version.entry !== undefined) { + graphInput.entry = version.entry; + } + return toEngineGraph(graphInput); +} + +function toAuthoringChatTurn(record: { + id: string; + sessionId: string; + organizationId: string; + projectId: string; + sourceRunId: string; + status: string; + stateRevision: number; + proposalId: string | null; + changeSetId: string | null; + workflowDraftId: string | null; + completedOperationId: string | null; + patchRefs: readonly string[]; + taskId: string | null; +}): AuthoringChatTurn { + const turn: AuthoringChatTurn = { + id: record.id, + sessionId: record.sessionId, + organizationId: record.organizationId, + projectId: record.projectId, + sourceRunId: record.sourceRunId, + status: record.status as AuthoringChatTurn["status"], + stateRevision: record.stateRevision, + proposalId: record.proposalId, + changeSetId: record.changeSetId, + workflowDraftId: record.workflowDraftId, + completedOperationId: record.completedOperationId, + patchRefs: record.patchRefs, + }; + if (record.taskId) { + turn.taskId = record.taskId; + } + return turn; +} + function wrapError(error: unknown): unknown { if (error instanceof AppError) { return error; @@ -3506,6 +3901,12 @@ function wrapError(error: unknown): unknown { if (error instanceof HostCapabilityError) { return new AppError("unsupported_capability", error.message); } + if (error instanceof WorkspaceError) { + if (error.message === "unknown authorizationRef") { + return new AppError("forbidden", "Workspace authorization is unknown"); + } + return new AppError("validation_failed", error.message); + } if (error && typeof error === "object" && "code" in error) { const code = (error as { code: unknown }).code; if (code === "unsupported_capability") { diff --git a/apps/daemon/src/composition/index.ts b/apps/daemon/src/composition/index.ts index 061bec0..ba774f1 100644 --- a/apps/daemon/src/composition/index.ts +++ b/apps/daemon/src/composition/index.ts @@ -23,6 +23,8 @@ export { export { CompositionWorktreeHost, bindWorktreesToHost, + workspaceGrantsFile, + isDesktopWorkspaceGrant, type ProvisionedWorktree, } from "./worktree-host.js"; export { captureMockPatch, gitDiffArtifactFromCapture, isGitDiffSlot } from "./delivery-bind.js"; diff --git a/apps/daemon/src/composition/worktree-host.ts b/apps/daemon/src/composition/worktree-host.ts index ffe95d4..a0ecd0d 100644 --- a/apps/daemon/src/composition/worktree-host.ts +++ b/apps/daemon/src/composition/worktree-host.ts @@ -1,10 +1,11 @@ import { execFile } from "node:child_process"; +import fs from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import type { RuntimeHostPort, StartRunHostRequest, TaskRecord } from "@workforce/application"; -import { GitWorkspaceService } from "@workforce/workspace"; +import { GitWorkspaceService, WorkspaceError } from "@workforce/workspace"; const execFileAsync = promisify(execFile); @@ -39,6 +40,16 @@ export interface CompositionWorktreeHostOptions { stateDir: string; } +export const WORKSPACE_GRANTS_FILENAME = "workspace-grants.json"; + +export function workspaceGrantsFile(stateDir: string): string { + return path.join(stateDir, WORKSPACE_GRANTS_FILENAME); +} + +export function isDesktopWorkspaceGrant(authorizationRef: string): boolean { + return authorizationRef.startsWith("wsauth_"); +} + function isWorktreeRole(role: string): boolean { return role === "developer" || role === "reviewer"; } @@ -89,12 +100,55 @@ export async function ensureMockGitRepository( return { repoPath, baseSha }; } +/** Init or reuse a git repo at a user-selected directory. Does not invent mock fixture files when the tree already has content. */ +export async function ensureUserGitRepository( + repoPath: string, +): Promise<{ repoPath: string; baseSha: string }> { + const resolved = path.resolve(repoPath); + await mkdir(resolved, { recursive: true }); + try { + const inside = await runMockGit(resolved, ["rev-parse", "--is-inside-work-tree"]); + if (inside !== "true") { + throw new Error("not a work tree"); + } + } catch { + await runMockGit(resolved, ["init", "-b", "main"]); + await runMockGit(resolved, ["config", "user.name", MOCK_GIT_NAME]); + await runMockGit(resolved, ["config", "user.email", MOCK_GIT_EMAIL]); + } + let baseSha: string | undefined; + try { + baseSha = await runMockGit(resolved, ["rev-parse", "HEAD"]); + } catch { + // Empty repo has no HEAD until the initial commit below. + } + if (!baseSha) { + const entries = fs.readdirSync(resolved).filter((name) => name !== ".git"); + if (entries.length === 0) { + await writeFile(path.join(resolved, "README.md"), "workforce workspace\n"); + } + await runMockGit(resolved, ["add", "-A"]); + await runMockGit(resolved, ["commit", "-m", "init", "--allow-empty"]); + baseSha = await runMockGit(resolved, ["rev-parse", "HEAD"]); + } + return { repoPath: resolved, baseSha }; +} + +interface ProjectGitBinding { + gitWorkspaceId: string; + baseSha: string; + hostGrantRef?: string; +} + export class CompositionWorktreeHost { readonly git: GitWorkspaceService; readonly repoPath: string; readonly worktreeRoot: string; readonly baseSha: string; private readonly gitAuthorizationRef: string; + private readonly grantsFile: string; + private readonly hostGrants = new Map(); + private readonly projectBindings = new Map(); private readonly projectToGitWorkspace = new Map(); private readonly byRunKey = new Map(); @@ -104,12 +158,14 @@ export class CompositionWorktreeHost { worktreeRoot: string; baseSha: string; gitAuthorizationRef: string; + grantsFile: string; }) { this.git = input.git; this.repoPath = input.repoPath; this.worktreeRoot = input.worktreeRoot; this.baseSha = input.baseSha; this.gitAuthorizationRef = input.gitAuthorizationRef; + this.grantsFile = input.grantsFile; } static async open(options: CompositionWorktreeHostOptions): Promise { @@ -124,10 +180,43 @@ export class CompositionWorktreeHost { worktreeRoot, baseSha: ensured.baseSha, gitAuthorizationRef: registered.authorizationRef, + grantsFile: workspaceGrantsFile(options.stateDir), }); } - async bindProject(projectId: string): Promise<{ gitWorkspaceId: string }> { + registerHostGrant(authorizationRef: string, hostPath: string): void { + this.hostGrants.set(authorizationRef, path.resolve(hostPath)); + } + + resolveGrant(authorizationRef: string): string | undefined { + const remembered = this.hostGrants.get(authorizationRef); + if (remembered) { + return remembered; + } + const persisted = readPersistedGrant(this.grantsFile, authorizationRef); + if (persisted) { + this.hostGrants.set(authorizationRef, persisted); + } + return persisted; + } + + baseShaFor(projectId: string): string { + return this.projectBindings.get(projectId)?.baseSha ?? this.baseSha; + } + + async bindProject( + projectId: string, + authorizationRef?: string, + ): Promise<{ gitWorkspaceId: string }> { + if (authorizationRef) { + const hostPath = this.resolveGrant(authorizationRef); + if (hostPath) { + return this.bindUserRepo(projectId, authorizationRef, hostPath); + } + if (isDesktopWorkspaceGrant(authorizationRef)) { + throw new WorkspaceError("unknown authorizationRef"); + } + } const existing = this.projectToGitWorkspace.get(projectId); if (existing) { return { gitWorkspaceId: existing }; @@ -137,6 +226,34 @@ export class CompositionWorktreeHost { authorizationRef: this.gitAuthorizationRef, }); this.projectToGitWorkspace.set(projectId, binding.workspaceId); + this.projectBindings.set(projectId, { + gitWorkspaceId: binding.workspaceId, + baseSha: this.baseSha, + }); + return { gitWorkspaceId: binding.workspaceId }; + } + + private async bindUserRepo( + projectId: string, + authorizationRef: string, + hostPath: string, + ): Promise<{ gitWorkspaceId: string }> { + const already = this.projectBindings.get(projectId); + if (already?.hostGrantRef === authorizationRef) { + return { gitWorkspaceId: already.gitWorkspaceId }; + } + const ensured = await ensureUserGitRepository(hostPath); + const registered = await this.git.registerLocalRepository(ensured.repoPath); + const binding = await this.git.bind({ + projectId, + authorizationRef: registered.authorizationRef, + }); + this.projectToGitWorkspace.set(projectId, binding.workspaceId); + this.projectBindings.set(projectId, { + gitWorkspaceId: binding.workspaceId, + baseSha: ensured.baseSha, + hostGrantRef: authorizationRef, + }); return { gitWorkspaceId: binding.workspaceId }; } @@ -154,7 +271,7 @@ export class CompositionWorktreeHost { } const { gitWorkspaceId } = await this.bindProject(input.projectId); this.git.attachRun(runKey, gitWorkspaceId); - const instance = await this.git.provisionRunWorkspace(runKey, this.baseSha); + const instance = await this.git.provisionRunWorkspace(runKey, this.baseShaFor(input.projectId)); const worktreePath = await this.git.assertInstancePath(instance.workspaceInstanceId, "."); const record: ProvisionedWorktree = { projectId: input.projectId, @@ -232,3 +349,34 @@ export function bindWorktreesToHost(input: { } return port; } + +function readPersistedGrant(file: string, authorizationRef: string): string | undefined { + let raw: string; + try { + raw = fs.readFileSync(file, "utf8"); + } catch { + return undefined; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + return undefined; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return undefined; + } + const record = parsed as { grants?: unknown }; + const grants = record.grants; + if (grants === null || typeof grants !== "object" || Array.isArray(grants)) { + return undefined; + } + const value = (grants as Record)[authorizationRef]; + if (typeof value !== "string" || value.length === 0) { + return undefined; + } + if (value.startsWith("/") || /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith("\\\\")) { + return path.resolve(value); + } + return undefined; +} diff --git a/apps/daemon/src/modules/dto.ts b/apps/daemon/src/modules/dto.ts index 4aa0295..0e4a1f3 100644 --- a/apps/daemon/src/modules/dto.ts +++ b/apps/daemon/src/modules/dto.ts @@ -68,6 +68,7 @@ export type { WorkerPageDto, WorkerVersionDto, WorkerVersionReferencesDto, + AuthoringChangeSetDto, } from "@workforce/protocol"; export interface PageDto { @@ -162,6 +163,12 @@ export interface ConfirmPlanInput { planArtifactVersionId: string; } +export interface ContinueAuthoringChangeSetInput { + workflowDrafts?: readonly Record[]; + teamDrafts?: readonly Record[]; + taskPatches?: readonly Record[]; +} + export interface StartProjectInput { budgetHardLimitMinor?: number; orchestrationMode?: "workflow_bound" | "direct"; diff --git a/apps/daemon/src/modules/fake-app-services.ts b/apps/daemon/src/modules/fake-app-services.ts index c4b1fa7..e671e2a 100644 --- a/apps/daemon/src/modules/fake-app-services.ts +++ b/apps/daemon/src/modules/fake-app-services.ts @@ -233,6 +233,10 @@ export class FakeAppServices implements AppServices { throw new UseCaseError("unsupported_capability", "authoring chat requires the composed daemon"); } + continueAuthoringChangeSet(): never { + throw new UseCaseError("unsupported_capability", "authoring chat requires the composed daemon"); + } + getOperation(operationId: string, principalId: string): CommandReceipt | null { const receipt = this.operations.get(operationId); if (!receipt || receipt.scope.principalId !== principalId) { diff --git a/apps/daemon/src/modules/index.ts b/apps/daemon/src/modules/index.ts index 584d6d6..f237625 100644 --- a/apps/daemon/src/modules/index.ts +++ b/apps/daemon/src/modules/index.ts @@ -1,4 +1,5 @@ import type { + AuthoringChangeSetDto, AuthoringSessionPageDto, AuthoringSessionViewDto, AuthoringTurnDto, @@ -24,6 +25,7 @@ import type { CommandAcceptedDto, CommandContext, ConfirmPlanInput, + ContinueAuthoringChangeSetInput, CreateProjectInput, CreateWorkspaceInput, CreateWorkerInput, @@ -283,6 +285,12 @@ export interface AppServices { turnId: string, action: Exclude, ): MaybeAsync>; + continueAuthoringChangeSet( + ctx: CommandContext, + sessionId: string, + changeSetId: string, + input: ContinueAuthoringChangeSetInput, + ): MaybeAsync>; listApprovals(query: ListQuery): PageDto; getApproval(id: string): ApprovalDto | null; diff --git a/apps/desktop/src/main/ipc/allowlist.ts b/apps/desktop/src/main/ipc/allowlist.ts index 7873588..edbae47 100644 --- a/apps/desktop/src/main/ipc/allowlist.ts +++ b/apps/desktop/src/main/ipc/allowlist.ts @@ -46,6 +46,10 @@ export const API_ROUTE_TEMPLATES: readonly { method: ApiMethod; path: string }[] { method: "POST", path: "/api/v1/authoring-sessions/{sessionId}/turns/{turnId}/_cmd/cancel" }, { method: "POST", path: "/api/v1/authoring-sessions/{sessionId}/turns/{turnId}/_cmd/retry" }, { method: "POST", path: "/api/v1/authoring-sessions/{sessionId}/turns/{turnId}/_cmd/close" }, + { + method: "POST", + path: "/api/v1/authoring-sessions/{sessionId}/change-sets/{changeSetId}/_cmd/continue", + }, { method: "POST", path: "/api/v1/chat-intents:classify" }, { method: "GET", path: "/api/v1/approvals" }, { method: "GET", path: "/api/v1/approvals/{id}" }, diff --git a/apps/desktop/src/main/ipc/workspace-picker.ts b/apps/desktop/src/main/ipc/workspace-picker.ts index c3fb097..879d156 100644 --- a/apps/desktop/src/main/ipc/workspace-picker.ts +++ b/apps/desktop/src/main/ipc/workspace-picker.ts @@ -1,13 +1,22 @@ +import fs from "node:fs"; import path from "node:path"; import type { ApiRequest, ApiResponse, WorkspaceGrant, WorkspacePickResult } from "@workforce/ui"; +export const WORKSPACE_GRANTS_FILENAME = "workspace-grants.json"; + export interface DirectoryDialog { pick(): Promise; } export class WorkspaceGrantStore { readonly #grants = new Map(); + readonly #persistFile: string | undefined; + + constructor(options: { persistFile?: string } = {}) { + this.#persistFile = options.persistFile; + this.#load(); + } issue(absolutePath: string): WorkspaceGrant { const resolved = path.resolve(absolutePath); @@ -22,6 +31,7 @@ export class WorkspaceGrantStore { } const authorizationId = `wsauth_${crypto.randomUUID()}`; this.#grants.set(authorizationId, resolved); + this.#persist(); return { authorizationId, displayLabel }; } @@ -32,6 +42,40 @@ export class WorkspaceGrantStore { has(authorizationId: string): boolean { return this.#grants.has(authorizationId); } + + #load(): void { + const file = this.#persistFile; + if (!file) { + return; + } + try { + const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as { grants?: unknown }; + const grants = parsed.grants; + if (grants === null || typeof grants !== "object" || Array.isArray(grants)) { + return; + } + for (const [id, hostPath] of Object.entries(grants as Record)) { + if (typeof hostPath === "string" && hostPath.length > 0) { + this.#grants.set(id, path.resolve(hostPath)); + } + } + } catch { + // Missing or unreadable grant file is a cold store, not a picker failure. + } + } + + #persist(): void { + const file = this.#persistFile; + if (!file) { + return; + } + fs.mkdirSync(path.dirname(file), { recursive: true }); + const grants: Record = {}; + for (const [id, hostPath] of this.#grants) { + grants[id] = hostPath; + } + fs.writeFileSync(file, `${JSON.stringify({ grants }, null, 2)}\n`); + } } const PROJECT_WORKSPACE_POST = /^\/api\/v1\/projects\/[^/]+\/workspaces$/; diff --git a/apps/desktop/src/main/start.ts b/apps/desktop/src/main/start.ts index 9749498..e007a91 100644 --- a/apps/desktop/src/main/start.ts +++ b/apps/desktop/src/main/start.ts @@ -1,3 +1,5 @@ +import path from "node:path"; + import { protocolVersion } from "@workforce/protocol"; import { @@ -12,6 +14,7 @@ import { import { createDesktopRuntimeSession } from "./desktop-connection.js"; import { unknownWorkspaceGrantResponse, + WORKSPACE_GRANTS_FILENAME, WorkspaceGrantStore, } from "./ipc/workspace-picker.js"; import { createSupervisorDeps, resolveDesktopStateDir } from "./supervisor-runtime.js"; @@ -78,7 +81,9 @@ export async function startDesktopApp(options: StartDesktopOptions): Promise<{ env, }); const fetchImpl = options.fetchImpl ?? fetch; - const grants = new WorkspaceGrantStore(); + const grants = new WorkspaceGrantStore({ + persistFile: path.join(stateDir, WORKSPACE_GRANTS_FILENAME), + }); let windowPort: DesktopWindowPort | null = null; const runtime = createDesktopRuntimeSession({ From 5c37d5376a68823e47e3c0f3963ffd76909e714a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 13 Sep 2026 14:30:04 +0000 Subject: [PATCH 3/3] feat(daemon): wire authoring turn and ChangeSet retry Route turn retry through retryAuthoringTurn instead of unsupported_capability, and expose ChangeSet retry on the same authoring session so failed sets can move to applying before continue. Co-authored-by: NiceChen --- apps/daemon/src/api/routes.ts | 24 ++++ apps/daemon/src/composition/app-services.ts | 109 ++++++++++++------- apps/daemon/src/modules/fake-app-services.ts | 4 + apps/daemon/src/modules/index.ts | 5 + apps/desktop/src/main/ipc/allowlist.ts | 4 + 5 files changed, 108 insertions(+), 38 deletions(-) diff --git a/apps/daemon/src/api/routes.ts b/apps/daemon/src/api/routes.ts index 81c3e93..699f99a 100644 --- a/apps/daemon/src/api/routes.ts +++ b/apps/daemon/src/api/routes.ts @@ -652,6 +652,30 @@ export function registerRoutes(app: FastifyInstance, deps: RouteDeps): void { }, ); + app.post( + "/api/v1/authoring-sessions/:sessionId/change-sets/:changeSetId/_cmd/retry", + async (request, reply) => { + await cmd( + request, + reply, + { + canonicalOperation: + "POST /authoring-sessions/{sessionId}/change-sets/{changeSetId}:retry", + resource: (req) => `${param(req, "sessionId")}:${param(req, "changeSetId")}`, + requireIfMatch: true, + }, + (ctx, body) => { + rejectUnknownFields(body, ["operationId"]); + return deps.services.retryAuthoringChangeSet( + ctx, + param(request, "sessionId"), + param(request, "changeSetId"), + ); + }, + ); + }, + ); + app.get("/api/v1/approvals", async (request) => deps.services.listApprovals(listQuery(request))); app.get("/api/v1/approvals/:id", async (request, reply) => { const approval = requireFound( diff --git a/apps/daemon/src/composition/app-services.ts b/apps/daemon/src/composition/app-services.ts index 07c0a80..9c5f32f 100644 --- a/apps/daemon/src/composition/app-services.ts +++ b/apps/daemon/src/composition/app-services.ts @@ -1658,27 +1658,8 @@ export class ComposedAppServices implements AppServices { input: ContinueAuthoringChangeSetInput, ): Promise> { return this.exclusive(async () => { - const session = this.sqlite.authoringSessions.get(sessionId); - if (!session) throw new AppError("not_found", "Authoring session not found"); + const { session } = this.requireSessionChangeSet(sessionId, changeSetId); this.assertMatch(session.stateRevision, ctx.ifMatch); - const owned = this.sqlite.connection - .prepare( - `SELECT id FROM authoring_turns WHERE session_id = ? AND change_set_id = ? LIMIT 1`, - ) - .get(sessionId, changeSetId) as { id: string } | undefined; - if (!owned) { - throw new AppError("not_found", "Authoring change set not found in this session"); - } - const stored = this.app.world.authoringChangeSets.get(changeSetId); - if (!stored) { - throw new AppError("not_found", "Authoring change set not found"); - } - if (stored.projectId !== session.projectId) { - throw new AppError( - "validation_failed", - "authoring change set does not belong to this session", - ); - } const workflowDrafts = this.parseContinueDrafts( input.workflowDrafts, parseWorkflowDraft, @@ -1699,19 +1680,24 @@ export class ComposedAppServices implements AppServices { this.syncTeamDraftIntoCatalog(step.targetId); } } - await this.persistDurably(); - const now = this.app.world.nowIso(); - this.sqlite.connection - .prepare( - `UPDATE authoring_sessions SET state_revision = state_revision + 1, updated_at = ? WHERE id = ?`, - ) - .run(now, sessionId); - const current = this.sqlite.authoringSessions.get(sessionId); - return { - status: 200, - body: continued.changeSet, - revision: current?.stateRevision ?? session.stateRevision + 1, - }; + return this.finishSessionChangeSet(sessionId, session.stateRevision, continued.changeSet); + }); + } + + async retryAuthoringChangeSet( + ctx: CommandContext, + sessionId: string, + changeSetId: string, + ): Promise> { + return this.exclusive(async () => { + const { session } = this.requireSessionChangeSet(sessionId, changeSetId); + this.assertMatch(session.stateRevision, ctx.ifMatch); + const retried = await this.app.retryAuthoringChangeSet({ + operationId: ctx.operationId, + idempotencyKey: ctx.operationId, + changeSetId, + }); + return this.finishSessionChangeSet(sessionId, session.stateRevision, retried.changeSet); }); } @@ -1741,20 +1727,20 @@ export class ComposedAppServices implements AppServices { }, this.authoringTurnLifecycleDeps(), ); - void retried; await this.persistDurably(); const current = this.sqlite.authoringTurns.get(turnId); + const revision = current?.stateRevision ?? turn.stateRevision + 1; return { status: 200, body: { operationId: ctx.operationId, acceptedAt: this.app.world.nowIso(), - sessionId, - turnId, + sessionId: retried.sessionId, + turnId: retried.turnId, action: "retry", - revision: current?.stateRevision ?? turn.stateRevision + 1, + revision, }, - revision: current?.stateRevision ?? turn.stateRevision, + revision, }; } @@ -3112,6 +3098,53 @@ export class ComposedAppServices implements AppServices { } } + private requireSessionChangeSet( + sessionId: string, + changeSetId: string, + ): { session: { id: string; projectId: string; stateRevision: number } } { + const session = this.sqlite.authoringSessions.get(sessionId); + if (!session) throw new AppError("not_found", "Authoring session not found"); + const owned = this.sqlite.connection + .prepare( + `SELECT id FROM authoring_turns WHERE session_id = ? AND change_set_id = ? LIMIT 1`, + ) + .get(sessionId, changeSetId) as { id: string } | undefined; + if (!owned) { + throw new AppError("not_found", "Authoring change set not found in this session"); + } + const stored = this.app.world.authoringChangeSets.get(changeSetId); + if (!stored) { + throw new AppError("not_found", "Authoring change set not found"); + } + if (stored.projectId !== session.projectId) { + throw new AppError( + "validation_failed", + "authoring change set does not belong to this session", + ); + } + return { session }; + } + + private async finishSessionChangeSet( + sessionId: string, + previousRevision: number, + changeSet: AuthoringChangeSetDto, + ): Promise> { + await this.persistDurably(); + const now = this.app.world.nowIso(); + this.sqlite.connection + .prepare( + `UPDATE authoring_sessions SET state_revision = state_revision + 1, updated_at = ? WHERE id = ?`, + ) + .run(now, sessionId); + const current = this.sqlite.authoringSessions.get(sessionId); + return { + status: 200, + body: changeSet, + revision: current?.stateRevision ?? previousRevision + 1, + }; + } + private persist(): void { if (this.closed) { try { diff --git a/apps/daemon/src/modules/fake-app-services.ts b/apps/daemon/src/modules/fake-app-services.ts index e671e2a..a6497c5 100644 --- a/apps/daemon/src/modules/fake-app-services.ts +++ b/apps/daemon/src/modules/fake-app-services.ts @@ -237,6 +237,10 @@ export class FakeAppServices implements AppServices { throw new UseCaseError("unsupported_capability", "authoring chat requires the composed daemon"); } + retryAuthoringChangeSet(): never { + throw new UseCaseError("unsupported_capability", "authoring chat requires the composed daemon"); + } + getOperation(operationId: string, principalId: string): CommandReceipt | null { const receipt = this.operations.get(operationId); if (!receipt || receipt.scope.principalId !== principalId) { diff --git a/apps/daemon/src/modules/index.ts b/apps/daemon/src/modules/index.ts index f237625..c190f10 100644 --- a/apps/daemon/src/modules/index.ts +++ b/apps/daemon/src/modules/index.ts @@ -291,6 +291,11 @@ export interface AppServices { changeSetId: string, input: ContinueAuthoringChangeSetInput, ): MaybeAsync>; + retryAuthoringChangeSet( + ctx: CommandContext, + sessionId: string, + changeSetId: string, + ): MaybeAsync>; listApprovals(query: ListQuery): PageDto; getApproval(id: string): ApprovalDto | null; diff --git a/apps/desktop/src/main/ipc/allowlist.ts b/apps/desktop/src/main/ipc/allowlist.ts index edbae47..11588fc 100644 --- a/apps/desktop/src/main/ipc/allowlist.ts +++ b/apps/desktop/src/main/ipc/allowlist.ts @@ -50,6 +50,10 @@ export const API_ROUTE_TEMPLATES: readonly { method: ApiMethod; path: string }[] method: "POST", path: "/api/v1/authoring-sessions/{sessionId}/change-sets/{changeSetId}/_cmd/continue", }, + { + method: "POST", + path: "/api/v1/authoring-sessions/{sessionId}/change-sets/{changeSetId}/_cmd/retry", + }, { method: "POST", path: "/api/v1/chat-intents:classify" }, { method: "GET", path: "/api/v1/approvals" }, { method: "GET", path: "/api/v1/approvals/{id}" },