diff --git a/.gitignore b/.gitignore index ef6067824f2..81a88e3d73b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ node_modules/ *.log .env* !.env.example +.superpowers/ diff --git a/.t3/boards/delivery.json b/.t3/boards/delivery.json new file mode 100644 index 00000000000..4c10b28411b --- /dev/null +++ b/.t3/boards/delivery.json @@ -0,0 +1,73 @@ +{ + "name": "Standard delivery", + "settings": { + "maxConcurrentTickets": 3 + }, + "lanes": [ + { + "key": "backlog", + "name": "Backlog", + "entry": "manual" + }, + { + "key": "implement", + "name": "Implement", + "entry": "auto", + "pipeline": [ + { + "key": "code", + "type": "agent", + "agent": { + "instance": "codex", + "model": "gpt-5.5", + "options": [ + { + "id": "reasoningEffort", + "value": "xhigh" + } + ] + }, + "instruction": "Implement the requested ticket in this worktree. Keep the change focused, run the relevant checks, and report the verification evidence.", + "captureOutput": true + }, + { + "key": "review", + "type": "agent", + "agent": { + "instance": "codex", + "model": "gpt-5.5", + "options": [ + { + "id": "reasoningEffort", + "value": "medium" + } + ] + }, + "instruction": "Review the accumulated diff for blocking correctness, reliability, or integration issues. List only issues that must be fixed before the ticket can ship.", + "captureOutput": true + } + ], + "on": { + "success": "owner_review", + "failure": "needs_attention", + "blocked": "needs_attention" + } + }, + { + "key": "owner_review", + "name": "Owner Review", + "entry": "manual" + }, + { + "key": "needs_attention", + "name": "Needs Attention", + "entry": "manual" + }, + { + "key": "done", + "name": "Done", + "entry": "manual", + "terminal": true + } + ] +} diff --git a/apps/server/package.json b/apps/server/package.json index 8ef9784ba7f..dcf97ed2b79 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -30,6 +30,7 @@ "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", + "json-logic-js": "^2.0.5", "node-pty": "^1.1.0" }, "devDependencies": { diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 871ec1eab60..14f9fe99dcb 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { AuthAdministrativeScopes } from "@t3tools/contracts"; +import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -92,13 +92,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ); expect(verified.sessionId.length).toBeGreaterThan(0); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ]); + expect(verified.scopes).toEqual([...AuthStandardClientScopes]); expect(verified.subject).toBe("one-time-token"); }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); @@ -173,16 +167,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { makeCookieRequest(exchanged.sessionToken), ); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(verified.scopes).toEqual([...AuthAdministrativeScopes]); expect(verified.subject).toBe("administrative-bootstrap"); }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 44c28dea416..020b0b9e7d1 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AuthAdministrativeScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -74,29 +75,11 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) = const listedAfterRevoke = yield* environmentAuth.listSessions(); expect(issued.method).toBe("bearer-access-token"); - expect(issued.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(issued.scopes).toEqual([...AuthAdministrativeScopes]); expect(issued.client.deviceType).toBe("bot"); expect(issued.client.label).toBe("deploy-bot"); expect(verified.sessionId).toBe(issued.sessionId); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(verified.scopes).toEqual([...AuthAdministrativeScopes]); expect(verified.method).toBe("bearer-access-token"); expect(listedBeforeRevoke).toHaveLength(1); expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId); diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 3861b4fc78f..a2685bfeb18 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AuthAdministrativeScopes, AuthStandardClientScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -52,13 +53,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential)); expect(first.method).toBe("one-time-token"); - expect(first.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ]); + expect(first.scopes).toEqual([...AuthStandardClientScopes]); expect(first.subject).toBe("one-time-token"); expect(first.label).toBe("Julius iPhone"); expect(issued.label).toBe("Julius iPhone"); @@ -122,16 +117,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); expect(first.method).toBe("desktop-bootstrap"); - expect(first.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + expect(first.scopes).toEqual([...AuthAdministrativeScopes]); expect(first.subject).toBe("desktop-bootstrap"); expect(second._tag).toBe("BootstrapCredentialInvalidError"); }).pipe( diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 00abd6b9945..33b53d161b8 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AuthStandardClientScopes } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -123,13 +124,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect(verified.method).toBe("bearer-access-token"); expect(verified.subject).toBe("test-clock"); - expect(verified.scopes).toEqual([ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - ]); + expect(verified.scopes).toEqual([...AuthStandardClientScopes]); }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index ed640863d21..ae0ff3fac76 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -4,6 +4,8 @@ import { AuthStandardClientScopes, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthWorkflowOperateScope, + AuthWorkflowReadScope, AuthRelayReadScope, AuthRelayWriteScope, AuthReviewWriteScope, @@ -249,6 +251,8 @@ export const authHttpApiLayer = HttpApiBuilder.group( allowedScopes: new Set([ AuthOrchestrationReadScope, AuthOrchestrationOperateScope, + AuthWorkflowReadScope, + AuthWorkflowOperateScope, AuthTerminalOperateScope, AuthReviewWriteScope, AuthAccessReadScope, diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 27a1d55e90d..5e1994bbf68 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { EnvironmentOrchestrationHttpApi } from "@t3tools/contracts"; +import { AuthAdministrativeScopes, EnvironmentOrchestrationHttpApi } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -351,28 +351,10 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { assert.equal(typeof issued.sessionId, "string"); assert.equal(typeof issued.token, "string"); - assert.deepEqual(issued.scopes, [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + assert.deepEqual(issued.scopes, [...AuthAdministrativeScopes]); assert.equal(listed.length, 1); assert.equal(listed[0]?.sessionId, issued.sessionId); - assert.deepEqual(listed[0]?.scopes, [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + assert.deepEqual(listed[0]?.scopes, [...AuthAdministrativeScopes]); assert.equal("token" in (listed[0] ?? {}), false); }), ); diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 9f31532855a..c6f7cc9cbae 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -108,6 +108,7 @@ describe("CheckpointDiffQueryLive", () => { }), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + isThreadHidden: () => Effect.succeed(false), }), ), ); @@ -200,6 +201,7 @@ describe("CheckpointDiffQueryLive", () => { getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + isThreadHidden: () => Effect.succeed(false), }), ), ); @@ -282,6 +284,7 @@ describe("CheckpointDiffQueryLive", () => { getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + isThreadHidden: () => Effect.succeed(false), }), ), ); @@ -349,6 +352,7 @@ describe("CheckpointDiffQueryLive", () => { getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + isThreadHidden: () => Effect.succeed(false), }), ), ); @@ -401,6 +405,7 @@ describe("CheckpointDiffQueryLive", () => { getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + isThreadHidden: () => Effect.succeed(false), }), ), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 56876ec148e..ebb8b4c8a7b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -200,6 +200,7 @@ describe("OrchestrationEngine", () => { getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + isThreadHidden: () => Effect.succeed(false), }), ), Layer.provide( diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 4a48de19d39..3ab282a530c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -586,6 +586,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti pendingUserInputCount: 0, hasActionableProposedPlan: 0, deletedAt: null, + hidden: event.payload.hidden === true ? 1 : 0, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index e629d1604b3..c08a2ef5c36 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -369,6 +369,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { FROM projection_threads WHERE deleted_at IS NULL AND archived_at IS NULL + AND hidden = 0 ORDER BY project_id ASC, created_at ASC, thread_id ASC `, }); @@ -508,6 +509,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ON threads.thread_id = sessions.thread_id WHERE threads.deleted_at IS NULL AND threads.archived_at IS NULL + AND threads.hidden = 0 ORDER BY sessions.thread_id ASC `, }); @@ -602,6 +604,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { AND turns.turn_id = threads.latest_turn_id WHERE threads.deleted_at IS NULL AND threads.archived_at IS NULL + AND threads.hidden = 0 AND threads.latest_turn_id IS NOT NULL ORDER BY turns.thread_id ASC `, @@ -711,6 +714,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { WHERE project_id = ${projectId} AND deleted_at IS NULL AND archived_at IS NULL + AND hidden = 0 ORDER BY created_at ASC, thread_id ASC LIMIT 1 `, @@ -1012,16 +1016,36 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { Effect.flatMap( ([ projectRows, - threadRows, - messageRows, - proposedPlanRows, - activityRows, - sessionRows, - checkpointRows, - latestTurnRows, + allThreadRows, + allMessageRows, + allProposedPlanRows, + allActivityRows, + allSessionRows, + allCheckpointRows, + allLatestTurnRows, stateRows, ]) => Effect.gen(function* () { + // The public snapshot must never expose hidden (workflow + // internal) threads or any of their child rows; the decider's + // command read model keeps them via getCommandReadModel. + const hiddenThreadIds = new Set( + (yield* listHiddenThreadIds.pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionSnapshotQuery.getSnapshot:listHidden:query"), + ), + )).map((row) => row.threadId), + ); + const visible = ( + rows: ReadonlyArray, + ) => rows.filter((row) => !hiddenThreadIds.has(row.threadId)); + const threadRows = visible(allThreadRows); + const messageRows = visible(allMessageRows); + const proposedPlanRows = visible(allProposedPlanRows); + const activityRows = visible(allActivityRows); + const sessionRows = visible(allSessionRows); + const checkpointRows = visible(allCheckpointRows); + const latestTurnRows = visible(allLatestTurnRows); const messagesByThread = new Map>(); const proposedPlansByThread = new Map>(); const activitiesByThread = new Map>(); @@ -1894,6 +1918,22 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { } satisfies OrchestrationThreadShell); }); + const listHiddenThreadIds = sql<{ readonly threadId: string }>` + SELECT thread_id AS "threadId" + FROM projection_threads + WHERE hidden = 1 + `; + + const isThreadHidden: ProjectionSnapshotQueryShape["isThreadHidden"] = (threadId) => + sql<{ readonly hidden: number }>` + SELECT hidden + FROM projection_threads + WHERE thread_id = ${threadId} + `.pipe( + Effect.map((rows) => (rows[0]?.hidden ?? 0) !== 0), + Effect.mapError(toPersistenceSqlError("ProjectionSnapshotQuery.isThreadHidden:query")), + ); + const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => Effect.gen(function* () { const [ @@ -2047,6 +2087,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getFullThreadDiffContext, getThreadShellById, getThreadDetailById, + isThreadHidden, } satisfies ProjectionSnapshotQueryShape; }); diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 7d85f0240f7..ff6e88ead47 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -157,6 +157,14 @@ export interface ProjectionSnapshotQueryShape { readonly getThreadDetailById: ( threadId: ThreadId, ) => Effect.Effect, ProjectionRepositoryError>; + + /** + * Whether a thread is internal (workflow step/intake dispatch) and must be + * kept out of user-facing thread lists and live shell streams. + */ + readonly isThreadHidden: ( + threadId: ThreadId, + ) => Effect.Effect; } /** diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 0d4af771ca8..de567b48237 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -241,6 +241,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" worktreePath: command.worktreePath, createdAt: command.createdAt, updatedAt: command.createdAt, + ...(command.hidden === undefined ? {} : { hidden: command.hidden }), }, }; } diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 1baeb375c15..3571ee2f9bf 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -47,7 +47,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, - deleted_at + deleted_at, + hidden ) VALUES ( ${row.threadId}, @@ -66,7 +67,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, ${row.hasActionableProposedPlan}, - ${row.deletedAt} + ${row.deletedAt}, + ${row.hidden ?? 0} ) ON CONFLICT (thread_id) DO UPDATE SET @@ -85,7 +87,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, has_actionable_proposed_plan = excluded.has_actionable_proposed_plan, - deleted_at = excluded.deleted_at + deleted_at = excluded.deleted_at, + hidden = excluded.hidden `, }); @@ -111,7 +114,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + hidden FROM projection_threads WHERE thread_id = ${threadId} `, @@ -139,7 +143,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + hidden FROM projection_threads WHERE project_id = ${projectId} ORDER BY created_at ASC, thread_id ASC diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ba1131ee259..46a7c84ac8c 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,28 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; +import Migration0033 from "./Migrations/033_WorkflowEvents.ts"; +import Migration0034 from "./Migrations/034_WorkflowTicketToken.ts"; +import Migration0035 from "./Migrations/035_WorkflowLease.ts"; +import Migration0036 from "./Migrations/036_WorkflowDispatchOutbox.ts"; +import Migration0037 from "./Migrations/037_WorkflowSetupRun.ts"; +import Migration0038 from "./Migrations/038_WorkflowStepRefs.ts"; +import Migration0039 from "./Migrations/039_WorkflowProjectTrust.ts"; +import Migration0040 from "./Migrations/040_WorkflowScriptRun.ts"; +import Migration0041 from "./Migrations/041_WorkflowStepOutput.ts"; +import Migration0042 from "./Migrations/042_WorkflowTicketQueue.ts"; +import Migration0043 from "./Migrations/043_WorkflowBoardVersion.ts"; +import Migration0044 from "./Migrations/044_WorkflowTicketMessages.ts"; +import Migration0045 from "./Migrations/045_WorkflowStepProviderResponseKind.ts"; +import Migration0046 from "./Migrations/046_WorkflowTicketTerminalAt.ts"; +import Migration0047 from "./Migrations/047_WorkflowDispatchOutboxOptions.ts"; +import Migration0048 from "./Migrations/048_WorkflowStepRunAttempt.ts"; +import Migration0049 from "./Migrations/049_WorkflowStepRunUsage.ts"; +import Migration0050 from "./Migrations/050_ProjectionThreadHidden.ts"; +import Migration0051 from "./Migrations/051_WorkflowDispatchOutboxThreadShell.ts"; +import Migration0052 from "./Migrations/052_WorkflowTicketDependencies.ts"; +import Migration0053 from "./Migrations/053_WorkflowTicketTokenBudget.ts"; +import Migration0054 from "./Migrations/054_WorkflowBoardWebhook.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +111,28 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [33, "WorkflowEvents", Migration0033], + [34, "WorkflowTicketToken", Migration0034], + [35, "WorkflowLease", Migration0035], + [36, "WorkflowDispatchOutbox", Migration0036], + [37, "WorkflowSetupRun", Migration0037], + [38, "WorkflowStepRefs", Migration0038], + [39, "WorkflowProjectTrust", Migration0039], + [40, "WorkflowScriptRun", Migration0040], + [41, "WorkflowStepOutput", Migration0041], + [42, "WorkflowTicketQueue", Migration0042], + [43, "WorkflowBoardVersion", Migration0043], + [44, "WorkflowTicketMessages", Migration0044], + [45, "WorkflowStepProviderResponseKind", Migration0045], + [46, "WorkflowTicketTerminalAt", Migration0046], + [47, "WorkflowDispatchOutboxOptions", Migration0047], + [48, "WorkflowStepRunAttempt", Migration0048], + [49, "WorkflowStepRunUsage", Migration0049], + [50, "ProjectionThreadHidden", Migration0050], + [51, "WorkflowDispatchOutboxThreadShell", Migration0051], + [52, "WorkflowTicketDependencies", Migration0052], + [53, "WorkflowTicketTokenBudget", Migration0053], + [54, "WorkflowBoardWebhook", Migration0054], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_WorkflowEvents.ts b/apps/server/src/persistence/Migrations/033_WorkflowEvents.ts new file mode 100644 index 00000000000..8bb5c766ff3 --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_WorkflowEvents.ts @@ -0,0 +1,85 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + ticket_id TEXT NOT NULL, + stream_version INTEGER NOT NULL, + event_type TEXT NOT NULL, + occurred_at TEXT NOT NULL, + payload_json TEXT NOT NULL + ) + `; + yield* sql` + CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_events_stream_version + ON workflow_events(ticket_id, stream_version) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_board ( + board_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + workflow_file_path TEXT NOT NULL, + workflow_version_hash TEXT NOT NULL, + max_concurrent_tickets INTEGER NOT NULL + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_ticket ( + ticket_id TEXT PRIMARY KEY, + board_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + current_lane_key TEXT NOT NULL, + status TEXT NOT NULL, + worktree_ref TEXT, + baseline_ref TEXT, + external_ref TEXT, + priority INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_ticket_board + ON projection_ticket(board_id) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_pipeline_run ( + pipeline_run_id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL, + lane_key TEXT NOT NULL, + lane_entry_token TEXT NOT NULL, + status TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_step_run ( + step_run_id TEXT PRIMARY KEY, + pipeline_run_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + step_key TEXT NOT NULL, + step_type TEXT NOT NULL, + status TEXT NOT NULL, + waiting_reason TEXT, + error TEXT, + started_at TEXT NOT NULL, + finished_at TEXT + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_step_run_ticket + ON projection_step_run(ticket_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/034_WorkflowTicketToken.ts b/apps/server/src/persistence/Migrations/034_WorkflowTicketToken.ts new file mode 100644 index 00000000000..666212dad94 --- /dev/null +++ b/apps/server/src/persistence/Migrations/034_WorkflowTicketToken.ts @@ -0,0 +1,8 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql`ALTER TABLE projection_ticket ADD COLUMN current_lane_entry_token TEXT`; +}); diff --git a/apps/server/src/persistence/Migrations/035_WorkflowLease.ts b/apps/server/src/persistence/Migrations/035_WorkflowLease.ts new file mode 100644 index 00000000000..3317a351bab --- /dev/null +++ b/apps/server/src/persistence/Migrations/035_WorkflowLease.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS worktree_lease ( + worktree_ref TEXT PRIMARY KEY, + owner_kind TEXT NOT NULL, + owner_id TEXT NOT NULL, + fence_token INTEGER NOT NULL, + acquired_at TEXT NOT NULL, + expires_at TEXT NOT NULL + ) + `; +}); diff --git a/apps/server/src/persistence/Migrations/036_WorkflowDispatchOutbox.ts b/apps/server/src/persistence/Migrations/036_WorkflowDispatchOutbox.ts new file mode 100644 index 00000000000..a1bcc99caf8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/036_WorkflowDispatchOutbox.ts @@ -0,0 +1,27 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_dispatch_outbox ( + dispatch_id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL, + step_run_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + turn_id TEXT, + provider_instance TEXT NOT NULL, + model TEXT NOT NULL, + instruction TEXT NOT NULL, + worktree_path TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + started_at TEXT, + confirmed_at TEXT + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_dispatch_outbox_pending + ON workflow_dispatch_outbox(status) + `; +}); diff --git a/apps/server/src/persistence/Migrations/037_WorkflowSetupRun.ts b/apps/server/src/persistence/Migrations/037_WorkflowSetupRun.ts new file mode 100644 index 00000000000..d9752a40a89 --- /dev/null +++ b/apps/server/src/persistence/Migrations/037_WorkflowSetupRun.ts @@ -0,0 +1,17 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_setup_run ( + setup_run_id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL UNIQUE, + worktree_ref TEXT NOT NULL, + status TEXT NOT NULL, + exit_code INTEGER, + started_at TEXT NOT NULL, + finished_at TEXT + ) + `; +}); diff --git a/apps/server/src/persistence/Migrations/038_WorkflowStepRefs.ts b/apps/server/src/persistence/Migrations/038_WorkflowStepRefs.ts new file mode 100644 index 00000000000..f33570c996c --- /dev/null +++ b/apps/server/src/persistence/Migrations/038_WorkflowStepRefs.ts @@ -0,0 +1,8 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE projection_step_run ADD COLUMN pre_checkpoint_ref TEXT`; + yield* sql`ALTER TABLE projection_step_run ADD COLUMN post_checkpoint_ref TEXT`; +}); diff --git a/apps/server/src/persistence/Migrations/039_WorkflowProjectTrust.test.ts b/apps/server/src/persistence/Migrations/039_WorkflowProjectTrust.test.ts new file mode 100644 index 00000000000..1ee359631fe --- /dev/null +++ b/apps/server/src/persistence/Migrations/039_WorkflowProjectTrust.test.ts @@ -0,0 +1,37 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("039_WorkflowProjectTrust", (it) => { + it.effect("creates workflow_project_trust for per-project script trust", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 39 }); + + const tables = yield* sql<{ readonly name: string }>` + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name = 'workflow_project_trust' + `; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(workflow_project_trust) + `; + + assert.deepEqual( + tables.map((table) => table.name), + ["workflow_project_trust"], + ); + assert.deepEqual( + columns.map((column) => column.name), + ["project_id", "trusted_at"], + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/039_WorkflowProjectTrust.ts b/apps/server/src/persistence/Migrations/039_WorkflowProjectTrust.ts new file mode 100644 index 00000000000..c9075d389c3 --- /dev/null +++ b/apps/server/src/persistence/Migrations/039_WorkflowProjectTrust.ts @@ -0,0 +1,12 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_project_trust ( + project_id TEXT PRIMARY KEY, + trusted_at TEXT NOT NULL + ) + `; +}); diff --git a/apps/server/src/persistence/Migrations/040_WorkflowScriptRun.test.ts b/apps/server/src/persistence/Migrations/040_WorkflowScriptRun.test.ts new file mode 100644 index 00000000000..81da695e5fb --- /dev/null +++ b/apps/server/src/persistence/Migrations/040_WorkflowScriptRun.test.ts @@ -0,0 +1,39 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("040_WorkflowScriptRun", (it) => { + it.effect("creates workflow_script_run for durable script terminal metadata", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 40 }); + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(workflow_script_run) + `; + + assert.deepEqual( + columns.map((column) => column.name), + [ + "script_run_id", + "step_run_id", + "ticket_id", + "script_thread_id", + "terminal_id", + "status", + "exit_code", + "signal", + "started_at", + "finished_at", + ], + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/040_WorkflowScriptRun.ts b/apps/server/src/persistence/Migrations/040_WorkflowScriptRun.ts new file mode 100644 index 00000000000..a39bbff0b7d --- /dev/null +++ b/apps/server/src/persistence/Migrations/040_WorkflowScriptRun.ts @@ -0,0 +1,28 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_script_run ( + script_run_id TEXT PRIMARY KEY, + step_run_id TEXT NOT NULL UNIQUE, + ticket_id TEXT NOT NULL, + script_thread_id TEXT NOT NULL, + terminal_id TEXT NOT NULL, + status TEXT NOT NULL, + exit_code INTEGER, + signal INTEGER, + started_at TEXT NOT NULL, + finished_at TEXT + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_script_run_ticket + ON workflow_script_run(ticket_id) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_script_run_status + ON workflow_script_run(status) + `; +}); diff --git a/apps/server/src/persistence/Migrations/041_WorkflowStepOutput.test.ts b/apps/server/src/persistence/Migrations/041_WorkflowStepOutput.test.ts new file mode 100644 index 00000000000..5dc6f30dbd0 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_WorkflowStepOutput.test.ts @@ -0,0 +1,21 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../Layers/Sqlite.ts"; +import { runMigrations } from "../Migrations.ts"; + +const layer = it.layer(Layer.mergeAll(SqlitePersistenceMemory)); + +layer("041_WorkflowStepOutput", (it) => { + it.effect("adds output_json to projected step runs", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 41 }); + + const cols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_step_run)`; + assert.isTrue(cols.some((col) => col.name === "output_json")); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/041_WorkflowStepOutput.ts b/apps/server/src/persistence/Migrations/041_WorkflowStepOutput.ts new file mode 100644 index 00000000000..9cee8687d67 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_WorkflowStepOutput.ts @@ -0,0 +1,7 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE projection_step_run ADD COLUMN output_json TEXT`; +}); diff --git a/apps/server/src/persistence/Migrations/042_WorkflowTicketQueue.test.ts b/apps/server/src/persistence/Migrations/042_WorkflowTicketQueue.test.ts new file mode 100644 index 00000000000..de2ab676b3e --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_WorkflowTicketQueue.test.ts @@ -0,0 +1,47 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../Layers/Sqlite.ts"; +import { migrationEntries, runMigrations } from "../Migrations.ts"; + +const layer = it.layer(Layer.mergeAll(SqlitePersistenceMemory)); + +layer("042_WorkflowTicketQueue", (it) => { + it.effect("adds queued_at and lane admission indexes to projected tickets", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + assert.isTrue( + migrationEntries.some(([id, name]) => id === 42 && name === "WorkflowTicketQueue"), + ); + + yield* runMigrations({ toMigrationInclusive: 42 }); + + const cols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_ticket)`; + assert.isTrue(cols.some((col) => col.name === "queued_at")); + + const indexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(projection_ticket) + `; + assert.isTrue(indexes.some((index) => index.name === "idx_projection_ticket_lane_admission")); + assert.isTrue(indexes.some((index) => index.name === "idx_projection_ticket_lane_queue")); + + const admissionIndex = yield* sql<{ readonly name: string }>` + PRAGMA index_info(idx_projection_ticket_lane_admission) + `; + const queueIndex = yield* sql<{ readonly name: string }>` + PRAGMA index_info(idx_projection_ticket_lane_queue) + `; + + assert.deepEqual( + admissionIndex.map((col) => col.name), + ["board_id", "current_lane_key", "current_lane_entry_token"], + ); + assert.deepEqual( + queueIndex.map((col) => col.name), + ["board_id", "current_lane_key", "queued_at"], + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/042_WorkflowTicketQueue.ts b/apps/server/src/persistence/Migrations/042_WorkflowTicketQueue.ts new file mode 100644 index 00000000000..1bd79fcada8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/042_WorkflowTicketQueue.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql`ALTER TABLE projection_ticket ADD COLUMN queued_at TEXT`; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_ticket_lane_admission + ON projection_ticket(board_id, current_lane_key, current_lane_entry_token) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_ticket_lane_queue + ON projection_ticket(board_id, current_lane_key, queued_at) + `; +}); diff --git a/apps/server/src/persistence/Migrations/043_WorkflowBoardVersion.test.ts b/apps/server/src/persistence/Migrations/043_WorkflowBoardVersion.test.ts new file mode 100644 index 00000000000..154409e4f9e --- /dev/null +++ b/apps/server/src/persistence/Migrations/043_WorkflowBoardVersion.test.ts @@ -0,0 +1,69 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../Layers/Sqlite.ts"; +import { migrationEntries, runMigrations } from "../Migrations.ts"; + +const layer = it.layer(Layer.mergeAll(SqlitePersistenceMemory)); + +layer("043_WorkflowBoardVersion", (it) => { + it.effect("creates the workflow board version table and board-scoped indexes", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + assert.isTrue( + migrationEntries.some(([id, name]) => id === 43 && name === "WorkflowBoardVersion"), + ); + + yield* runMigrations({ toMigrationInclusive: 43 }); + + const cols = yield* sql<{ + readonly name: string; + readonly type: string; + readonly pk: number; + readonly notnull: number; + }>`PRAGMA table_info(workflow_board_version)`; + + assert.deepEqual( + cols.map((col) => ({ + name: col.name, + type: col.type, + pk: col.pk, + notnull: col.notnull, + })), + [ + { name: "version_id", type: "INTEGER", pk: 1, notnull: 0 }, + { name: "board_id", type: "TEXT", pk: 0, notnull: 1 }, + { name: "version_hash", type: "TEXT", pk: 0, notnull: 1 }, + { name: "content_json", type: "TEXT", pk: 0, notnull: 1 }, + { name: "source", type: "TEXT", pk: 0, notnull: 1 }, + { name: "created_at", type: "TEXT", pk: 0, notnull: 1 }, + ], + ); + + const indexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(workflow_board_version) + `; + assert.isTrue(indexes.some((index) => index.name === "idx_workflow_board_version_board")); + assert.isTrue(indexes.some((index) => index.name === "idx_workflow_board_version_hash")); + + const boardIndex = yield* sql<{ readonly name: string }>` + PRAGMA index_info(idx_workflow_board_version_board) + `; + const hashIndex = yield* sql<{ readonly name: string }>` + PRAGMA index_info(idx_workflow_board_version_hash) + `; + + assert.deepEqual( + boardIndex.map((col) => col.name), + ["board_id", "version_id"], + ); + assert.deepEqual( + hashIndex.map((col) => col.name), + ["board_id", "version_hash"], + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/043_WorkflowBoardVersion.ts b/apps/server/src/persistence/Migrations/043_WorkflowBoardVersion.ts new file mode 100644 index 00000000000..ec0303c271f --- /dev/null +++ b/apps/server/src/persistence/Migrations/043_WorkflowBoardVersion.ts @@ -0,0 +1,25 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_board_version ( + version_id INTEGER PRIMARY KEY AUTOINCREMENT, + board_id TEXT NOT NULL, + version_hash TEXT NOT NULL, + content_json TEXT NOT NULL, + source TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_board_version_board + ON workflow_board_version(board_id, version_id) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_board_version_hash + ON workflow_board_version(board_id, version_hash) + `; +}); diff --git a/apps/server/src/persistence/Migrations/044_WorkflowTicketMessages.test.ts b/apps/server/src/persistence/Migrations/044_WorkflowTicketMessages.test.ts new file mode 100644 index 00000000000..1664e20544e --- /dev/null +++ b/apps/server/src/persistence/Migrations/044_WorkflowTicketMessages.test.ts @@ -0,0 +1,66 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../Layers/Sqlite.ts"; +import { migrationEntries, runMigrations } from "../Migrations.ts"; + +const layer = it.layer(Layer.mergeAll(SqlitePersistenceMemory)); + +layer("044_WorkflowTicketMessages", (it) => { + it.effect("creates ticket messages and keeps ticket descriptions projected", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + assert.isTrue( + migrationEntries.some(([id, name]) => id === 44 && name === "WorkflowTicketMessages"), + ); + + yield* runMigrations({ toMigrationInclusive: 44 }); + + const ticketCols = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_ticket) + `; + assert.isTrue(ticketCols.some((col) => col.name === "description")); + + const messageCols = yield* sql<{ + readonly name: string; + readonly type: string; + readonly pk: number; + readonly notnull: number; + }>`PRAGMA table_info(projection_ticket_message)`; + + assert.deepEqual( + messageCols.map((col) => ({ + name: col.name, + type: col.type, + pk: col.pk, + notnull: col.notnull, + })), + [ + { name: "message_id", type: "TEXT", pk: 1, notnull: 1 }, + { name: "ticket_id", type: "TEXT", pk: 0, notnull: 1 }, + { name: "step_run_id", type: "TEXT", pk: 0, notnull: 0 }, + { name: "author", type: "TEXT", pk: 0, notnull: 1 }, + { name: "body", type: "TEXT", pk: 0, notnull: 1 }, + { name: "attachments_json", type: "TEXT", pk: 0, notnull: 1 }, + { name: "created_at", type: "TEXT", pk: 0, notnull: 1 }, + ], + ); + + const indexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(projection_ticket_message) + `; + assert.isTrue(indexes.some((index) => index.name === "idx_projection_ticket_message_ticket")); + + const ticketIndex = yield* sql<{ readonly name: string }>` + PRAGMA index_info(idx_projection_ticket_message_ticket) + `; + assert.deepEqual( + ticketIndex.map((col) => col.name), + ["ticket_id", "created_at"], + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/044_WorkflowTicketMessages.ts b/apps/server/src/persistence/Migrations/044_WorkflowTicketMessages.ts new file mode 100644 index 00000000000..9641241ef5c --- /dev/null +++ b/apps/server/src/persistence/Migrations/044_WorkflowTicketMessages.ts @@ -0,0 +1,27 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const ticketCols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_ticket)`; + if (!ticketCols.some((col) => col.name === "description")) { + yield* sql`ALTER TABLE projection_ticket ADD COLUMN description TEXT`; + } + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_ticket_message ( + message_id TEXT PRIMARY KEY NOT NULL, + ticket_id TEXT NOT NULL, + step_run_id TEXT, + author TEXT NOT NULL, + body TEXT NOT NULL, + attachments_json TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_ticket_message_ticket + ON projection_ticket_message(ticket_id, created_at) + `; +}); diff --git a/apps/server/src/persistence/Migrations/045_WorkflowStepProviderResponseKind.test.ts b/apps/server/src/persistence/Migrations/045_WorkflowStepProviderResponseKind.test.ts new file mode 100644 index 00000000000..df4b24304ef --- /dev/null +++ b/apps/server/src/persistence/Migrations/045_WorkflowStepProviderResponseKind.test.ts @@ -0,0 +1,21 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../Layers/Sqlite.ts"; +import { runMigrations } from "../Migrations.ts"; + +const layer = it.layer(Layer.mergeAll(SqlitePersistenceMemory)); + +layer("045_WorkflowStepProviderResponseKind", (it) => { + it.effect("adds provider_response_kind to projected step runs", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 45 }); + + const cols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_step_run)`; + assert.isTrue(cols.some((col) => col.name === "provider_response_kind")); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/045_WorkflowStepProviderResponseKind.ts b/apps/server/src/persistence/Migrations/045_WorkflowStepProviderResponseKind.ts new file mode 100644 index 00000000000..e310d7f8698 --- /dev/null +++ b/apps/server/src/persistence/Migrations/045_WorkflowStepProviderResponseKind.ts @@ -0,0 +1,7 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE projection_step_run ADD COLUMN provider_response_kind TEXT`; +}); diff --git a/apps/server/src/persistence/Migrations/046_WorkflowTicketTerminalAt.test.ts b/apps/server/src/persistence/Migrations/046_WorkflowTicketTerminalAt.test.ts new file mode 100644 index 00000000000..eb99c604fe3 --- /dev/null +++ b/apps/server/src/persistence/Migrations/046_WorkflowTicketTerminalAt.test.ts @@ -0,0 +1,41 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../Migrations.ts"; +import { SqlitePersistenceMemory } from "../Layers/Sqlite.ts"; +import Migration046 from "./046_WorkflowTicketTerminalAt.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("046_WorkflowTicketTerminalAt", (it) => { + it.effect("adds terminal_at to projection_ticket with a retention sweep index", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const cols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_ticket)`; + const indexes = yield* sql<{ readonly name: string }>`PRAGMA index_list(projection_ticket)`; + + assert.isTrue(cols.some((column) => column.name === "terminal_at")); + assert.isTrue( + indexes.some((index) => index.name === "idx_projection_ticket_terminal_retention"), + ); + }), + ); + + it.effect("is safe to run again when terminal_at already exists", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* Migration046; + + const cols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_ticket)`; + const indexes = yield* sql<{ readonly name: string }>`PRAGMA index_list(projection_ticket)`; + + assert.equal(cols.filter((column) => column.name === "terminal_at").length, 1); + assert.isTrue( + indexes.some((index) => index.name === "idx_projection_ticket_terminal_retention"), + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/046_WorkflowTicketTerminalAt.ts b/apps/server/src/persistence/Migrations/046_WorkflowTicketTerminalAt.ts new file mode 100644 index 00000000000..1196823239f --- /dev/null +++ b/apps/server/src/persistence/Migrations/046_WorkflowTicketTerminalAt.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const ticketCols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_ticket)`; + if (!ticketCols.some((col) => col.name === "terminal_at")) { + yield* sql`ALTER TABLE projection_ticket ADD COLUMN terminal_at TEXT`; + } + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_ticket_terminal_retention + ON projection_ticket(board_id, current_lane_key, terminal_at) + `; +}); diff --git a/apps/server/src/persistence/Migrations/047_WorkflowDispatchOutboxOptions.test.ts b/apps/server/src/persistence/Migrations/047_WorkflowDispatchOutboxOptions.test.ts new file mode 100644 index 00000000000..fc1fa6f796c --- /dev/null +++ b/apps/server/src/persistence/Migrations/047_WorkflowDispatchOutboxOptions.test.ts @@ -0,0 +1,37 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../Migrations.ts"; +import { SqlitePersistenceMemory } from "../Layers/Sqlite.ts"; +import Migration047 from "./047_WorkflowDispatchOutboxOptions.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("047_WorkflowDispatchOutboxOptions", (it) => { + it.effect("adds options_json to workflow_dispatch_outbox", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const cols = yield* sql<{ + readonly name: string; + }>`PRAGMA table_info(workflow_dispatch_outbox)`; + + assert.isTrue(cols.some((column) => column.name === "options_json")); + }), + ); + + it.effect("is safe to run again when options_json already exists", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* Migration047; + + const cols = yield* sql<{ + readonly name: string; + }>`PRAGMA table_info(workflow_dispatch_outbox)`; + + assert.equal(cols.filter((column) => column.name === "options_json").length, 1); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/047_WorkflowDispatchOutboxOptions.ts b/apps/server/src/persistence/Migrations/047_WorkflowDispatchOutboxOptions.ts new file mode 100644 index 00000000000..3ed4e10b525 --- /dev/null +++ b/apps/server/src/persistence/Migrations/047_WorkflowDispatchOutboxOptions.ts @@ -0,0 +1,13 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const cols = yield* sql<{ + readonly name: string; + }>`PRAGMA table_info(workflow_dispatch_outbox)`; + if (!cols.some((col) => col.name === "options_json")) { + yield* sql`ALTER TABLE workflow_dispatch_outbox ADD COLUMN options_json TEXT`; + } +}); diff --git a/apps/server/src/persistence/Migrations/048_WorkflowStepRunAttempt.test.ts b/apps/server/src/persistence/Migrations/048_WorkflowStepRunAttempt.test.ts new file mode 100644 index 00000000000..18a159c9505 --- /dev/null +++ b/apps/server/src/persistence/Migrations/048_WorkflowStepRunAttempt.test.ts @@ -0,0 +1,37 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../Migrations.ts"; +import { SqlitePersistenceMemory } from "../Layers/Sqlite.ts"; +import Migration048 from "./048_WorkflowStepRunAttempt.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("048_WorkflowStepRunAttempt", (it) => { + it.effect("adds attempt to projection_step_run", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const cols = yield* sql<{ + readonly name: string; + }>`PRAGMA table_info(projection_step_run)`; + + assert.isTrue(cols.some((column) => column.name === "attempt")); + }), + ); + + it.effect("is safe to run again when attempt already exists", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* Migration048; + + const cols = yield* sql<{ + readonly name: string; + }>`PRAGMA table_info(projection_step_run)`; + + assert.equal(cols.filter((column) => column.name === "attempt").length, 1); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/048_WorkflowStepRunAttempt.ts b/apps/server/src/persistence/Migrations/048_WorkflowStepRunAttempt.ts new file mode 100644 index 00000000000..05d8ac089ef --- /dev/null +++ b/apps/server/src/persistence/Migrations/048_WorkflowStepRunAttempt.ts @@ -0,0 +1,13 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const cols = yield* sql<{ + readonly name: string; + }>`PRAGMA table_info(projection_step_run)`; + if (!cols.some((col) => col.name === "attempt")) { + yield* sql`ALTER TABLE projection_step_run ADD COLUMN attempt INTEGER`; + } +}); diff --git a/apps/server/src/persistence/Migrations/049_WorkflowStepRunUsage.ts b/apps/server/src/persistence/Migrations/049_WorkflowStepRunUsage.ts new file mode 100644 index 00000000000..2f740d4ad58 --- /dev/null +++ b/apps/server/src/persistence/Migrations/049_WorkflowStepRunUsage.ts @@ -0,0 +1,26 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const cols = yield* sql<{ + readonly name: string; + }>`PRAGMA table_info(projection_step_run)`; + const names = new Set(cols.map((col) => col.name)); + if (!names.has("input_tokens")) { + yield* sql`ALTER TABLE projection_step_run ADD COLUMN input_tokens INTEGER`; + } + if (!names.has("cached_input_tokens")) { + yield* sql`ALTER TABLE projection_step_run ADD COLUMN cached_input_tokens INTEGER`; + } + if (!names.has("output_tokens")) { + yield* sql`ALTER TABLE projection_step_run ADD COLUMN output_tokens INTEGER`; + } + if (!names.has("total_tokens")) { + yield* sql`ALTER TABLE projection_step_run ADD COLUMN total_tokens INTEGER`; + } + if (!names.has("retryable")) { + yield* sql`ALTER TABLE projection_step_run ADD COLUMN retryable INTEGER`; + } +}); diff --git a/apps/server/src/persistence/Migrations/050_ProjectionThreadHidden.ts b/apps/server/src/persistence/Migrations/050_ProjectionThreadHidden.ts new file mode 100644 index 00000000000..1fe9c6987f6 --- /dev/null +++ b/apps/server/src/persistence/Migrations/050_ProjectionThreadHidden.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Adds projection_threads.hidden so internal threads (workflow step and + * intake dispatches) can carry full turn/message/activity projections without + * appearing in user-facing thread lists. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0 + `; +}); diff --git a/apps/server/src/persistence/Migrations/051_WorkflowDispatchOutboxThreadShell.ts b/apps/server/src/persistence/Migrations/051_WorkflowDispatchOutboxThreadShell.ts new file mode 100644 index 00000000000..5e2adb86f07 --- /dev/null +++ b/apps/server/src/persistence/Migrations/051_WorkflowDispatchOutboxThreadShell.ts @@ -0,0 +1,24 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Persists the hidden-thread-shell fields on workflow dispatches so a + * recovery after restart can still create the orchestration thread (and use + * the requested runtime mode) for a dispatch that never started. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE workflow_dispatch_outbox + ADD COLUMN project_id TEXT + `; + yield* sql` + ALTER TABLE workflow_dispatch_outbox + ADD COLUMN thread_title TEXT + `; + yield* sql` + ALTER TABLE workflow_dispatch_outbox + ADD COLUMN runtime_mode TEXT + `; +}); diff --git a/apps/server/src/persistence/Migrations/052_WorkflowTicketDependencies.ts b/apps/server/src/persistence/Migrations/052_WorkflowTicketDependencies.ts new file mode 100644 index 00000000000..29aea6e5379 --- /dev/null +++ b/apps/server/src/persistence/Migrations/052_WorkflowTicketDependencies.ts @@ -0,0 +1,19 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** Ticket-level blocked-by edges; resolved when the dependency ticket reaches a terminal lane. */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_ticket_dependency ( + ticket_id TEXT NOT NULL, + depends_on_ticket_id TEXT NOT NULL, + PRIMARY KEY (ticket_id, depends_on_ticket_id) + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_ticket_dependency_depends_on + ON projection_ticket_dependency(depends_on_ticket_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/053_WorkflowTicketTokenBudget.ts b/apps/server/src/persistence/Migrations/053_WorkflowTicketTokenBudget.ts new file mode 100644 index 00000000000..8b9debaa1e8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/053_WorkflowTicketTokenBudget.ts @@ -0,0 +1,12 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** Optional per-ticket token budget; agent steps block once the usage roll-up reaches it. */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_ticket + ADD COLUMN token_budget INTEGER + `; +}); diff --git a/apps/server/src/persistence/Migrations/054_WorkflowBoardWebhook.ts b/apps/server/src/persistence/Migrations/054_WorkflowBoardWebhook.ts new file mode 100644 index 00000000000..2b13f27794f --- /dev/null +++ b/apps/server/src/persistence/Migrations/054_WorkflowBoardWebhook.ts @@ -0,0 +1,28 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Per-board webhook ingress: the token is stored as a sha256 hash (the + * plaintext is shown once at create/rotate), and deliveries with an id are + * recorded so provider retries cannot re-route a ticket. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_board_webhook ( + board_id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL, + token_prefix TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `; + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_webhook_delivery ( + board_id TEXT NOT NULL, + delivery_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (board_id, delivery_id) + ) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 44fdc147a4a..a79a9028b51 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -41,6 +41,10 @@ export const ProjectionThread = Schema.Struct({ pendingUserInputCount: NonNegativeInt, hasActionableProposedPlan: NonNegativeInt, deletedAt: Schema.NullOr(IsoDateTime), + // Internal threads (workflow step/intake dispatches) carry projections but + // stay out of user-facing thread lists. Optional so ordinary chat-thread + // writers stay untouched; absent means visible. + hidden: Schema.optional(NonNegativeInt), }); export type ProjectionThread = typeof ProjectionThread.Type; diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts index 051a7d20de0..d10cafb9b92 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts @@ -39,6 +39,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), + isThreadHidden: () => Effect.succeed(false), }); describe("ProjectSetupScriptRunner", () => { @@ -55,6 +56,7 @@ describe("ProjectSetupScriptRunner", () => { Layer.succeed(TerminalManager, { open, attachStream: () => Effect.die(new Error("unused")), + attachHistoryStream: () => Effect.die(new Error("unused")), write, resize: () => Effect.void, clear: () => Effect.void, @@ -117,6 +119,7 @@ describe("ProjectSetupScriptRunner", () => { Layer.succeed(TerminalManager, { open, attachStream: () => Effect.die(new Error("unused")), + attachHistoryStream: () => Effect.die(new Error("unused")), write, resize: () => Effect.void, clear: () => Effect.void, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 18e6166c1cd..18ba3723992 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -210,6 +210,7 @@ describe("ProviderSessionReaper", () => { : Option.none(), ), getThreadDetailById: () => Effect.die("unused"), + isThreadHidden: () => Effect.succeed(false), }), ), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 960f27e752b..634d80d6c42 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -354,7 +354,11 @@ const make = Effect.gen(function* () { }); }); - const thread = yield* snapshotQuery.getThreadShellById(threadId); + // Hidden (workflow-internal) threads are never published externally. + const threadHidden = yield* snapshotQuery.isThreadHidden(threadId); + const thread = threadHidden + ? Option.none() + : yield* snapshotQuery.getThreadShellById(threadId); const project = Option.isSome(thread) ? yield* snapshotQuery.getProjectShellById(thread.value.projectId) : Option.none(); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0bf2f6589f0..5ec964eb0d2 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5,6 +5,7 @@ import * as NodeCrypto from "node:crypto"; import { AuthAccessTokenType, + AuthAdministrativeScopes, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, CommandId, @@ -65,6 +66,7 @@ import * as Socket from "effect/unstable/socket/Socket"; import { vi } from "vite-plus/test"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +const administrativeScopeText = AuthAdministrativeScopes.join(" "); import type { ServerConfigShape } from "./config.ts"; import { deriveServerPaths, ServerConfig } from "./config.ts"; @@ -107,6 +109,7 @@ import { ProjectSetupScriptRunnerError, type ProjectSetupScriptRunnerShape, } from "./project/Services/ProjectSetupScriptRunner.ts"; +import { ProjectScriptTrust } from "./workflow/Services/ProjectScriptTrust.ts"; import { RepositoryIdentityResolver, type RepositoryIdentityResolverShape, @@ -136,6 +139,17 @@ import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import { BoardRegistry } from "./workflow/Services/BoardRegistry.ts"; +import { BoardDiscovery } from "./workflow/Services/BoardDiscovery.ts"; +import { ProjectWorkspaceResolver } from "./workflow/Services/ProjectWorkspaceResolver.ts"; +import { TicketDiffQuery } from "./workflow/Services/TicketDiffQuery.ts"; +import { WorkflowBoardEvents } from "./workflow/Services/WorkflowBoardEvents.ts"; +import { WorkflowBoardSaveLocks } from "./workflow/Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowBoardVersionStore } from "./workflow/Services/WorkflowBoardVersionStore.ts"; +import { WorkflowEngine } from "./workflow/Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "./workflow/Services/WorkflowEventStore.ts"; +import { WorkflowFileLoader } from "./workflow/Services/WorkflowFileLoader.ts"; +import { WorkflowReadModel } from "./workflow/Services/WorkflowReadModel.ts"; import * as Data from "effect/Data"; const defaultProjectId = ProjectId.make("project-default"); @@ -535,6 +549,68 @@ const buildAppUnderTest = (options?: { ...options.layers.vcsStatusBroadcaster, }) : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer)); + const workflowRouteServicesLayer = Layer.mergeAll( + Layer.mock(WorkflowEngine)({ + createTicket: () => Effect.die("unused workflow createTicket"), + moveTicket: () => Effect.die("unused workflow moveTicket"), + runLane: () => Effect.die("unused workflow runLane"), + resolveApproval: () => Effect.die("unused workflow resolveApproval"), + cancelStep: () => Effect.die("unused workflow cancelStep"), + cancelBoardPipelines: () => Effect.void, + completeRecoveredStep: () => Effect.die("unused workflow completeRecoveredStep"), + }), + Layer.mock(WorkflowReadModel)({ + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + getBoard: () => Effect.succeed(null), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + listBoardsForProject: () => Effect.succeed([]), + }), + Layer.mock(WorkflowEventStore)({ + append: () => Effect.die("unused workflow event append"), + readByTicket: () => Stream.empty, + readFromSequence: () => Stream.empty, + readAll: () => Stream.empty, + deleteForBoard: () => Effect.void, + }), + Layer.mock(BoardRegistry)({ + register: () => Effect.die("unused workflow board register"), + getDefinition: () => Effect.succeed(null), + getLane: () => Effect.succeed(null), + }), + Layer.mock(TicketDiffQuery)({ + getTicketDiff: () => Effect.die("unused workflow ticket diff"), + }), + Layer.mock(WorkflowBoardEvents)({ + publish: () => Effect.void, + stream: () => Stream.empty, + }), + Layer.mock(WorkflowBoardSaveLocks)({ + withSaveLock: (_boardId, effect) => effect, + }), + Layer.mock(WorkflowBoardVersionStore)({ + record: () => Effect.void, + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }), + Layer.mock(WorkflowFileLoader)({ + loadAndRegister: () => Effect.die("unused workflow file load"), + }), + Layer.mock(BoardDiscovery)({ + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectWorkspaceResolver)({ + resolve: () => Effect.succeed("/tmp/default-project"), + }), + Layer.mock(ProjectScriptTrust)({ + isTrusted: () => Effect.succeed(false), + setTrusted: () => Effect.void, + }), + ); const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, @@ -719,6 +795,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.checkpointDiffQuery, }), ), + Layer.provide(workflowRouteServicesLayer), ); const appLayer = servedRoutesLayer.pipe( @@ -910,9 +987,7 @@ const exchangeAccessToken = ( subject_token: credential, subject_token_type: AuthEnvironmentBootstrapTokenType, requested_token_type: AuthAccessTokenType, - scope: - options?.scope ?? - "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", + scope: options?.scope ?? administrativeScopeText, ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}), ...(options?.clientMetadata?.deviceType ? { client_device_type: options.clientMetadata.deviceType } @@ -1409,10 +1484,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(tokenResponse.status, 200); assert.equal(tokenBody.issued_token_type, AuthAccessTokenType); assert.equal(tokenBody.token_type, "Bearer"); - assert.equal( - tokenBody.scope, - "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", - ); + assert.equal(tokenBody.scope, administrativeScopeText); assert.equal(typeof tokenBody.access_token, "string"); const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); @@ -1430,16 +1502,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(sessionResponse.status, 200); assert.equal(sessionBody.authenticated, true); assert.equal(sessionBody.sessionMethod, "bearer-access-token"); - assert.deepEqual(sessionBody.scopes, [ - "orchestration:read", - "orchestration:operate", - "terminal:operate", - "review:write", - "relay:read", - "access:read", - "access:write", - "relay:write", - ]); + assert.deepEqual(sessionBody.scopes, [...AuthAdministrativeScopes]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 94b6cb753a2..b7dd2c96163 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -5,6 +5,7 @@ import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { ServerConfig } from "./config.ts"; +import { workflowHooksRouteLayer } from "./workflow/webhookRoute.ts"; import { attachmentsRouteLayer, otlpTracesProxyRouteLayer, @@ -17,6 +18,7 @@ import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; +import { ProjectionTurnRepositoryLive } from "./persistence/Layers/ProjectionTurns.ts"; import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; @@ -76,6 +78,7 @@ import * as CloudCliState from "./cloud/CliState.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import { WorkflowServerRuntimeLive } from "./workflow/WorkflowRuntimeLive.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, @@ -259,13 +262,26 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); -const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( +const WorkflowRuntimeLayerLive = WorkflowServerRuntimeLive.pipe( + Layer.provideMerge(CheckpointingLayerLive), + Layer.provideMerge(GitLayerLive), + Layer.provideMerge(GitWorkflowLayerLive), + Layer.provideMerge(ProjectSetupScriptRunnerLive), + Layer.provideMerge(TerminalLayerLive), + Layer.provideMerge(ProviderRuntimeLayerLive), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(PersistenceLayerLive), + Layer.provideMerge(ProviderInstanceRegistryHydrationLive), +); + +const RuntimeCoreEngineLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), + Layer.provideMerge(WorkflowRuntimeLayerLive), Layer.provideMerge(TerminalLayerLive), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(KeybindingsLive), @@ -293,6 +309,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ProjectFaviconResolverLive), Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerEnvironmentLive), +); + +const RuntimeCoreDependenciesLive = RuntimeCoreEngineLive.pipe( Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( @@ -329,6 +348,7 @@ export const makeRoutesLayer = Layer.mergeAll( attachmentsRouteLayer, otlpTracesProxyRouteLayer, projectFaviconRouteLayer, + workflowHooksRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ).pipe(Layer.provide(browserApiCorsLayer)); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 90eebe33820..52a5c8216f9 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -103,6 +103,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), + isThreadHidden: () => Effect.succeed(false), }), Effect.provideService(AnalyticsService, { record: () => Effect.void, @@ -165,6 +166,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), + isThreadHidden: () => Effect.succeed(false), }), Effect.provideService(OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -207,6 +209,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), + isThreadHidden: () => Effect.succeed(false), }), Effect.provideService(OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -255,6 +258,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), + isThreadHidden: () => Effect.succeed(false), }), Effect.provideService(OrchestrationEngineService, { readEvents: () => Stream.empty, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index cde308ffe42..ca4901c5a99 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -34,6 +34,8 @@ import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper.ts"; +import { WorkflowRecovery } from "./workflow/Services/WorkflowRecovery.ts"; +import { WorkflowTerminalRetentionSweeper } from "./workflow/Services/WorkflowTerminalRetentionSweeper.ts"; import { formatHeadlessServeOutput, formatHostForUrl, @@ -286,9 +288,11 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { const keybindings = yield* Keybindings; const orchestrationReactor = yield* OrchestrationReactor; const providerSessionReaper = yield* ProviderSessionReaper; + const workflowTerminalRetentionSweeper = yield* WorkflowTerminalRetentionSweeper; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const serverEnvironment = yield* ServerEnvironment; + const workflowRecovery = yield* WorkflowRecovery; const crypto = yield* Crypto.Crypto; const commandGate = yield* makeCommandGate; @@ -334,9 +338,22 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { Effect.gen(function* () { yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope)); yield* providerSessionReaper.start().pipe(Scope.provide(reactorScope)); + yield* workflowTerminalRetentionSweeper.start().pipe(Scope.provide(reactorScope)); }), ); + yield* Effect.logDebug("startup phase: recovering workflow runtime"); + yield* runStartupPhase( + "workflow.recover", + workflowRecovery.recover().pipe( + Effect.catch((cause) => + Effect.logWarning("workflow recovery failed during startup", { + cause, + }), + ), + ), + ); + const welcomeBase = yield* resolveWelcomeBase; const environment = yield* serverEnvironment.getDescriptor; yield* Effect.logDebug("startup phase: preparing welcome payload"); diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 2ebf8481957..dc777bd28e5 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -219,6 +219,26 @@ interface ManagerFixture { readonly getEvents: Effect.Effect>; } +interface TerminalHistoryAttachStreamEvent { + readonly type: string; + readonly snapshot?: { + readonly threadId: string; + readonly terminalId: string; + readonly history: string; + readonly status: string | null; + readonly exitCode?: number | null; + readonly exitSignal?: number | null; + }; + readonly data?: string; +} + +type TerminalManagerWithHistory = TerminalManagerShape & { + readonly attachHistoryStream: ( + input: { readonly threadId: string; readonly terminalId: string }, + listener: (event: TerminalHistoryAttachStreamEvent) => Effect.Effect, + ) => Effect.Effect<() => void, unknown>; +}; + const createManager = ( historyLineLimit = 5, options: CreateManagerOptions = {}, @@ -355,6 +375,117 @@ it.layer( }), ); + it.effect("attaches to persisted terminal history without a cwd or shell spawn", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager(); + const threadId = "script-thread-1"; + const terminalId = "script-terminal-1"; + + yield* manager.open(openInput({ threadId, terminalId })); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("script output\n"); + process.emitExit({ exitCode: 0, signal: 0 }); + + yield* waitFor( + Effect.map(getEvents, (events) => + events.some( + (event) => + event.threadId === threadId && + event.terminalId === terminalId && + event.type === "exited", + ), + ), + "1200 millis", + ); + yield* manager.close({ threadId, terminalId }); + + const attachHistoryStream = (manager as TerminalManagerWithHistory).attachHistoryStream; + expect(typeof attachHistoryStream).toBe("function"); + + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* attachHistoryStream({ threadId, terminalId }, (event) => + Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + expect(yield* Ref.get(attachEvents)).toEqual([ + { + type: "snapshot", + snapshot: { + threadId, + terminalId, + history: "script output\n", + status: null, + exitCode: null, + exitSignal: null, + }, + }, + ]); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + }), + ); + + it.effect("streams live output after a history-only terminal snapshot", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager(); + const threadId = "script-thread-live"; + const terminalId = "script-terminal-live"; + + yield* manager.open(openInput({ threadId, terminalId })); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitData("before attach\n"); + yield* waitFor( + Effect.map(getEvents, (events) => + events.some( + (event) => + event.threadId === threadId && + event.terminalId === terminalId && + event.type === "output" && + event.data === "before attach\n", + ), + ), + "1200 millis", + ); + + const attachHistoryStream = (manager as TerminalManagerWithHistory).attachHistoryStream; + expect(typeof attachHistoryStream).toBe("function"); + + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* attachHistoryStream({ threadId, terminalId }, (event) => + Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + process.emitData("after attach\n"); + yield* waitFor( + Effect.map(Ref.get(attachEvents), (events) => + events.some((event) => event.type === "output" && event.data === "after attach\n"), + ), + "1200 millis", + ); + + const events = yield* Ref.get(attachEvents); + expect(events[0]).toEqual({ + type: "snapshot", + snapshot: { + threadId, + terminalId, + history: "before attach\n", + status: "running", + exitCode: null, + exitSignal: null, + }, + }); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + }), + ); + it.effect("restarts inactive sessions from attach only when requested", () => Effect.gen(function* () { const { manager, ptyAdapter, getEvents } = yield* createManager(); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index cd490de1e3f..05b29c019d5 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -3,6 +3,7 @@ import { type TerminalAttachInput, type TerminalAttachStreamEvent, type TerminalEvent, + type TerminalHistoryAttachStreamEvent, type TerminalMetadataStreamEvent, type TerminalOpenInput, type TerminalSessionSnapshot, @@ -263,6 +264,23 @@ function terminalEventToAttachEvent(event: TerminalEvent): TerminalAttachStreamE } } +function terminalEventToHistoryAttachEvent( + event: TerminalEvent, +): TerminalHistoryAttachStreamEvent | null { + switch (event.type) { + case "output": + case "exited": + case "closed": + case "error": + case "cleared": + case "activity": + return event; + case "started": + case "restarted": + return null; + } +} + function isDuplicateAttachSnapshotEvent( event: TerminalEvent, initialSnapshot: TerminalSessionSnapshot, @@ -2078,6 +2096,38 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith Effect.map((session) => (Option.isSome(session) ? summary(session.value) : null)), ); + const readHistorySnapshot = (input: { + readonly threadId: string; + readonly terminalId: string; + }) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + const session = yield* getSession(input.threadId, input.terminalId); + if (Option.isSome(session)) { + return { + threadId: session.value.threadId, + terminalId: session.value.terminalId, + history: session.value.history, + status: session.value.status, + exitCode: session.value.exitCode, + exitSignal: session.value.exitSignal, + }; + } + + yield* flushPersist(input.threadId, input.terminalId); + const history = yield* readHistory(input.threadId, input.terminalId); + return { + threadId: input.threadId, + terminalId: input.terminalId, + history, + status: null, + exitCode: null, + exitSignal: null, + }; + }), + ); + const subscribe: TerminalManagerShape["subscribe"] = (listener) => Effect.sync(() => { terminalEventListeners.add(listener); @@ -2143,6 +2193,59 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith ); }; + const attachHistoryStream: TerminalManagerShape["attachHistoryStream"] = (input, listener) => { + let unsubscribe: (() => void) | null = null; + + return Effect.gen(function* () { + const bufferedEvents: TerminalEvent[] = []; + let deliverLive = false; + + unsubscribe = yield* subscribe((event) => { + if (event.threadId !== input.threadId || event.terminalId !== input.terminalId) { + return Effect.void; + } + + if (!deliverLive) { + bufferedEvents.push(event); + return Effect.void; + } + + const attachEvent = terminalEventToHistoryAttachEvent(event); + return attachEvent ? listener(attachEvent) : Effect.void; + }); + + const initialSnapshot = yield* readHistorySnapshot(input); + + yield* listener({ + type: "snapshot", + snapshot: initialSnapshot, + }); + + for (const event of bufferedEvents) { + const attachEvent = terminalEventToHistoryAttachEvent(event); + if (attachEvent) { + yield* listener(attachEvent); + } + } + + deliverLive = true; + return () => { + unsubscribe?.(); + unsubscribe = null; + }; + }).pipe( + Effect.catchCause((cause) => + Effect.flatMap( + Effect.sync(() => { + unsubscribe?.(); + unsubscribe = null; + }), + () => Effect.failCause(cause), + ), + ), + ); + }; + const metadataEventFromTerminalEvent = ( event: TerminalEvent, ): Effect.Effect => { @@ -2382,6 +2485,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith return { open, attachStream, + attachHistoryStream, write, resize, clear, diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index 51c66f49f7c..fc1ca052a83 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -12,6 +12,8 @@ import { TerminalClearInput, TerminalCloseInput, TerminalEvent, + TerminalHistoryAttachInput, + TerminalHistoryAttachStreamEvent, TerminalCwdError, TerminalError, TerminalHistoryError, @@ -92,6 +94,15 @@ export interface TerminalManagerShape { listener: (event: TerminalAttachStreamEvent) => Effect.Effect, ) => Effect.Effect<() => void, TerminalError>; + /** + * Attach to persisted terminal history and stream live events if a matching + * session is still active. This never opens or restarts a shell. + */ + readonly attachHistoryStream: ( + input: TerminalHistoryAttachInput, + listener: (event: TerminalHistoryAttachStreamEvent) => Effect.Effect, + ) => Effect.Effect<() => void, TerminalError>; + /** * Write input bytes to a terminal session. */ diff --git a/apps/server/src/workflow/Layers/ApprovalGate.test.ts b/apps/server/src/workflow/Layers/ApprovalGate.test.ts new file mode 100644 index 00000000000..4697a4fe47f --- /dev/null +++ b/apps/server/src/workflow/Layers/ApprovalGate.test.ts @@ -0,0 +1,21 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; + +import { ApprovalGate } from "../Services/ApprovalGate.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; + +const layer = it.layer(ApprovalGateLive); + +layer("ApprovalGate", (it) => { + it.effect("await resolves once resolve is called", () => + Effect.gen(function* () { + const gate = yield* ApprovalGate; + const fiber = yield* Effect.forkChild(gate.await("sr-1" as never)); + yield* Effect.yieldNow; + yield* gate.resolve("sr-1" as never, true); + const approved = yield* Fiber.join(fiber); + assert.equal(approved, true); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/ApprovalGate.ts b/apps/server/src/workflow/Layers/ApprovalGate.ts new file mode 100644 index 00000000000..934355a2842 --- /dev/null +++ b/apps/server/src/workflow/Layers/ApprovalGate.ts @@ -0,0 +1,67 @@ +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { ApprovalGate } from "../Services/ApprovalGate.ts"; + +export const ApprovalGateLive = Layer.effect( + ApprovalGate, + Effect.gen(function* () { + const pending = yield* Ref.make(new Map>()); + const activeWaiters = yield* Ref.make(new Map()); + + const getOrCreate = (stepRunId: string) => + Effect.gen(function* () { + // Created speculatively, registered atomically: two concurrent + // callers must end up waiting on the SAME deferred or the loser's + // waiter could never be resolved. + const fresh = yield* Deferred.make(); + return yield* Ref.modify(pending, (current) => { + const existing = current.get(stepRunId); + if (existing) { + return [existing, current] as const; + } + return [fresh, new Map(current).set(stepRunId, fresh)] as const; + }); + }); + + const incrementWaiter = (stepRunId: string) => + Ref.update(activeWaiters, (current) => { + const next = new Map(current); + next.set(stepRunId, (next.get(stepRunId) ?? 0) + 1); + return next; + }); + + const decrementWaiter = (stepRunId: string) => + Ref.update(activeWaiters, (current) => { + const next = new Map(current); + const count = (next.get(stepRunId) ?? 0) - 1; + if (count <= 0) { + next.delete(stepRunId); + } else { + next.set(stepRunId, count); + } + return next; + }); + + return ApprovalGate.of({ + park: (stepRunId) => getOrCreate(stepRunId).pipe(Effect.asVoid), + await: (stepRunId) => + Effect.gen(function* () { + const id = stepRunId as string; + const deferred = yield* getOrCreate(id); + yield* incrementWaiter(id); + return yield* Deferred.await(deferred).pipe(Effect.ensuring(decrementWaiter(id))); + }), + resolve: (stepRunId, approved) => + Effect.gen(function* () { + const id = stepRunId as string; + const deferred = yield* getOrCreate(id); + const liveWaiters = (yield* Ref.get(activeWaiters)).get(id) ?? 0; + yield* Deferred.succeed(deferred, approved); + return liveWaiters > 0; + }), + }); + }), +); diff --git a/apps/server/src/workflow/Layers/BoardDiscovery.test.ts b/apps/server/src/workflow/Layers/BoardDiscovery.test.ts new file mode 100644 index 00000000000..9e3b0680619 --- /dev/null +++ b/apps/server/src/workflow/Layers/BoardDiscovery.test.ts @@ -0,0 +1,545 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { BoardId, type ProjectId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { BoardDiscovery } from "../Services/BoardDiscovery.ts"; +import { ProjectWorkspaceResolver } from "../Services/ProjectWorkspaceResolver.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowBoardVersionStore } from "../Services/WorkflowBoardVersionStore.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowProviderInstancePort } from "../Services/WorkflowFileLoader.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { defaultBoardDefinition } from "../defaultBoard.ts"; +import { encodeWorkflowDefinitionJson } from "../workflowFile.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { BoardDiscoveryLive } from "./BoardDiscovery.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowBoardVersionStoreLive } from "./WorkflowBoardVersionStore.ts"; +import { WorkflowEventStoreLive } from "./WorkflowEventStore.ts"; +import { WorkflowFileLoaderLive, WorkflowFilePortLive } from "./WorkflowFileLoader.ts"; +import { WorkflowReadModelLive } from "./WorkflowReadModel.ts"; + +const projectId = "project-discovery" as ProjectId; + +const boardFile = (name: string) => + encodeWorkflowDefinitionJson( + defaultBoardDefinition({ + name, + agent: { instance: "codex_main", model: "gpt-5.5" }, + }), + ); + +const workflowEngineStub = Layer.succeed(WorkflowEngine, { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.die("unused"), + runLane: () => Effect.die("unused"), + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.die("unused"), + answerTicketStep: () => Effect.die("unused"), + postTicketMessage: () => Effect.die("unused"), + cancelStep: () => Effect.die("unused"), + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.die("unused"), +}); + +it.layer(NodeServices.layer)("BoardDiscovery", (it) => { + it.effect( + "discovers boards, reports invalid files, and retains history across absent files", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-board-discovery-", + }); + const boardsDir = path.join(workspaceRoot, ".t3/boards"); + yield* fs.makeDirectory(boardsDir, { recursive: true }); + yield* fs.writeFileString(path.join(boardsDir, "alpha.json"), boardFile("Alpha")); + yield* fs.writeFileString(path.join(boardsDir, "beta.json"), boardFile("Beta")); + yield* fs.writeFileString(path.join(boardsDir, "broken.json"), "{"); + + const layer = BoardDiscoveryLive.pipe( + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed(workspaceRoot), + }), + ), + Layer.provideMerge(WorkflowFileLoaderLive), + Layer.provideMerge(WorkflowFilePortLive), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: (instanceId) => Effect.succeed(instanceId === "codex_main"), + }), + ), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(workflowEngineStub), + Layer.provideMerge(WorkflowEventStoreLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(WorkflowBoardVersionStoreLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const discovery = yield* BoardDiscovery; + const read = yield* WorkflowReadModel; + const registry = yield* BoardRegistry; + const versions = yield* WorkflowBoardVersionStore; + const sql = yield* SqlClient.SqlClient; + const alphaBoardId = `${projectId}__alpha` as never; + + const entries = yield* discovery.discover(projectId); + assert.equal(entries.length, 3); + assert.isTrue( + entries.some( + (entry) => + entry.boardId === `${projectId}__alpha` && + entry.filePath === ".t3/boards/alpha.json" && + entry.error === null, + ), + ); + assert.isTrue( + entries.some( + (entry) => entry.boardId === `${projectId}__broken` && entry.error !== null, + ), + ); + assert.deepEqual(yield* versions.list(alphaBoardId), []); + + const boards = yield* read.listBoardsForProject(projectId); + assert.deepEqual( + boards.map((board) => board.boardId), + [`${projectId}__alpha`, `${projectId}__beta`], + ); + + yield* versions.record({ + boardId: alphaBoardId, + versionHash: "hash-alpha", + contentJson: '{"name":"Alpha"}\n', + source: "import", + }); + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-alpha-stale', + ${alphaBoardId}, + 'Stale alpha ticket', + 'backlog', + 'idle', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_events ( + event_id, + ticket_id, + stream_version, + event_type, + occurred_at, + payload_json + ) + VALUES ( + 'evt-alpha-stale', + 'ticket-alpha-stale', + 0, + 'TicketCreated', + '2026-06-07T00:00:00.000Z', + ${`{"boardId":"${alphaBoardId}","title":"Stale alpha ticket","laneKey":"backlog"}`} + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES ( + 'dispatch-alpha-stale', + 'ticket-alpha-stale', + 'step-alpha-stale', + 'thread-alpha-stale', + 'codex', + 'gpt-5.5', + 'stale dispatch', + '/tmp/alpha-stale', + 'pending', + '2026-06-07T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_setup_run ( + setup_run_id, + ticket_id, + worktree_ref, + status, + started_at + ) + VALUES ( + 'setup-alpha-stale', + 'ticket-alpha-stale', + 'worktree-alpha-stale', + 'running', + '2026-06-07T00:00:00.000Z' + ) + `; + yield* fs.writeFileString(path.join(boardsDir, "alpha.json"), "{"); + const afterInvalid = yield* discovery.discover(projectId); + assert.isTrue( + afterInvalid.some( + (entry) => entry.boardId === `${projectId}__alpha` && entry.error !== null, + ), + ); + assert.isNotNull(yield* registry.getDefinition(`${projectId}__alpha` as never)); + assert.deepEqual( + (yield* versions.list(alphaBoardId)).map((version) => version.versionHash), + ["hash-alpha"], + ); + assert.isTrue( + (yield* read.listBoardsForProject(projectId)).some( + (board) => board.boardId === `${projectId}__alpha`, + ), + ); + + yield* fs.remove(path.join(boardsDir, "alpha.json")); + const afterAbsent = yield* discovery.discover(projectId); + assert.isFalse(afterAbsent.some((entry) => entry.boardId === `${projectId}__alpha`)); + assert.isNull(yield* registry.getDefinition(`${projectId}__alpha` as never)); + assert.deepEqual( + (yield* versions.list(alphaBoardId)).map((version) => version.versionHash), + [], + ); + assert.deepEqual( + (yield* read.listBoardsForProject(projectId)).map((board) => board.boardId), + [`${projectId}__beta`], + ); + const staleRows = yield* sql<{ readonly tableName: string; readonly count: number }>` + SELECT 'projection_ticket' AS tableName, COUNT(*) AS count + FROM projection_ticket + WHERE board_id = ${alphaBoardId} + UNION ALL + SELECT 'workflow_events' AS tableName, COUNT(*) AS count + FROM workflow_events + WHERE ticket_id = 'ticket-alpha-stale' + UNION ALL + SELECT 'workflow_dispatch_outbox' AS tableName, COUNT(*) AS count + FROM workflow_dispatch_outbox + WHERE ticket_id = 'ticket-alpha-stale' + UNION ALL + SELECT 'workflow_setup_run' AS tableName, COUNT(*) AS count + FROM workflow_setup_run + WHERE ticket_id = 'ticket-alpha-stale' + `; + assert.deepEqual( + staleRows.map((row) => [row.tableName, row.count]), + [ + ["projection_ticket", 0], + ["workflow_events", 0], + ["workflow_dispatch_outbox", 0], + ["workflow_setup_run", 0], + ], + ); + + yield* fs.writeFileString(path.join(boardsDir, "alpha.json"), boardFile("Alpha")); + const afterReappear = yield* discovery.discover(projectId); + assert.isTrue(afterReappear.some((entry) => entry.boardId === `${projectId}__alpha`)); + assert.deepEqual( + (yield* versions.list(alphaBoardId)).map((version) => version.versionHash), + [], + ); + assert.deepEqual(yield* read.listTickets(alphaBoardId), []); + }).pipe(Effect.provide(layer)); + }), + ), + ); + + it.effect("does not register a board that is deleted after directory listing", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-board-discovery-race-", + }); + const boardsDir = path.join(workspaceRoot, ".t3/boards"); + const alphaPath = path.join(boardsDir, "alpha.json"); + const alphaBoardId = BoardId.make(`${projectId}__alpha`); + const staleAlpha = boardFile("Alpha"); + const listed = yield* Deferred.make>(); + const deleted = yield* Deferred.make(); + yield* fs.makeDirectory(boardsDir, { recursive: true }); + yield* fs.writeFileString(alphaPath, staleAlpha); + + const staleFileSystemLayer = Layer.succeed(FileSystem.FileSystem, { + ...fs, + readDirectory: (target, options) => + target === boardsDir + ? Effect.gen(function* () { + const entries = yield* fs.readDirectory(target, options); + yield* Deferred.succeed(listed, entries).pipe(Effect.ignore); + yield* Deferred.await(deleted); + return entries; + }) + : fs.readDirectory(target, options), + readFileString: (target, encoding) => + target === alphaPath ? Effect.succeed(staleAlpha) : fs.readFileString(target, encoding), + } satisfies FileSystem.FileSystem); + + const layer = BoardDiscoveryLive.pipe( + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed(workspaceRoot), + }), + ), + Layer.provideMerge(WorkflowFileLoaderLive), + Layer.provideMerge(WorkflowFilePortLive), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: (instanceId) => Effect.succeed(instanceId === "codex_main"), + }), + ), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(workflowEngineStub), + Layer.provideMerge(WorkflowEventStoreLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(WorkflowBoardVersionStoreLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(staleFileSystemLayer), + ); + + yield* Effect.gen(function* () { + const discovery = yield* BoardDiscovery; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const saveLocks = yield* WorkflowBoardSaveLocks; + + yield* registry.register( + alphaBoardId, + defaultBoardDefinition({ + name: "Alpha", + agent: { instance: "codex_main", model: "gpt-5.5" }, + }), + ); + yield* read.registerBoard({ + boardId: alphaBoardId, + projectId, + name: "Alpha", + workflowFilePath: ".t3/boards/alpha.json", + workflowVersionHash: "hash-alpha-before-delete", + maxConcurrentTickets: 3, + }); + + const discoverFiber = yield* Effect.forkChild(discovery.discover(projectId)); + assert.deepEqual(yield* Deferred.await(listed), ["alpha.json"]); + + yield* saveLocks.withSaveLock( + alphaBoardId, + Effect.gen(function* () { + yield* fs.remove(alphaPath); + yield* registry.unregister(alphaBoardId); + yield* read.deleteBoard(alphaBoardId); + }), + ); + yield* Deferred.succeed(deleted, undefined); + + const entries = yield* Fiber.join(discoverFiber); + assert.isFalse(entries.some((entry) => entry.boardId === alphaBoardId)); + assert.isNull(yield* registry.getDefinition(alphaBoardId)); + assert.isNull(yield* read.getBoard(alphaBoardId)); + }).pipe(Effect.provide(layer)); + }), + ), + ); + + it.effect("cascades a persisted board whose file is missing without a cache entry", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-board-discovery-persisted-missing-", + }); + const boardsDir = path.join(workspaceRoot, ".t3/boards"); + const boardId = BoardId.make(`${projectId}__persisted-missing`); + yield* fs.makeDirectory(boardsDir, { recursive: true }); + + const layer = BoardDiscoveryLive.pipe( + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed(workspaceRoot), + }), + ), + Layer.provideMerge(WorkflowFileLoaderLive), + Layer.provideMerge(WorkflowFilePortLive), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: (instanceId) => Effect.succeed(instanceId === "codex_main"), + }), + ), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(workflowEngineStub), + Layer.provideMerge(WorkflowEventStoreLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(WorkflowBoardVersionStoreLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const discovery = yield* BoardDiscovery; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const versions = yield* WorkflowBoardVersionStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + + yield* registry.register( + boardId, + defaultBoardDefinition({ + name: "Persisted missing", + agent: { instance: "codex_main", model: "gpt-5.5" }, + }), + ); + yield* read.registerBoard({ + boardId, + projectId, + name: "Persisted missing", + workflowFilePath: ".t3/boards/persisted-missing.json", + workflowVersionHash: "hash-persisted-missing", + maxConcurrentTickets: 1, + }); + yield* versions.record({ + boardId, + versionHash: "hash-persisted-missing", + contentJson: '{"name":"Persisted missing"}\n', + source: "import", + }); + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-persisted-missing', + ${boardId}, + 'Persisted missing ticket', + 'backlog', + 'idle', + ${now}, + ${now} + ) + `; + yield* sql` + INSERT INTO workflow_events ( + event_id, + ticket_id, + stream_version, + event_type, + occurred_at, + payload_json + ) + VALUES ( + 'evt-persisted-missing', + 'ticket-persisted-missing', + 0, + 'TicketCreated', + ${now}, + ${`{"boardId":"${boardId}","title":"Persisted missing ticket","laneKey":"backlog"}`} + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES ( + 'dispatch-persisted-missing', + 'ticket-persisted-missing', + 'step-persisted-missing', + 'thread-persisted-missing', + 'codex', + 'gpt-5.5', + 'stale persisted dispatch', + '/tmp/persisted-missing', + 'pending', + ${now} + ) + `; + + const entries = yield* discovery.discover(projectId).pipe(Effect.timeout("1 second")); + + assert.isFalse(entries.some((entry) => entry.boardId === boardId)); + assert.isNull(yield* registry.getDefinition(boardId)); + assert.isNull(yield* read.getBoard(boardId)); + assert.deepEqual(yield* versions.list(boardId), []); + const staleRows = yield* sql<{ readonly tableName: string; readonly count: number }>` + SELECT 'projection_ticket' AS tableName, COUNT(*) AS count + FROM projection_ticket + WHERE board_id = ${boardId} + UNION ALL + SELECT 'workflow_events' AS tableName, COUNT(*) AS count + FROM workflow_events + WHERE ticket_id = 'ticket-persisted-missing' + UNION ALL + SELECT 'workflow_dispatch_outbox' AS tableName, COUNT(*) AS count + FROM workflow_dispatch_outbox + WHERE ticket_id = 'ticket-persisted-missing' + `; + assert.deepEqual( + staleRows.map((row) => [row.tableName, row.count]), + [ + ["projection_ticket", 0], + ["workflow_events", 0], + ["workflow_dispatch_outbox", 0], + ], + ); + }).pipe(Effect.provide(layer)); + }), + ), + ); +}); diff --git a/apps/server/src/workflow/Layers/BoardDiscovery.ts b/apps/server/src/workflow/Layers/BoardDiscovery.ts new file mode 100644 index 00000000000..360df0a0f54 --- /dev/null +++ b/apps/server/src/workflow/Layers/BoardDiscovery.ts @@ -0,0 +1,255 @@ +import { + BoardId, + WorkflowDefinition, + WorkflowRpcError, + type BoardListEntry, + type ProjectId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { BoardDiscovery, type BoardDiscoveryShape } from "../Services/BoardDiscovery.ts"; +import { ProjectWorkspaceResolver } from "../Services/ProjectWorkspaceResolver.ts"; +import { WorkflowBoardVersionStore } from "../Services/WorkflowBoardVersionStore.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowFileLoader } from "../Services/WorkflowFileLoader.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowWebhook } from "../Services/WorkflowWebhook.ts"; +import { WorkflowWorktreeJanitor } from "../Services/WorkflowWorktreeJanitor.ts"; +import { deleteWorkflowBoardOwnedState } from "../boardDeletion.ts"; + +const decodeWorkflowDefinitionJson = Schema.decodeEffect(Schema.fromJsonString(WorkflowDefinition)); + +const toWorkflowRpcError = (message: string) => (cause: unknown) => + new WorkflowRpcError({ message, cause }); + +const errorMessage = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); + +const isJsonBoardFile = (name: string) => name.endsWith(".json"); + +const boardSlugFromFileName = (fileName: string): string => fileName.slice(0, -".json".length); + +const boardIdFor = (projectId: ProjectId, slug: string) => BoardId.make(`${projectId}__${slug}`); + +const makeEntry = (input: { + readonly boardId: BoardId; + readonly name: string; + readonly relativePath: string; + readonly error: string | null; +}): BoardListEntry => ({ + boardId: input.boardId, + name: input.name, + filePath: input.relativePath, + error: input.error, +}); + +interface RemovedBoardCandidate { + readonly boardId: BoardId; + readonly filePath: string; +} + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolver = yield* ProjectWorkspaceResolver; + const loader = yield* WorkflowFileLoader; + const registry = yield* BoardRegistry; + const readModel = yield* WorkflowReadModel; + const saveLocks = yield* WorkflowBoardSaveLocks; + const engine = yield* WorkflowEngine; + const eventStore = yield* WorkflowEventStore; + const versionStore = yield* WorkflowBoardVersionStore; + const worktreeJanitor = Context.getOption( + (yield* Effect.context()) as Context.Context, + WorkflowWorktreeJanitor, + ); + const webhook = Context.getOption( + (yield* Effect.context()) as Context.Context, + WorkflowWebhook, + ); + const cache = yield* Ref.make>>(new Map()); + + const discoverFile = (input: { + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly fileName: string; + }) => { + const slug = boardSlugFromFileName(input.fileName); + const boardId = boardIdFor(input.projectId, slug); + const relativePath = `.t3/boards/${input.fileName}`; + const absolutePath = path.join(input.workspaceRoot, relativePath); + + return saveLocks.withSaveLock( + boardId, + Effect.gen(function* () { + const stillExists = yield* fileSystem + .exists(absolutePath) + .pipe( + Effect.mapError(toWorkflowRpcError(`Failed to check workflow board ${relativePath}`)), + ); + if (!stillExists) { + return null; + } + + return yield* fileSystem.readFileString(absolutePath).pipe( + Effect.mapError(toWorkflowRpcError(`Failed to read workflow board ${relativePath}`)), + Effect.flatMap((raw) => + decodeWorkflowDefinitionJson(raw).pipe( + Effect.matchEffect({ + onFailure: (cause) => + Effect.succeed( + makeEntry({ + boardId, + name: slug, + relativePath, + error: errorMessage(cause), + }), + ), + onSuccess: (definition) => + loader + .loadAndRegister({ + boardId, + projectId: input.projectId, + workspaceRoot: input.workspaceRoot, + relativePath, + }) + .pipe( + Effect.matchEffect({ + onFailure: (cause) => + Effect.succeed( + makeEntry({ + boardId, + name: definition.name, + relativePath, + error: errorMessage(cause), + }), + ), + onSuccess: () => + Effect.succeed( + makeEntry({ + boardId, + name: definition.name, + relativePath, + error: null, + }), + ), + }), + ), + }), + ), + ), + ); + }), + ); + }; + + const discover: BoardDiscoveryShape["discover"] = (projectId) => + Effect.gen(function* () { + const workspaceRoot = yield* resolver + .resolve(projectId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to resolve workflow project root"))); + const boardsDir = path.join(workspaceRoot, ".t3/boards"); + const exists = yield* fileSystem + .exists(boardsDir) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to check workflow boards directory"))); + const fileNames = exists + ? yield* fileSystem + .readDirectory(boardsDir) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list workflow boards directory"))) + : []; + const boardFileNames = fileNames.filter(isJsonBoardFile).sort(); + const discoveredEntries = yield* Effect.forEach(boardFileNames, (fileName) => + discoverFile({ projectId, workspaceRoot, fileName }), + ); + const entries = discoveredEntries.filter((entry): entry is BoardListEntry => entry !== null); + + const presentBoardIds = new Set(entries.map((entry) => entry.boardId as string)); + const presentFilePaths = new Set(boardFileNames.map((fileName) => `.t3/boards/${fileName}`)); + const cachedEntries = (yield* Ref.get(cache)).get(projectId as string) ?? []; + const persistedBoards = yield* readModel + .listBoardsForProject(projectId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list persisted workflow boards"))); + const removedCandidates = new Map(); + + for (const board of persistedBoards) { + if (!presentFilePaths.has(board.filePath)) { + removedCandidates.set(board.boardId as string, { + boardId: board.boardId as BoardId, + filePath: board.filePath, + }); + } + } + + for (const entry of cachedEntries) { + if (!presentBoardIds.has(entry.boardId as string)) { + removedCandidates.set(entry.boardId as string, { + boardId: entry.boardId, + filePath: entry.filePath, + }); + } + } + + yield* Effect.forEach( + removedCandidates.values(), + (candidate) => + saveLocks + .withSaveLock( + candidate.boardId, + Effect.gen(function* () { + const stillExists = yield* fileSystem + .exists(path.join(workspaceRoot, candidate.filePath)) + .pipe( + Effect.mapError( + toWorkflowRpcError(`Failed to check workflow board ${candidate.filePath}`), + ), + ); + if (stillExists) { + return; + } + + yield* deleteWorkflowBoardOwnedState( + { + boardRegistry: registry, + engine, + eventStore, + readModel, + versionStore, + ...(Option.isSome(worktreeJanitor) + ? { worktreeJanitor: worktreeJanitor.value } + : {}), + ...(Option.isSome(webhook) ? { webhook: webhook.value } : {}), + }, + candidate.boardId, + ); + }), + ) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to unregister workflow board"))), + { discard: true }, + ); + + yield* Ref.update(cache, (current) => new Map(current).set(projectId as string, entries)); + return entries; + }); + + const list: BoardDiscoveryShape["list"] = (projectId) => + Ref.get(cache).pipe( + Effect.flatMap((current) => { + const cached = current.get(projectId as string); + return cached === undefined ? discover(projectId) : Effect.succeed(cached); + }), + ); + + return { discover, list } satisfies BoardDiscoveryShape; +}); + +export const BoardDiscoveryLive = Layer.effect(BoardDiscovery, make); diff --git a/apps/server/src/workflow/Layers/BoardRegistry.test.ts b/apps/server/src/workflow/Layers/BoardRegistry.test.ts new file mode 100644 index 00000000000..2de4c83c4de --- /dev/null +++ b/apps/server/src/workflow/Layers/BoardRegistry.test.ts @@ -0,0 +1,109 @@ +import { assert, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; + +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; + +const layer = it.layer(BoardRegistryLive); + +const def = { + name: "wf", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +layer("BoardRegistry", (it) => { + it.effect("registers a definition and resolves lanes", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-1" as never, def); + const lane = yield* registry.getLane("b-1" as never, "impl" as never); + assert.equal(lane?.entry, "auto"); + assert.equal(lane?.pipeline?.length, 1); + }), + ); + + it.effect("rejects an invalid definition", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const result = yield* Effect.exit( + registry.register("b-2" as never, { + name: "bad", + lanes: [{ key: "a", name: "A", entry: "auto", on: { success: "ghost" } }], + }), + ); + assert.equal(result._tag, "Failure"); + }), + ); + + it.effect("rejects invalid WIP limits during registration", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const result = yield* Effect.exit( + registry.register("b-invalid-wip" as never, { + name: "bad wip", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual", wipLimit: 0 }, + { key: "done", name: "Done", entry: "manual", terminal: true, wipLimit: 1 }, + ], + }), + ); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.isTrue(result.cause.toString().includes("invalid_wip_limit")); + } + }), + ); + + it.effect("registers an already-decoded workflow definition with retention duration", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-retention" as never, { + name: "retention", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "done", + name: "Done", + entry: "manual", + terminal: true, + retention: Duration.days(7), + }, + ], + }); + + const lane = yield* registry.getLane("b-retention" as never, "done" as never); + assert.equal( + Duration.toMillis((lane as any)?.retention), + Duration.toMillis(Duration.days(7)), + ); + }), + ); + + it.effect("unregister removes a registered definition", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-3" as never, def); + yield* registry.unregister("b-3" as never); + assert.isNull(yield* registry.getDefinition("b-3" as never)); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/BoardRegistry.ts b/apps/server/src/workflow/Layers/BoardRegistry.ts new file mode 100644 index 00000000000..b506b708aed --- /dev/null +++ b/apps/server/src/workflow/Layers/BoardRegistry.ts @@ -0,0 +1,77 @@ +import { WorkflowDefinition } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import { + BoardRegistry, + BoardRegistryError, + type BoardRegistryShape, +} from "../Services/BoardRegistry.ts"; +import { lintWorkflowDefinition } from "../workflowFile.ts"; + +const decodeWorkflowDefinition = Schema.decodeUnknownEffect(WorkflowDefinition); +const isWorkflowDefinition = Schema.is(WorkflowDefinition); + +const make = Effect.gen(function* () { + const store = yield* Ref.make>(new Map()); + + const register: BoardRegistryShape["register"] = (boardId, raw) => + Effect.gen(function* () { + const definition = isWorkflowDefinition(raw) + ? raw + : yield* decodeWorkflowDefinition(raw).pipe( + Effect.mapError( + (cause) => new BoardRegistryError({ message: `Invalid workflow: ${String(cause)}` }), + ), + ); + const errors = lintWorkflowDefinition(definition, { + providerInstanceExists: () => true, + instructionFileExists: () => true, + }); + if (errors.length > 0) { + return yield* new BoardRegistryError({ + message: `Workflow lint failed: ${errors.map((error) => error.code).join(", ")}`, + }); + } + + yield* Ref.update(store, (current) => new Map(current).set(boardId as string, definition)); + return definition; + }); + + const getDefinition: BoardRegistryShape["getDefinition"] = (boardId) => + Ref.get(store).pipe(Effect.map((current) => current.get(boardId as string) ?? null)); + + const listDefinitions: BoardRegistryShape["listDefinitions"] = () => + Ref.get(store).pipe( + Effect.map((current) => + Array.from(current.entries()).map(([boardId, definition]) => ({ + boardId: boardId as never, + definition, + })), + ), + ); + + const unregister: BoardRegistryShape["unregister"] = (boardId) => + Ref.update(store, (current) => { + const next = new Map(current); + next.delete(boardId as string); + return next; + }); + + const getLane: BoardRegistryShape["getLane"] = (boardId, laneKey) => + getDefinition(boardId).pipe( + Effect.map((definition) => definition?.lanes.find((lane) => lane.key === laneKey) ?? null), + ); + + return { + register, + unregister, + getDefinition, + listDefinitions, + getLane, + } satisfies BoardRegistryShape; +}); + +export const BoardRegistryLive = Layer.effect(BoardRegistry, make); diff --git a/apps/server/src/workflow/Layers/CapturedStepOutputReader.test.ts b/apps/server/src/workflow/Layers/CapturedStepOutputReader.test.ts new file mode 100644 index 00000000000..f3475907e0e --- /dev/null +++ b/apps/server/src/workflow/Layers/CapturedStepOutputReader.test.ts @@ -0,0 +1,242 @@ +import { assert, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { PersistenceSqlError } from "../../persistence/Errors.ts"; +import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { CapturedStepOutputReader } from "../Services/CapturedStepOutputReader.ts"; +import { ProviderDispatchOutbox } from "../Services/ProviderDispatchOutbox.ts"; +import { CapturedStepOutputReaderLive } from "./CapturedStepOutputReader.ts"; + +const layer = it.layer( + CapturedStepOutputReaderLive.pipe( + Layer.provideMerge( + Layer.succeed(ProviderDispatchOutbox, { + confirmStep: () => Effect.void, + ensureStarted: () => Effect.succeed({ turnId: "turn-captured-output" as never }), + getDispatchForStep: () => + Effect.succeed({ + threadId: "thread-captured-output" as never, + turnId: "turn-captured-output" as never, + }), + awaitTerminal: () => Effect.succeed({ ok: true }), + awaitStepTerminal: () => Effect.succeed({ ok: true }), + recoverPending: () => Effect.void, + }), + ), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(ProjectionThreadMessageRepositoryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const seedAssistantMessage = (text: string) => + seedAssistantMessageFor({ + threadId: "thread-captured-output", + turnId: "turn-captured-output", + messageId: "message-captured-output", + text, + }); + +const seedAssistantMessageFor = (input: { + readonly threadId: string; + readonly turnId: string; + readonly messageId: string; + readonly text: string; +}) => + Effect.gen(function* () { + const turns = yield* ProjectionTurnRepository; + const messages = yield* ProjectionThreadMessageRepository; + yield* turns.upsertByTurnId({ + threadId: input.threadId as never, + turnId: input.turnId as never, + pendingMessageId: null, + sourceProposedPlanThreadId: null, + sourceProposedPlanId: null, + assistantMessageId: input.messageId as never, + state: "completed", + requestedAt: "2026-06-07T00:00:00.000Z" as never, + startedAt: "2026-06-07T00:00:00.000Z" as never, + completedAt: "2026-06-07T00:00:01.000Z" as never, + checkpointTurnCount: null, + checkpointRef: null, + checkpointStatus: null, + checkpointFiles: [], + }); + yield* messages.upsert({ + messageId: input.messageId as never, + threadId: input.threadId as never, + turnId: input.turnId as never, + role: "assistant", + text: input.text, + isStreaming: false, + createdAt: "2026-06-07T00:00:01.000Z" as never, + updatedAt: "2026-06-07T00:00:01.000Z" as never, + }); + }); + +layer("CapturedStepOutputReader", (it) => { + it.effect("returns the last object from a fenced JSON block", () => + Effect.gen(function* () { + const reader = yield* CapturedStepOutputReader; + yield* seedAssistantMessage( + [ + "Earlier:", + "```json", + '{"verdict":"ignore"}', + "```", + "Final:", + "```json", + '{"verdict":"pass","score":0.98}', + "```", + ].join("\n"), + ); + + const output = yield* reader.read({ + stepRunId: "step-captured-output" as never, + threadId: "thread-captured-output" as never, + turnId: "turn-captured-output" as never, + }); + + assert.deepEqual(output, { verdict: "pass", score: 0.98 }); + }), + ); + + it.effect("reads the assistant message for the exact awaited turn, not the latest dispatch", () => + Effect.gen(function* () { + const reader = yield* CapturedStepOutputReader; + yield* seedAssistantMessage('Latest dispatch.\n```json\n{"verdict":"latest"}\n```'); + yield* seedAssistantMessageFor({ + threadId: "thread-captured-output", + turnId: "turn-awaited", + messageId: "message-awaited", + text: 'Awaited turn.\n```json\n{"verdict":"awaited"}\n```', + }); + + const output = yield* reader.read({ + stepRunId: "step-captured-output" as never, + threadId: "thread-captured-output" as never, + turnId: "turn-awaited" as never, + } as never); + + assert.deepEqual(output, { verdict: "awaited" }); + }), + ); + + it.effect("returns undefined when no valid object block exists", () => + Effect.gen(function* () { + const reader = yield* CapturedStepOutputReader; + yield* seedAssistantMessage("Done without structured output."); + + const output = yield* reader.read({ + stepRunId: "step-captured-output" as never, + threadId: "thread-captured-output" as never, + turnId: "turn-captured-output" as never, + }); + + assert.equal(output, undefined); + }), + ); + + it.effect("falls back to earlier messages in the turn when the final one has no block", () => + Effect.gen(function* () { + const reader = yield* CapturedStepOutputReader; + const messages = yield* ProjectionThreadMessageRepository; + // The turn's recorded final message is a closing remark; the verdict + // was emitted in an earlier message of the same multi-message turn. + yield* seedAssistantMessage("All set — see the verdict above."); + yield* messages.upsert({ + messageId: "message-earlier-verdict" as never, + threadId: "thread-captured-output" as never, + turnId: "turn-captured-output" as never, + role: "assistant", + text: 'Findings reviewed.\n```json\n{"verdict":"approve"}\n```', + isStreaming: false, + createdAt: "2026-06-07T00:00:00.500Z" as never, + updatedAt: "2026-06-07T00:00:00.500Z" as never, + }); + // A different turn's verdict must never bleed in. + yield* messages.upsert({ + messageId: "message-other-turn" as never, + threadId: "thread-captured-output" as never, + turnId: "turn-unrelated" as never, + role: "assistant", + text: '```json\n{"verdict":"unrelated"}\n```', + isStreaming: false, + createdAt: "2026-06-07T00:00:00.900Z" as never, + updatedAt: "2026-06-07T00:00:00.900Z" as never, + }); + + const output = yield* reader.read({ + stepRunId: "step-captured-output" as never, + threadId: "thread-captured-output" as never, + turnId: "turn-captured-output" as never, + }); + + assert.deepEqual(output, { verdict: "approve" }); + }), + ); +}); + +it.effect( + "CapturedStepOutputReader propagates repository lookup errors instead of returning undefined", + () => + Effect.gen(function* () { + const readerErrorLayer = CapturedStepOutputReaderLive.pipe( + Layer.provideMerge( + Layer.succeed(ProjectionTurnRepository, { + upsertByTurnId: () => Effect.void, + replacePendingTurnStart: () => Effect.void, + getPendingTurnStartByThreadId: () => Effect.succeed(Option.none()), + deletePendingTurnStartByThreadId: () => Effect.void, + listByThreadId: () => Effect.succeed([]), + getByTurnId: () => + Effect.fail( + new PersistenceSqlError({ + operation: "ProjectionTurnRepository.getByTurnId:test", + detail: "simulated lookup failure", + }), + ), + clearCheckpointTurnConflict: () => Effect.void, + deleteByThreadId: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectionThreadMessageRepository, { + upsert: () => Effect.void, + getByMessageId: () => Effect.succeed(Option.none()), + listByThreadId: () => Effect.succeed([]), + deleteByThreadId: () => Effect.void, + }), + ), + ); + + const exit = yield* Effect.gen(function* () { + const reader = yield* CapturedStepOutputReader; + return yield* reader.read({ + stepRunId: "step-error" as never, + threadId: "thread-error" as never, + turnId: "turn-error" as never, + }); + }).pipe(Effect.provide(readerErrorLayer), Effect.exit); + + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + assert.equal((error as { readonly _tag?: string })._tag, "WorkflowEventStoreError"); + assert.equal( + (error as { readonly message?: string }).message, + "structured output turn lookup failed", + ); + } + }), +); diff --git a/apps/server/src/workflow/Layers/CapturedStepOutputReader.ts b/apps/server/src/workflow/Layers/CapturedStepOutputReader.ts new file mode 100644 index 00000000000..fea75b1bd3a --- /dev/null +++ b/apps/server/src/workflow/Layers/CapturedStepOutputReader.ts @@ -0,0 +1,94 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { + CapturedStepOutputReader, + type CapturedStepOutputReaderShape, +} from "../Services/CapturedStepOutputReader.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; + +const decodeCapturedJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); + +const findLastJsonBlock = (text: string) => { + const jsonBlock = /```json\s*([\s\S]*?)```/gi; + let last: string | undefined; + let match: RegExpExecArray | null = null; + while ((match = jsonBlock.exec(text)) !== null) { + last = match[1]?.trim(); + } + return last; +}; + +const parseCapturedOutput = (text: string): Effect.Effect => { + const block = findLastJsonBlock(text); + if (block === undefined) { + return Effect.void; + } + return decodeCapturedJson(block).pipe( + Effect.map((value) => + typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined, + ), + Effect.orElseSucceed(() => undefined), + ); +}; + +const toReaderError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const make = Effect.gen(function* () { + const projectionTurns = yield* ProjectionTurnRepository; + const threadMessages = yield* ProjectionThreadMessageRepository; + + const read: CapturedStepOutputReaderShape["read"] = (input) => + Effect.gen(function* () { + const turn = yield* projectionTurns + .getByTurnId({ + threadId: input.threadId, + turnId: input.turnId, + }) + .pipe(Effect.mapError(toReaderError("structured output turn lookup failed"))); + if (Option.isNone(turn) || turn.value.assistantMessageId === null) { + return undefined; + } + + const message = yield* threadMessages + .getByMessageId({ messageId: turn.value.assistantMessageId }) + .pipe(Effect.mapError(toReaderError("structured output message lookup failed"))); + if (Option.isNone(message)) { + return undefined; + } + + const fromFinalMessage = yield* parseCapturedOutput(message.value.text); + if (fromFinalMessage !== undefined) { + return fromFinalMessage; + } + + // Agents with multi-message turns (progress notes, skill-driven + // formats) sometimes emit the fenced block before their closing + // remark — scan the turn's earlier assistant messages, newest first. + const allMessages = yield* threadMessages + .listByThreadId({ threadId: input.threadId }) + .pipe(Effect.mapError(toReaderError("structured output turn messages lookup failed"))); + const turnAssistantMessages = allMessages.filter( + (candidate) => + candidate.turnId === (input.turnId as string) && + candidate.role === "assistant" && + candidate.messageId !== turn.value.assistantMessageId, + ); + for (const candidate of [...turnAssistantMessages].toReversed()) { + const parsed = yield* parseCapturedOutput(candidate.text); + if (parsed !== undefined) { + return parsed; + } + } + return undefined; + }); + + return { read } satisfies CapturedStepOutputReaderShape; +}); + +export const CapturedStepOutputReaderLive = Layer.effect(CapturedStepOutputReader, make); diff --git a/apps/server/src/workflow/Layers/DurableApprovalResume.test.ts b/apps/server/src/workflow/Layers/DurableApprovalResume.test.ts new file mode 100644 index 00000000000..70394cff9e0 --- /dev/null +++ b/apps/server/src/workflow/Layers/DurableApprovalResume.test.ts @@ -0,0 +1,410 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ApprovalGate } from "../Services/ApprovalGate.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { DurableApprovalResume } from "../Services/DurableApprovalResume.ts"; +import { ProviderResponsePort } from "../Services/ProviderResponsePort.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor } from "../Services/StepExecutor.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowEventStoreLive } from "./WorkflowEventStore.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { DurableApprovalResumeLive } from "./DurableApprovalResume.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +const eventStoreLayer = WorkflowEventStoreLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), +); + +it.effect("parks unresolved workflow approval waits during recovery", () => + Effect.gen(function* () { + const parked = yield* Ref.make>([]); + const layer = DurableApprovalResumeLive.pipe( + Layer.provideMerge(eventStoreLayer), + Layer.provideMerge( + Layer.succeed(ApprovalGate, { + await: () => Effect.die("unused"), + resolve: () => Effect.succeed(false), + park: (stepRunId) => + Ref.update(parked, (ids) => [...ids, stepRunId as string]).pipe(Effect.asVoid), + }), + ), + ); + + yield* Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const resume = yield* DurableApprovalResume; + yield* store.append({ + type: "StepAwaitingUser", + eventId: "evt-await" as never, + ticketId: "ticket-1" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { stepRunId: "step-run-1" as never, waitingReason: "Approve?" }, + }); + + yield* resume.resume(); + + assert.deepEqual(yield* Ref.get(parked), ["step-run-1"]); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("resets provider-backed waits and clears stale projected turns during recovery", () => + Effect.gen(function* () { + const parked = yield* Ref.make>([]); + const layer = DurableApprovalResumeLive.pipe( + Layer.provideMerge(eventStoreLayer), + Layer.provideMerge( + Layer.succeed(ApprovalGate, { + await: () => Effect.die("unused"), + resolve: () => Effect.succeed(false), + park: (stepRunId) => + Ref.update(parked, (ids) => [...ids, stepRunId as string]).pipe(Effect.asVoid), + }), + ), + ); + + yield* Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const resume = yield* DurableApprovalResume; + const sql = yield* SqlClient.SqlClient; + yield* store.append({ + type: "StepAwaitingUser", + eventId: "evt-provider-await-stale" as never, + ticketId: "ticket-provider-stale" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + stepRunId: "step-run-provider-stale" as never, + waitingReason: "Provider is waiting for user input", + providerThreadId: "thread-provider-stale" as never, + providerRequestId: "request-provider-stale" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-provider-stale", + }, + }); + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + turn_id, + created_at, + started_at + ) + VALUES ( + 'dispatch-provider-stale', + 'ticket-provider-stale', + 'step-run-provider-stale', + 'thread-provider-stale', + 'codex', + 'gpt-5.5', + 'ask again', + '/tmp/provider-stale', + 'started', + 'turn-provider-stale', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:01.000Z' + ) + `; + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + 'thread-provider-stale', + 'turn-provider-stale', + NULL, + NULL, + NULL, + NULL, + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:01.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `; + + yield* resume.resume(); + + const dispatchRows = yield* sql<{ + readonly status: string; + readonly turnId: string | null; + readonly startedAt: string | null; + }>` + SELECT + status, + turn_id AS "turnId", + started_at AS "startedAt" + FROM workflow_dispatch_outbox + WHERE dispatch_id = 'dispatch-provider-stale' + `; + assert.deepEqual(dispatchRows[0], { + status: "pending", + turnId: null, + startedAt: null, + }); + + const turnRows = yield* sql<{ + readonly state: string; + readonly completedAt: string | null; + }>` + SELECT + state, + completed_at AS "completedAt" + FROM projection_turns + WHERE thread_id = 'thread-provider-stale' + AND turn_id = 'turn-provider-stale' + `; + assert.equal(turnRows[0]?.state, "interrupted"); + assert.isString(turnRows[0]?.completedAt); + assert.deepEqual(yield* Ref.get(parked), []); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("routes provider-question approval resolution to the provider response port", () => + Effect.gen(function* () { + const responses = yield* Ref.make>([]); + const layer = WorkflowEngineLayer.pipe( + Layer.provideMerge(eventStoreLayer), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(responses, (values) => [...values, input]), + }), + ), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(ApprovalGate, { + await: () => Effect.die("unused"), + resolve: () => Effect.succeed(false), + park: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowEventCommitter, { + commit: () => Effect.void, + commitMany: () => Effect.void, + }), + ), + Layer.provideMerge(Layer.succeed(StepExecutor, { execute: () => Effect.die("unused") })), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge( + Layer.succeed(WorkflowReadModel, { + registerBoard: () => Effect.void, + getBoard: () => Effect.succeed(null), + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + listTickets: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + getTicketDetail: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }), + ), + Layer.provideMerge( + Layer.succeed(BoardRegistry, { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }), + ), + Layer.provideMerge(DeterministicWorkflowIds), + ); + + yield* Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const engine = yield* WorkflowEngine; + yield* store.append({ + type: "StepAwaitingUser", + eventId: "evt-provider-await" as never, + ticketId: "ticket-provider" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + stepRunId: "step-run-provider" as never, + waitingReason: "Provider needs approval", + providerThreadId: "thread-provider" as never, + providerRequestId: "request-provider" as never, + providerResponseKind: "request", + }, + }); + + yield* engine.resolveApproval("step-run-provider" as never, true); + + assert.deepEqual(yield* Ref.get(responses), [ + { + threadId: "thread-provider", + requestId: "request-provider", + responseKind: "request", + approved: true, + }, + ]); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("rejects resolveApproval for provider user-input waits without responding", () => + Effect.gen(function* () { + const responses = yield* Ref.make>([]); + const layer = WorkflowEngineLayer.pipe( + Layer.provideMerge(eventStoreLayer), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(responses, (values) => [...values, input]), + }), + ), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(ApprovalGate, { + await: () => Effect.die("unused"), + resolve: () => Effect.succeed(false), + park: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowEventCommitter, { + commit: () => Effect.void, + commitMany: () => Effect.void, + }), + ), + Layer.provideMerge(Layer.succeed(StepExecutor, { execute: () => Effect.die("unused") })), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge( + Layer.succeed(WorkflowReadModel, { + registerBoard: () => Effect.void, + getBoard: () => Effect.succeed(null), + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + listTickets: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + getTicketDetail: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }), + ), + Layer.provideMerge( + Layer.succeed(BoardRegistry, { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }), + ), + Layer.provideMerge(DeterministicWorkflowIds), + ); + + yield* Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const engine = yield* WorkflowEngine; + yield* store.append({ + type: "StepAwaitingUser", + eventId: "evt-provider-user-input-await" as never, + ticketId: "ticket-provider-user-input" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + stepRunId: "step-run-provider-user-input" as never, + waitingReason: "Which API should I use?", + providerThreadId: "thread-provider-user-input" as never, + providerRequestId: "request-provider-user-input" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-provider-user-input", + }, + }); + + const error = yield* Effect.flip( + engine.resolveApproval("step-run-provider-user-input" as never, true), + ); + + assert.include(error.message, "answerTicketStep"); + assert.deepEqual(yield* Ref.get(responses), []); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/workflow/Layers/DurableApprovalResume.ts b/apps/server/src/workflow/Layers/DurableApprovalResume.ts new file mode 100644 index 00000000000..8d7f3e33f5f --- /dev/null +++ b/apps/server/src/workflow/Layers/DurableApprovalResume.ts @@ -0,0 +1,90 @@ +import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { ApprovalGate } from "../Services/ApprovalGate.ts"; +import { + DurableApprovalResume, + type DurableApprovalResumeShape, +} from "../Services/DurableApprovalResume.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; + +interface PendingWaitRow { + readonly providerRequestId: string | null; + readonly providerThreadId: string | null; + readonly stepRunId: string; +} + +const toResumeError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrapSql = (effect: Effect.Effect) => + effect.pipe(Effect.mapError(toResumeError("approval resume sql failed"))); +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +const make = Effect.gen(function* () { + const approvals = yield* ApprovalGate; + const sql = yield* SqlClient.SqlClient; + + const resetProviderDispatch = (stepRunId: string) => + Effect.gen(function* () { + const interruptedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE projection_turns + SET state = 'interrupted', + completed_at = ${interruptedAt} + WHERE state IN ('pending', 'running') + AND EXISTS ( + SELECT 1 + FROM workflow_dispatch_outbox AS outbox + WHERE outbox.step_run_id = ${stepRunId} + AND outbox.status != 'confirmed' + AND outbox.thread_id = projection_turns.thread_id + AND outbox.turn_id = projection_turns.turn_id + ) + `); + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'pending', + turn_id = NULL, + started_at = NULL, + confirmed_at = NULL + WHERE step_run_id = ${stepRunId} + AND status != 'confirmed' + `); + }); + + const resume: DurableApprovalResumeShape["resume"] = () => + Effect.gen(function* () { + const pendingWaits = yield* wrapSql(sql` + SELECT + json_extract(await.payload_json, '$.providerRequestId') AS "providerRequestId", + json_extract(await.payload_json, '$.providerThreadId') AS "providerThreadId", + json_extract(await.payload_json, '$.stepRunId') AS "stepRunId" + FROM workflow_events AS await + WHERE await.event_type = 'StepAwaitingUser' + AND NOT EXISTS ( + SELECT 1 + FROM workflow_events AS resolved + WHERE resolved.event_type = 'StepUserResolved' + AND json_extract(resolved.payload_json, '$.stepRunId') + = json_extract(await.payload_json, '$.stepRunId') + ) + ORDER BY await.sequence ASC + `); + + for (const pending of pendingWaits) { + if (pending.providerThreadId && pending.providerRequestId) { + yield* resetProviderDispatch(pending.stepRunId); + } else { + yield* approvals.park(pending.stepRunId as never); + } + } + }); + + return { resume } satisfies DurableApprovalResumeShape; +}); + +export const DurableApprovalResumeLive = Layer.effect(DurableApprovalResume, make); diff --git a/apps/server/src/workflow/Layers/MockAcpProvider.ts b/apps/server/src/workflow/Layers/MockAcpProvider.ts new file mode 100644 index 00000000000..4a44ed32c67 --- /dev/null +++ b/apps/server/src/workflow/Layers/MockAcpProvider.ts @@ -0,0 +1,94 @@ +import type { TurnId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { ProviderTurnPort } from "../Services/ProviderDispatchOutbox.ts"; +import { TurnProjectionPort } from "../Services/TurnStateReader.ts"; + +type MockTurnState = "running" | "completed" | "error"; + +interface MockTurn { + readonly threadId: string; + readonly turnId: TurnId; + readonly state: MockTurnState; +} + +interface MockAcpState { + readonly startedCount: number; + readonly turns: ReadonlyMap; +} + +export interface MockAcpProviderShape { + readonly startedCount: Effect.Effect; + readonly completeAllRunning: () => Effect.Effect; +} + +export class MockAcpProvider extends Context.Service()( + "t3/workflow/Layers/MockAcpProvider", +) {} + +export const MockAcpProviderLive = Layer.unwrap( + Effect.gen(function* () { + const state = yield* Ref.make({ + startedCount: 0, + turns: new Map(), + }); + + const providerTurnPort = ProviderTurnPort.of({ + ensureTurnStarted: (request) => + Ref.modify(state, (current) => { + const existing = current.turns.get(request.threadId as string); + if (existing) { + return [{ turnId: existing.turnId }, current] as const; + } + + const turn = { + threadId: request.threadId as string, + turnId: `turn-${request.threadId}` as TurnId, + state: "running" as const, + } satisfies MockTurn; + const turns = new Map(current.turns); + turns.set(turn.threadId, turn); + return [ + { turnId: turn.turnId }, + { startedCount: current.startedCount + 1, turns }, + ] as const; + }), + }); + + const turnProjectionPort = TurnProjectionPort.of({ + getLatestTurnState: (threadId) => + Ref.get(state).pipe( + Effect.map((current) => { + const turn = current.turns.get(threadId as string); + return { + state: turn?.state ?? "pending", + completed: turn?.state === "completed" || turn?.state === "error", + }; + }), + ), + }); + + const mock = MockAcpProvider.of({ + startedCount: Ref.get(state).pipe(Effect.map((current) => current.startedCount)), + completeAllRunning: () => + Ref.update(state, (current) => { + const turns = new Map(current.turns); + for (const [threadId, turn] of turns) { + if (turn.state === "running") { + turns.set(threadId, { ...turn, state: "completed" }); + } + } + return { ...current, turns }; + }), + }); + + return Layer.mergeAll( + Layer.succeed(MockAcpProvider, mock), + Layer.succeed(ProviderTurnPort, providerTurnPort), + Layer.succeed(TurnProjectionPort, turnProjectionPort), + ); + }), +); diff --git a/apps/server/src/workflow/Layers/PredicateEvaluator.test.ts b/apps/server/src/workflow/Layers/PredicateEvaluator.test.ts new file mode 100644 index 00000000000..e5826e93209 --- /dev/null +++ b/apps/server/src/workflow/Layers/PredicateEvaluator.test.ts @@ -0,0 +1,69 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { PredicateEvaluationError, PredicateEvaluator } from "../Services/PredicateEvaluator.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; + +const layer = it.layer(PredicateEvaluatorLive); + +layer("PredicateEvaluator", (it) => { + it.effect("evaluates allowlisted JSONLogic and reports referenced paths", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const evaluation = yield* evaluator.evaluate( + { + and: [ + { "==": [{ var: "steps.tests.exitCode" }, 0] }, + { in: ["pass", { var: "steps.review.output.verdict" }] }, + { "!=": [{ var: "pipeline.result" }, "failure"] }, + { "!": { var: "steps.review.output.blocked" } }, + ], + }, + { + pipeline: { result: "success" }, + status: "running", + steps: { + tests: { exitCode: 0, status: "completed" }, + review: { status: "completed", output: { verdict: "pass", blocked: false } }, + }, + }, + ); + + assert.equal(evaluation.result, true); + assert.deepEqual(evaluation.matchedPaths, [ + "steps.tests.exitCode", + "steps.review.output.verdict", + "pipeline.result", + "steps.review.output.blocked", + ]); + }), + ); + + it.effect("rejects unsupported operators before evaluation", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const result = yield* Effect.exit(evaluator.evaluate({ cat: ["x", "y"] }, {})); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.isTrue( + result.cause.toString().includes(PredicateEvaluationError.name) || + result.cause.toString().includes("unsupported JSONLogic operator"), + ); + } + }), + ); + + it.effect("rejects var defaults and non-string var paths", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const withDefault = yield* Effect.exit( + evaluator.evaluate({ "==": [{ var: ["status", "idle"] }, "idle"] }, {}), + ); + const nonString = yield* Effect.exit(evaluator.evaluate({ var: 123 }, {})); + + assert.equal(withDefault._tag, "Failure"); + assert.equal(nonString._tag, "Failure"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/PredicateEvaluator.ts b/apps/server/src/workflow/Layers/PredicateEvaluator.ts new file mode 100644 index 00000000000..ef7617f1ce8 --- /dev/null +++ b/apps/server/src/workflow/Layers/PredicateEvaluator.ts @@ -0,0 +1,53 @@ +import { createRequire } from "node:module"; + +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { + PredicateEvaluationError, + PredicateEvaluator, + type PredicateEvaluatorShape, +} from "../Services/PredicateEvaluator.ts"; +import { inspectJsonLogicRule } from "../jsonLogicRule.ts"; + +interface JsonLogicModule { + readonly apply: (rule: unknown, data?: unknown) => unknown; + readonly truthy: (value: unknown) => boolean; +} + +const require = createRequire(import.meta.url); +const jsonLogic = require("json-logic-js") as JsonLogicModule; +const isPredicateEvaluationError = Schema.is(PredicateEvaluationError); + +const makePredicateError = (message: string, cause?: unknown) => + new PredicateEvaluationError({ + message, + ...(cause === undefined ? {} : { cause }), + }); + +const evaluateRule = (rule: unknown, context: unknown) => + Effect.try({ + try: () => { + const inspection = inspectJsonLogicRule(rule); + const issue = inspection.issues[0]; + if (issue !== undefined) { + throw makePredicateError(issue.message); + } + const raw = jsonLogic.apply(rule, context); + return { + result: jsonLogic.truthy(raw), + matchedPaths: inspection.variablePaths, + }; + }, + catch: (cause) => + isPredicateEvaluationError(cause) + ? cause + : makePredicateError("JSONLogic evaluation failed", cause), + }); + +const make = Effect.succeed({ + evaluate: evaluateRule, +} satisfies PredicateEvaluatorShape); + +export const PredicateEvaluatorLive = Layer.effect(PredicateEvaluator, make); diff --git a/apps/server/src/workflow/Layers/ProjectScriptTrust.test.ts b/apps/server/src/workflow/Layers/ProjectScriptTrust.test.ts new file mode 100644 index 00000000000..fe649a88079 --- /dev/null +++ b/apps/server/src/workflow/Layers/ProjectScriptTrust.test.ts @@ -0,0 +1,27 @@ +import { assert, it } from "@effect/vitest"; +import { ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ProjectScriptTrust } from "../Services/ProjectScriptTrust.ts"; +import { ProjectScriptTrustLive } from "./ProjectScriptTrust.ts"; + +const layer = it.layer(Layer.provide(ProjectScriptTrustLive, SqlitePersistenceMemory)); + +layer("ProjectScriptTrustLive", (it) => { + it.effect("persists per-project trust grants and revocations", () => + Effect.gen(function* () { + const trust = yield* ProjectScriptTrust; + const projectId = ProjectId.make("project-trust"); + + assert.isFalse(yield* trust.isTrusted(projectId)); + + yield* trust.setTrusted(projectId, true); + assert.isTrue(yield* trust.isTrusted(projectId)); + + yield* trust.setTrusted(projectId, false); + assert.isFalse(yield* trust.isTrusted(projectId)); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/ProjectScriptTrust.ts b/apps/server/src/workflow/Layers/ProjectScriptTrust.ts new file mode 100644 index 00000000000..52f0fbe7aef --- /dev/null +++ b/apps/server/src/workflow/Layers/ProjectScriptTrust.ts @@ -0,0 +1,52 @@ +import type { ProjectId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + ProjectScriptTrust, + type ProjectScriptTrustShape, +} from "../Services/ProjectScriptTrust.ts"; + +const toTrustError = (cause: unknown) => + new WorkflowEventStoreError({ message: "project script trust failed", cause }); + +const wrap = (effect: Effect.Effect) => effect.pipe(Effect.mapError(toTrustError)); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const isTrusted: ProjectScriptTrustShape["isTrusted"] = (projectId: ProjectId) => + wrap(sql<{ readonly trusted: number }>` + SELECT 1 AS trusted + FROM workflow_project_trust + WHERE project_id = ${projectId} + LIMIT 1 + `).pipe(Effect.map((rows) => rows.length > 0)); + + const setTrusted: ProjectScriptTrustShape["setTrusted"] = (projectId, trusted) => { + if (!trusted) { + return wrap(sql` + DELETE FROM workflow_project_trust + WHERE project_id = ${projectId} + `).pipe(Effect.asVoid); + } + + return Effect.gen(function* () { + const trustedAt = DateTime.formatIso(yield* DateTime.now); + yield* wrap(sql` + INSERT INTO workflow_project_trust (project_id, trusted_at) + VALUES (${projectId}, ${trustedAt}) + ON CONFLICT(project_id) DO UPDATE SET + trusted_at = excluded.trusted_at + `); + }).pipe(Effect.asVoid); + }; + + return { isTrusted, setTrusted } satisfies ProjectScriptTrustShape; +}); + +export const ProjectScriptTrustLive = Layer.effect(ProjectScriptTrust, make); diff --git a/apps/server/src/workflow/Layers/ProjectWorkspaceResolver.test.ts b/apps/server/src/workflow/Layers/ProjectWorkspaceResolver.test.ts new file mode 100644 index 00000000000..cf018055cae --- /dev/null +++ b/apps/server/src/workflow/Layers/ProjectWorkspaceResolver.test.ts @@ -0,0 +1,70 @@ +import { assert, it } from "@effect/vitest"; +import type { ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import type { ProjectionSnapshotQueryShape } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + ProjectWorkspaceResolver, + ProjectWorkspaceResolverError, +} from "../Services/ProjectWorkspaceResolver.ts"; +import { ProjectWorkspaceResolverLive } from "./ProjectWorkspaceResolver.ts"; + +const projectId = "project-1" as ProjectId; + +const queryLayer = (getProjectShellById: ProjectionSnapshotQueryShape["getProjectShellById"]) => + Layer.succeed(ProjectionSnapshotQuery, { + getProjectShellById, + } as unknown as ProjectionSnapshotQueryShape); + +it.effect("ProjectWorkspaceResolver resolves a project workspaceRoot", () => + Effect.gen(function* () { + const layer = ProjectWorkspaceResolverLive.pipe( + Layer.provide( + queryLayer(() => + Effect.succeed( + Option.some({ + id: projectId, + title: "Project", + workspaceRoot: "/tmp/t3-project", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-06-07T00:00:00.000Z" as never, + updatedAt: "2026-06-07T00:00:00.000Z" as never, + }), + ), + ), + ), + ); + + const workspaceRoot = yield* Effect.gen(function* () { + const resolver = yield* ProjectWorkspaceResolver; + return yield* resolver.resolve(projectId); + }).pipe(Effect.provide(layer)); + + assert.equal(workspaceRoot, "/tmp/t3-project"); + }), +); + +it.effect("ProjectWorkspaceResolver fails with a typed error for an unknown project", () => + Effect.gen(function* () { + const layer = ProjectWorkspaceResolverLive.pipe( + Layer.provide(queryLayer(() => Effect.succeed(Option.none()))), + ); + + const result = yield* Effect.exit( + Effect.gen(function* () { + const resolver = yield* ProjectWorkspaceResolver; + return yield* resolver.resolve("missing-project" as ProjectId); + }).pipe(Effect.provide(layer)), + ); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.isTrue(String(result.cause).includes(ProjectWorkspaceResolverError.name)); + } + }), +); diff --git a/apps/server/src/workflow/Layers/ProjectWorkspaceResolver.ts b/apps/server/src/workflow/Layers/ProjectWorkspaceResolver.ts new file mode 100644 index 00000000000..c7f0769da3e --- /dev/null +++ b/apps/server/src/workflow/Layers/ProjectWorkspaceResolver.ts @@ -0,0 +1,37 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + ProjectWorkspaceResolver, + ProjectWorkspaceResolverError, + type ProjectWorkspaceResolverShape, +} from "../Services/ProjectWorkspaceResolver.ts"; + +const toResolverError = (message: string) => (cause: unknown) => + new ProjectWorkspaceResolverError({ message, cause }); + +const make = Effect.gen(function* () { + const projects = yield* ProjectionSnapshotQuery; + + const resolve: ProjectWorkspaceResolverShape["resolve"] = (projectId) => + projects.getProjectShellById(projectId).pipe( + Effect.mapError(toResolverError(`Failed to resolve workspace for project ${projectId}`)), + Effect.flatMap((project) => + Option.match(project, { + onNone: () => + Effect.fail( + new ProjectWorkspaceResolverError({ + message: `Project ${projectId} was not found`, + }), + ), + onSome: (shell) => Effect.succeed(shell.workspaceRoot as string), + }), + ), + ); + + return { resolve } satisfies ProjectWorkspaceResolverShape; +}); + +export const ProjectWorkspaceResolverLive = Layer.effect(ProjectWorkspaceResolver, make); diff --git a/apps/server/src/workflow/Layers/ProviderDispatchOutbox.test.ts b/apps/server/src/workflow/Layers/ProviderDispatchOutbox.test.ts new file mode 100644 index 00000000000..838b896c7fe --- /dev/null +++ b/apps/server/src/workflow/Layers/ProviderDispatchOutbox.test.ts @@ -0,0 +1,349 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { + ProviderDispatchOutbox, + ProviderTurnPort, + type DispatchRequest, +} from "../Services/ProviderDispatchOutbox.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; +import { ProviderDispatchOutboxLive } from "./ProviderDispatchOutbox.ts"; + +const request = { + dispatchId: "dispatch-1" as never, + ticketId: "ticket-1" as never, + stepRunId: "step-run-1" as never, + threadId: "thread-1" as never, + providerInstance: "codex", + model: "gpt-5.5", + instruction: "Implement the next workflow step", + worktreePath: "/tmp/workflow-ticket-1", +} satisfies DispatchRequest; + +it.effect("starts provider dispatch idempotently and confirms from terminal turn state", () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make(0); + const turnReads = yield* Ref.make(0); + + const layer = ProviderDispatchOutboxLive.pipe( + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => + Ref.update(providerCalls, (count) => count + 1).pipe( + Effect.as({ turnId: "turn-1" as never }), + ), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => + Ref.updateAndGet(turnReads, (count) => count + 1).pipe( + Effect.map((count) => + count === 1 ? ({ _tag: "running" } as const) : ({ _tag: "completed" } as const), + ), + ), + }), + ), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const outbox = yield* ProviderDispatchOutbox; + const sql = yield* SqlClient.SqlClient; + + yield* outbox.ensureStarted(request); + yield* outbox.ensureStarted(request); + + assert.equal(yield* Ref.get(providerCalls), 1); + + const started = yield* sql<{ readonly status: string; readonly turnId: string | null }>` + SELECT status, turn_id AS "turnId" + FROM workflow_dispatch_outbox + WHERE dispatch_id = ${request.dispatchId} + `; + assert.equal(started[0]?.status, "started"); + assert.equal(started[0]?.turnId, "turn-1"); + + const terminalFiber = yield* Effect.forkChild( + outbox.awaitTerminal(request.dispatchId, request.threadId), + ); + yield* Effect.yieldNow; + yield* TestClock.adjust("500 millis"); + const terminal = yield* Fiber.join(terminalFiber); + assert.deepEqual(terminal, { ok: true }); + + const confirmed = yield* sql<{ readonly status: string }>` + SELECT status FROM workflow_dispatch_outbox WHERE dispatch_id = ${request.dispatchId} + `; + assert.equal(confirmed[0]?.status, "confirmed"); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("looks up dispatch thread and turn by step run", () => + Effect.gen(function* () { + const layer = ProviderDispatchOutboxLive.pipe( + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.succeed({ turnId: "turn-1" as never }), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const outbox = yield* ProviderDispatchOutbox; + + yield* outbox.ensureStarted(request); + + const dispatch = yield* outbox.getDispatchForStep(request.stepRunId); + assert.deepEqual(dispatch, { + threadId: "thread-1", + turnId: "turn-1", + }); + }).pipe(Effect.provide(layer)); + }), +); + +const agentOptions = [ + { id: "effort", value: "high" }, + { id: "thinking", value: true }, +]; + +it.effect("persists agent option selections as JSON on dispatch", () => + Effect.gen(function* () { + const layer = ProviderDispatchOutboxLive.pipe( + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.succeed({ turnId: "turn-1" as never }), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const outbox = yield* ProviderDispatchOutbox; + const sql = yield* SqlClient.SqlClient; + + yield* outbox.ensureStarted({ ...request, options: agentOptions }); + + const stored = yield* sql<{ readonly optionsJson: string | null }>` + SELECT options_json AS "optionsJson" + FROM workflow_dispatch_outbox + WHERE dispatch_id = ${request.dispatchId} + `; + const optionsJson = stored[0]?.optionsJson ?? null; + assert.isNotNull(optionsJson); + // @effect-diagnostics-next-line preferSchemaOverJson:off - test asserts the persisted JSON shape. + assert.deepEqual(JSON.parse(optionsJson!), agentOptions); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("replays persisted agent options to the provider on recovery", () => + Effect.gen(function* () { + const replayed = yield* Ref.make>([]); + const layer = ProviderDispatchOutboxLive.pipe( + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: (req: DispatchRequest) => + Ref.update(replayed, (all) => [...all, req]).pipe( + Effect.as({ turnId: "turn-1" as never }), + ), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const outbox = yield* ProviderDispatchOutbox; + const sql = yield* SqlClient.SqlClient; + + yield* sql` + INSERT INTO projection_board ( + board_id, project_id, name, workflow_file_path, workflow_version_hash, max_concurrent_tickets + ) + VALUES ('board-1', 'project-1', 'Board', '.t3/board.toml', 'hash-1', 1) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ( + ${request.ticketId}, 'board-1', 'Ticket', 'implement', 'active', + '2026-06-09T00:00:00.000Z', '2026-06-09T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, ticket_id, step_run_id, thread_id, + provider_instance, model, instruction, worktree_path, options_json, status, created_at + ) + VALUES ( + ${request.dispatchId}, ${request.ticketId}, ${request.stepRunId}, ${request.threadId}, + ${request.providerInstance}, ${request.model}, ${request.instruction}, ${request.worktreePath}, + '[{"id":"effort","value":"high"},{"id":"thinking","value":true}]', + 'pending', '2026-06-09T00:00:00.000Z' + ) + `; + + yield* outbox.recoverPending(); + + const all = yield* Ref.get(replayed); + assert.equal(all.length, 1); + assert.deepEqual(all[0]?.options, agentOptions); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("recovers dispatches without options as plain requests", () => + Effect.gen(function* () { + const replayed = yield* Ref.make>([]); + const layer = ProviderDispatchOutboxLive.pipe( + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: (req: DispatchRequest) => + Ref.update(replayed, (all) => [...all, req]).pipe( + Effect.as({ turnId: "turn-1" as never }), + ), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const outbox = yield* ProviderDispatchOutbox; + const sql = yield* SqlClient.SqlClient; + + yield* sql` + INSERT INTO projection_board ( + board_id, project_id, name, workflow_file_path, workflow_version_hash, max_concurrent_tickets + ) + VALUES ('board-1', 'project-1', 'Board', '.t3/board.toml', 'hash-1', 1) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ( + ${request.ticketId}, 'board-1', 'Ticket', 'implement', 'active', + '2026-06-09T00:00:00.000Z', '2026-06-09T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, ticket_id, step_run_id, thread_id, + provider_instance, model, instruction, worktree_path, options_json, status, created_at + ) + VALUES ( + ${request.dispatchId}, ${request.ticketId}, ${request.stepRunId}, ${request.threadId}, + ${request.providerInstance}, ${request.model}, ${request.instruction}, ${request.worktreePath}, + NULL, 'pending', '2026-06-09T00:00:00.000Z' + ) + `; + + yield* outbox.recoverPending(); + + const all = yield* Ref.get(replayed); + assert.equal(all.length, 1); + assert.equal(all[0]?.options, undefined); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("deletes pending dispatches whose ticket projection no longer exists", () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make(0); + const layer = ProviderDispatchOutboxLive.pipe( + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => + Ref.update(providerCalls, (count) => count + 1).pipe( + Effect.as({ turnId: "turn-orphan" as never }), + ), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const outbox = yield* ProviderDispatchOutbox; + const sql = yield* SqlClient.SqlClient; + + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES ( + 'dispatch-orphan', + 'ticket-orphan', + 'step-orphan', + 'thread-orphan', + 'codex', + 'gpt-5.5', + 'do not start', + '/tmp/orphan', + 'pending', + '2026-06-07T00:00:00.000Z' + ) + `; + + yield* outbox.recoverPending(); + + assert.equal(yield* Ref.get(providerCalls), 0); + const remaining = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_dispatch_outbox + WHERE dispatch_id = 'dispatch-orphan' + `; + assert.equal(remaining[0]?.count, 0); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts b/apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts new file mode 100644 index 00000000000..dcccfb23baa --- /dev/null +++ b/apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts @@ -0,0 +1,468 @@ +import { + ProviderInstanceId, + ProviderOptionSelections, + TrimmedNonEmptyString, + type ModelSelection, + type ProviderSendTurnInput, + type ProviderSessionStartInput, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + ProviderDispatchOutbox, + ProviderTurnPort, + type DispatchRequest, + type ProviderDispatchTerminalResult, + type ProviderDispatchOutboxShape, + type ProviderTurnPortShape, +} from "../Services/ProviderDispatchOutbox.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); +const TERMINAL_WAIT_TIMEOUT = Duration.minutes(30); + +const toDispatchError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrapSql = (effect: Effect.Effect) => + effect.pipe(Effect.mapError(toDispatchError("dispatch op failed"))); + +interface DispatchStatusRow { + readonly status: "pending" | "started" | "confirmed"; + readonly turnId: string | null; +} + +interface RecoverDispatchRow extends Omit< + DispatchRequest, + "options" | "projectId" | "threadTitle" | "runtimeMode" +> { + readonly status: "pending" | "started" | "confirmed"; + readonly optionsJson: string | null; + readonly projectId: string | null; + readonly threadTitle: string | null; + readonly runtimeMode: string | null; +} + +const dispatchOptionsJson = Schema.fromJsonString(ProviderOptionSelections); +const encodeDispatchOptionsJson = Schema.encodeEffect(dispatchOptionsJson); +const decodeDispatchOptionsJson = Schema.decodeEffect(dispatchOptionsJson); + +// Tolerant decode: an unparseable/legacy row should not abort recovery of the +// remaining pending dispatches, so a decode failure degrades to "no options". +const recoverDispatchRowToRequest = (row: RecoverDispatchRow): Effect.Effect => + Effect.gen(function* () { + const options = + row.optionsJson === null || row.optionsJson.length === 0 + ? undefined + : yield* decodeDispatchOptionsJson(row.optionsJson).pipe( + Effect.orElseSucceed(() => undefined), + ); + const runtimeMode = + row.runtimeMode === "approval-required" || + row.runtimeMode === "auto-accept-edits" || + row.runtimeMode === "full-access" + ? row.runtimeMode + : undefined; + return { + dispatchId: row.dispatchId, + ticketId: row.ticketId, + stepRunId: row.stepRunId, + threadId: row.threadId, + providerInstance: row.providerInstance, + model: row.model, + instruction: row.instruction, + worktreePath: row.worktreePath, + ...(options === undefined ? {} : { options }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + ...(row.threadTitle === null ? {} : { threadTitle: row.threadTitle }), + ...(runtimeMode === undefined ? {} : { runtimeMode }), + }; + }); + +interface StepDispatchRow { + readonly dispatchId: string; +} + +interface DispatchForStepRow { + readonly threadId: string; + readonly turnId: string | null; +} + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const provider = yield* ProviderTurnPort; + const turns = yield* TurnStateReader; + + const getDispatchStatus = (dispatchId: string) => + wrapSql(sql` + SELECT + status, + turn_id AS "turnId" + FROM workflow_dispatch_outbox + WHERE dispatch_id = ${dispatchId} + `).pipe(Effect.map((rows) => rows[0] ?? null)); + + const confirmStep: ProviderDispatchOutboxShape["confirmStep"] = (stepRunId) => + Effect.gen(function* () { + const confirmedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'confirmed', + confirmed_at = ${confirmedAt} + WHERE step_run_id = ${stepRunId} + AND status != 'confirmed' + `); + }); + + const ensureStarted: ProviderDispatchOutboxShape["ensureStarted"] = (req) => + Effect.gen(function* () { + const createdAt = yield* nowIso; + const optionsJson = + req.options === undefined + ? null + : yield* encodeDispatchOptionsJson(req.options).pipe( + Effect.mapError(toDispatchError("dispatch options encode failed")), + ); + yield* wrapSql(sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + options_json, + project_id, + thread_title, + runtime_mode, + status, + created_at + ) + VALUES ( + ${req.dispatchId}, + ${req.ticketId}, + ${req.stepRunId}, + ${req.threadId}, + ${req.providerInstance}, + ${req.model}, + ${req.instruction}, + ${req.worktreePath}, + ${optionsJson}, + ${req.projectId ?? null}, + ${req.threadTitle ?? null}, + ${req.runtimeMode ?? null}, + 'pending', + ${createdAt} + ) + ON CONFLICT(dispatch_id) DO NOTHING + `); + + const status = yield* getDispatchStatus(req.dispatchId); + if ( + (status?.status === "started" || status?.status === "confirmed") && + status.turnId !== null + ) { + return { turnId: status.turnId as never }; + } + + const { turnId } = yield* provider.ensureTurnStarted(req); + const startedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'started', + turn_id = ${turnId}, + started_at = ${startedAt} + WHERE dispatch_id = ${req.dispatchId} + `); + return { turnId }; + }); + + const getDispatchForStep: ProviderDispatchOutboxShape["getDispatchForStep"] = (stepRunId) => + wrapSql(sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId" + FROM workflow_dispatch_outbox + WHERE step_run_id = ${stepRunId} + ORDER BY created_at DESC, dispatch_id DESC + LIMIT 1 + `).pipe( + Effect.map((rows) => { + const row = rows[0]; + if (!row || row.turnId === null) { + return null; + } + return { + threadId: row.threadId as never, + turnId: row.turnId as never, + }; + }), + ); + + const awaitTerminal: ProviderDispatchOutboxShape["awaitTerminal"] = (dispatchId, threadId) => { + const waitForTerminal: Effect.Effect = + Effect.gen(function* () { + let state = yield* turns.read(threadId); + while (state._tag === "running") { + yield* Effect.sleep("500 millis"); + state = yield* turns.read(threadId); + } + if (state._tag === "awaiting_user") { + return { + ok: false, + awaitingUser: true, + waitingReason: state.waitingReason, + providerThreadId: state.providerThreadId, + providerRequestId: state.providerRequestId, + providerResponseKind: state.providerResponseKind, + ...(state.providerQuestionId === undefined + ? {} + : { providerQuestionId: state.providerQuestionId }), + } satisfies ProviderDispatchTerminalResult; + } + + const confirmedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'confirmed', + confirmed_at = ${confirmedAt} + WHERE dispatch_id = ${dispatchId} + `); + + return state._tag === "completed" + ? ({ ok: true } satisfies ProviderDispatchTerminalResult) + : ({ ok: false, error: state.error } satisfies ProviderDispatchTerminalResult); + }); + + return waitForTerminal.pipe( + Effect.timeoutOption(TERMINAL_WAIT_TIMEOUT), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.succeed({ + ok: false, + error: "turn did not reach a terminal state before timeout", + } satisfies ProviderDispatchTerminalResult), + onSome: Effect.succeed, + }), + ), + ); + }; + + const awaitStepTerminal: ProviderDispatchOutboxShape["awaitStepTerminal"] = ( + stepRunId, + threadId, + ) => + Effect.gen(function* () { + const rows = yield* wrapSql(sql` + SELECT dispatch_id AS "dispatchId" + FROM workflow_dispatch_outbox + WHERE step_run_id = ${stepRunId} + ORDER BY created_at DESC, dispatch_id DESC + LIMIT 1 + `); + const dispatchId = rows[0]?.dispatchId; + if (!dispatchId) { + return yield* new WorkflowEventStoreError({ + message: `dispatch not found for step ${stepRunId}`, + }); + } + return yield* awaitTerminal(dispatchId as never, threadId); + }); + + const deleteOrphanDispatches = wrapSql(sql` + DELETE FROM workflow_dispatch_outbox + WHERE NOT EXISTS ( + SELECT 1 + FROM projection_ticket AS ticket + INNER JOIN projection_board AS board + ON board.board_id = ticket.board_id + WHERE ticket.ticket_id = workflow_dispatch_outbox.ticket_id + ) + `).pipe(Effect.asVoid); + + // A dispatch row is only worth restarting while its pipeline still owns the + // ticket: a manual move (or re-route) hands out a new lane entry token, and + // restarting the superseded dispatch would let a stale agent mutate the + // worktree after the user moved on. + const tombstoneStaleDispatches = Effect.gen(function* () { + const confirmedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'confirmed', + confirmed_at = ${confirmedAt} + WHERE status != 'confirmed' + AND EXISTS ( + SELECT 1 + FROM projection_step_run AS step + INNER JOIN projection_pipeline_run AS pipeline + ON pipeline.pipeline_run_id = step.pipeline_run_id + INNER JOIN projection_ticket AS ticket + ON ticket.ticket_id = pipeline.ticket_id + WHERE step.step_run_id = workflow_dispatch_outbox.step_run_id + AND ( + ticket.current_lane_entry_token IS NULL + OR pipeline.lane_entry_token != ticket.current_lane_entry_token + ) + ) + `); + }); + + const recoverPending: ProviderDispatchOutboxShape["recoverPending"] = () => + Effect.gen(function* () { + yield* deleteOrphanDispatches; + yield* tombstoneStaleDispatches; + const rows = yield* wrapSql(sql` + SELECT + dispatch_id AS "dispatchId", + ticket_id AS "ticketId", + step_run_id AS "stepRunId", + thread_id AS "threadId", + provider_instance AS "providerInstance", + model, + instruction, + worktree_path AS "worktreePath", + options_json AS "optionsJson", + project_id AS "projectId", + thread_title AS "threadTitle", + runtime_mode AS "runtimeMode", + status + FROM workflow_dispatch_outbox + WHERE status != 'confirmed' + `); + + yield* Effect.forEach( + rows, + (row) => + row.status === "pending" + ? recoverDispatchRowToRequest(row).pipe(Effect.flatMap(ensureStarted)) + : Effect.void, + { discard: true }, + ); + }); + + return { + confirmStep, + ensureStarted, + getDispatchForStep, + awaitTerminal, + awaitStepTerminal, + recoverPending, + } satisfies ProviderDispatchOutboxShape; +}); + +export const ProviderDispatchOutboxLive = Layer.effect(ProviderDispatchOutbox, make); + +export const ProviderTurnPortLive = Layer.effect( + ProviderTurnPort, + Effect.gen(function* () { + const providerSvc = yield* ProviderService; + const turns = yield* ProjectionTurnRepository; + const orchestration = yield* Effect.serviceOption(OrchestrationEngineService); + + // Provider runtime ingestion (and the orchestration decider behind it) + // only accepts events for threads that exist in the orchestration domain. + // Workflow dispatch threads are not user chat threads, so create them as + // hidden threads through the real command path before the session starts; + // without this every dispatch turn is invisible and never reaches a + // terminal state from the workflow's perspective. + const ensureHiddenThreadShell = (req: DispatchRequest, modelSelection: ModelSelection) => + Effect.gen(function* () { + if (req.projectId === undefined || Option.isNone(orchestration)) { + return; + } + const now = yield* nowIso; + yield* orchestration.value + .dispatch({ + type: "thread.create", + commandId: `workflow-thread-${req.threadId}` as never, + threadId: req.threadId, + projectId: req.projectId as never, + title: req.threadTitle ?? "Workflow dispatch", + modelSelection, + runtimeMode: req.runtimeMode ?? "full-access", + interactionMode: "default", + branch: null, + worktreePath: req.worktreePath as never, + createdAt: now as never, + hidden: true, + }) + .pipe( + Effect.catchCause((cause) => { + // Re-dispatch after recovery hits the already-exists invariant — + // that one is a benign no-op. Anything else means the provider + // session would run invisibly, so fail the dispatch loudly. + if ( + Cause.squash(cause) instanceof Error && + String(Cause.squash(cause)).includes("already exists") + ) { + return Effect.void; + } + return Effect.logWarning("workflow thread create failed", { cause }).pipe( + Effect.andThen( + Effect.fail( + new WorkflowEventStoreError({ + message: "workflow thread create failed", + cause: Cause.squash(cause), + }), + ), + ), + ); + }), + ); + }).pipe(Effect.mapError(toDispatchError("workflow thread create failed"))); + + const ensureTurnStarted: ProviderTurnPortShape["ensureTurnStarted"] = (req) => + Effect.gen(function* () { + const existingTurns = yield* turns + .listByThreadId({ threadId: req.threadId }) + .pipe(Effect.orElseSucceed(() => [])); + const existingTurn = existingTurns.findLast( + (turn) => turn.turnId !== null && (turn.state === "pending" || turn.state === "running"), + ); + if (existingTurn?.turnId !== undefined && existingTurn.turnId !== null) { + return { turnId: existingTurn.turnId }; + } + + const providerInstanceId = ProviderInstanceId.make(req.providerInstance); + const modelSelection = { + instanceId: providerInstanceId, + model: TrimmedNonEmptyString.make(req.model), + ...(req.options === undefined ? {} : { options: req.options }), + }; + yield* ensureHiddenThreadShell(req, modelSelection); + const sessionInput = { + threadId: req.threadId, + providerInstanceId, + cwd: TrimmedNonEmptyString.make(req.worktreePath), + modelSelection, + runtimeMode: req.runtimeMode ?? "full-access", + } satisfies ProviderSessionStartInput; + const sendInput = { + threadId: req.threadId, + input: TrimmedNonEmptyString.make(req.instruction), + modelSelection, + } satisfies ProviderSendTurnInput; + + yield* providerSvc.startSession(req.threadId, sessionInput); + const turn = yield* providerSvc.sendTurn(sendInput); + return { turnId: turn.turnId }; + }).pipe(Effect.mapError(toDispatchError("provider start failed"))); + + return { ensureTurnStarted } satisfies ProviderTurnPortShape; + }), +); diff --git a/apps/server/src/workflow/Layers/ProviderResponsePort.test.ts b/apps/server/src/workflow/Layers/ProviderResponsePort.test.ts new file mode 100644 index 00000000000..e7314e52b4d --- /dev/null +++ b/apps/server/src/workflow/Layers/ProviderResponsePort.test.ts @@ -0,0 +1,94 @@ +import { assert, it } from "@effect/vitest"; +import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { ProviderResponsePort } from "../Services/ProviderResponsePort.ts"; +import { ProviderResponsePortLive } from "./ProviderResponsePort.ts"; + +it.effect("ProviderResponsePortLive keys user-input answers by the awaiting question id", () => + Effect.gen(function* () { + const userInputResponses = yield* Ref.make>([]); + const providerLayer = Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: () => Effect.die("unused"), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: (input) => Ref.update(userInputResponses, (calls) => [...calls, input]), + stopSession: () => Effect.die("unused"), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die("unused"), + getInstanceInfo: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + streamEvents: Stream.empty, + }); + + const program = Effect.gen(function* () { + const port = yield* ProviderResponsePort; + yield* port.respond({ + threadId: ThreadId.make("thread-ticket-answer"), + requestId: ApprovalRequestId.make("request-ticket-answer"), + responseKind: "user-input", + approved: true, + questionId: "Which API should I use?", + text: "Use the sandbox endpoint.", + } as never); + }); + + yield* program.pipe( + Effect.provide(ProviderResponsePortLive.pipe(Layer.provide(providerLayer))), + ); + + assert.deepEqual(yield* Ref.get(userInputResponses), [ + { + threadId: "thread-ticket-answer", + requestId: "request-ticket-answer", + answers: { + "Which API should I use?": "Use the sandbox endpoint.", + }, + }, + ]); + }), +); + +it.effect("ProviderResponsePortLive rejects text user-input answers without a question id", () => + Effect.gen(function* () { + const userInputResponses = yield* Ref.make>([]); + const providerLayer = Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: () => Effect.die("unused"), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: (input) => Ref.update(userInputResponses, (calls) => [...calls, input]), + stopSession: () => Effect.die("unused"), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die("unused"), + getInstanceInfo: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + streamEvents: Stream.empty, + }); + + const program = Effect.gen(function* () { + const port = yield* ProviderResponsePort; + const error = yield* Effect.flip( + port.respond({ + threadId: ThreadId.make("thread-ticket-answer-missing-question"), + requestId: ApprovalRequestId.make("request-ticket-answer-missing-question"), + responseKind: "user-input", + approved: true, + text: "Use the sandbox endpoint.", + } as never), + ); + assert.include(error.message, "question id"); + }); + + yield* program.pipe( + Effect.provide(ProviderResponsePortLive.pipe(Layer.provide(providerLayer))), + ); + + assert.deepEqual(yield* Ref.get(userInputResponses), []); + }), +); diff --git a/apps/server/src/workflow/Layers/ProviderResponsePort.ts b/apps/server/src/workflow/Layers/ProviderResponsePort.ts new file mode 100644 index 00000000000..d205d72d455 --- /dev/null +++ b/apps/server/src/workflow/Layers/ProviderResponsePort.ts @@ -0,0 +1,55 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + ProviderResponsePort, + type ProviderResponsePortShape, +} from "../Services/ProviderResponsePort.ts"; + +const toResponseError = (cause: unknown) => + new WorkflowEventStoreError({ message: "provider response failed", cause }); + +export const ProviderResponsePortLive = Layer.effect( + ProviderResponsePort, + Effect.gen(function* () { + const provider = yield* ProviderService; + + const respond: ProviderResponsePortShape["respond"] = (input) => { + if (input.responseKind === "request") { + return provider + .respondToRequest({ + threadId: input.threadId, + requestId: input.requestId, + decision: input.approved ? "accept" : "decline", + }) + .pipe(Effect.mapError(toResponseError)); + } + + if ( + input.text !== undefined && + (input.questionId === undefined || input.questionId.trim().length === 0) + ) { + return Effect.fail( + new WorkflowEventStoreError({ + message: "provider user-input text response requires a question id", + }), + ); + } + + return provider + .respondToUserInput({ + threadId: input.threadId, + requestId: input.requestId, + answers: + input.questionId === undefined || input.text === undefined + ? {} + : { [input.questionId]: input.text }, + }) + .pipe(Effect.mapError(toResponseError)); + }; + + return { respond } satisfies ProviderResponsePortShape; + }), +); diff --git a/apps/server/src/workflow/Layers/ProviderTurnPort.test.ts b/apps/server/src/workflow/Layers/ProviderTurnPort.test.ts new file mode 100644 index 00000000000..a91074aaa63 --- /dev/null +++ b/apps/server/src/workflow/Layers/ProviderTurnPort.test.ts @@ -0,0 +1,171 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import type { + ProviderSendTurnInput, + ProviderSessionStartInput, + ThreadId, +} from "@t3tools/contracts"; + +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../../orchestration/Services/OrchestrationEngine.ts"; +import { + ProjectionTurnRepository, + type ProjectionTurnRepositoryShape, +} from "../../persistence/Services/ProjectionTurns.ts"; +import { + ProviderService, + type ProviderServiceShape, +} from "../../provider/Services/ProviderService.ts"; +import { ProviderTurnPort, type DispatchRequest } from "../Services/ProviderDispatchOutbox.ts"; +import { ProviderTurnPortLive } from "./ProviderDispatchOutbox.ts"; + +const baseRequest = { + dispatchId: "dispatch-1" as never, + ticketId: "ticket-1" as never, + stepRunId: "step-run-1" as never, + threadId: "thread-1" as never, + providerInstance: "claudeAgent", + model: "claude-opus-4-6", + instruction: "Do the workflow step", + worktreePath: "/tmp/workflow-ticket-1", +} satisfies DispatchRequest; + +interface Captured { + readonly start: Ref.Ref; + readonly send: Ref.Ref; + readonly commands?: Array>; +} + +const makeLayer = (captured: Captured) => + ProviderTurnPortLive.pipe( + Layer.provideMerge( + Layer.succeed(OrchestrationEngineService, { + dispatch: (command: Record) => + Effect.sync(() => { + captured.commands?.push(command); + return { sequence: 1 }; + }), + } as unknown as OrchestrationEngineShape), + ), + Layer.provideMerge( + Layer.succeed(ProviderService, { + startSession: (_threadId: ThreadId, input: ProviderSessionStartInput) => + Ref.set(captured.start, input).pipe( + Effect.as({ + provider: "claudeAgent", + status: "ready", + runtimeMode: "full-access", + threadId: input.threadId, + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + }), + ), + sendTurn: (input: ProviderSendTurnInput) => + Ref.set(captured.send, input).pipe( + Effect.as({ threadId: input.threadId, turnId: "turn-1" }), + ), + } as unknown as ProviderServiceShape), + ), + Layer.provideMerge( + Layer.succeed(ProjectionTurnRepository, { + listByThreadId: () => Effect.succeed([]), + } as unknown as ProjectionTurnRepositoryShape), + ), + ); + +it.effect("forwards agent option selections into the provider model selection", () => + Effect.gen(function* () { + const captured: Captured = { + start: yield* Ref.make(null), + send: yield* Ref.make(null), + }; + const options = [ + { id: "effort", value: "high" }, + { id: "thinking", value: true }, + ] as const; + + yield* Effect.gen(function* () { + const port = yield* ProviderTurnPort; + yield* port.ensureTurnStarted({ ...baseRequest, options }); + }).pipe(Effect.provide(makeLayer(captured))); + + const send = yield* Ref.get(captured.send); + const start = yield* Ref.get(captured.start); + assert.deepEqual(send?.modelSelection?.options, options); + assert.deepEqual(start?.modelSelection?.options, options); + }), +); + +it.effect("creates a hidden orchestration thread so ingestion projects the dispatch turn", () => + Effect.gen(function* () { + const commands: Array> = []; + const captured: Captured = { + start: yield* Ref.make(null), + send: yield* Ref.make(null), + commands, + }; + + yield* Effect.gen(function* () { + const port = yield* ProviderTurnPort; + yield* port.ensureTurnStarted({ + ...baseRequest, + projectId: "project-1", + threadTitle: "Workflow step review · ticket-1", + runtimeMode: "approval-required", + }); + }).pipe(Effect.provide(makeLayer(captured))); + + assert.equal(commands.length, 1); + const command = commands[0]; + assert.equal(command?.["type"], "thread.create"); + assert.equal(command?.["threadId"], "thread-1"); + assert.equal(command?.["projectId"], "project-1"); + assert.equal(command?.["title"], "Workflow step review · ticket-1"); + assert.equal(command?.["hidden"], true); + assert.equal(command?.["runtimeMode"], "approval-required"); + const start = yield* Ref.get(captured.start); + assert.equal(start?.runtimeMode, "approval-required"); + }), +); + +it.effect("skips thread creation when no project id is provided", () => + Effect.gen(function* () { + const commands: Array> = []; + const captured: Captured = { + start: yield* Ref.make(null), + send: yield* Ref.make(null), + commands, + }; + + yield* Effect.gen(function* () { + const port = yield* ProviderTurnPort; + yield* port.ensureTurnStarted(baseRequest); + }).pipe(Effect.provide(makeLayer(captured))); + + assert.equal(commands.length, 0); + const start = yield* Ref.get(captured.start); + assert.equal(start?.runtimeMode, "full-access"); + }), +); + +it.effect("omits model selection options when the agent step has none", () => + Effect.gen(function* () { + const captured: Captured = { + start: yield* Ref.make(null), + send: yield* Ref.make(null), + }; + + yield* Effect.gen(function* () { + const port = yield* ProviderTurnPort; + yield* port.ensureTurnStarted(baseRequest); + }).pipe(Effect.provide(makeLayer(captured))); + + const send = yield* Ref.get(captured.send); + assert.equal(send?.modelSelection?.options, undefined); + }), +); diff --git a/apps/server/src/workflow/Layers/RealStepExecutor.test.ts b/apps/server/src/workflow/Layers/RealStepExecutor.test.ts new file mode 100644 index 00000000000..1124d4f6c04 --- /dev/null +++ b/apps/server/src/workflow/Layers/RealStepExecutor.test.ts @@ -0,0 +1,1328 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { StepExecutionContext } from "../Services/StepExecutor.ts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { CapturedStepOutputReader } from "../Services/CapturedStepOutputReader.ts"; +import { + ProviderDispatchOutbox, + ProviderTurnPort, + type ProviderDispatchTerminalResult, +} from "../Services/ProviderDispatchOutbox.ts"; +import { ProjectScriptTrust } from "../Services/ProjectScriptTrust.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { ScriptCommandRunner, type ScriptCommandResult } from "../Services/ScriptCommandRunner.ts"; +import { SetupRunService } from "../Services/SetupRunService.ts"; +import { StepExecutor } from "../Services/StepExecutor.ts"; +import { TicketCheckpointService } from "../Services/TicketCheckpointService.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorktreePort } from "../Services/WorktreePort.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorktreeLeaseServiceLive } from "./WorktreeLeaseService.ts"; +import { ProviderDispatchOutboxLive } from "./ProviderDispatchOutbox.ts"; +import { RealStepExecutorLive } from "./RealStepExecutor.ts"; +import { ScriptStepExecutorLive } from "./ScriptStepExecutor.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; +import { TicketMergeService } from "../Services/TicketMergeService.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { ticketBaseRef } from "../ticketRefs.ts"; + +const context: StepExecutionContext = { + ticketId: "ticket-1" as never, + boardId: "board-1" as never, + pipelineRunId: "pipeline-run-1" as never, + stepRunId: "step-run-1" as never, + laneEntryToken: "lane-token-1" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: "Implement the ticket", + }, +}; + +const optionSelections = [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, +]; + +const optionsContext: StepExecutionContext = { + ...context, + ticketId: "ticket-options" as never, + stepRunId: "step-run-options" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + options: optionSelections as never, + }, + instruction: "Implement the ticket", + }, +}; + +const fileInstructionContext: StepExecutionContext = { + ...context, + ticketId: "ticket-file-instruction" as never, + stepRunId: "step-run-file-instruction" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: { file: "missing-instruction.md" }, + }, +}; + +const unsafeFileInstructionContext: StepExecutionContext = { + ...context, + ticketId: "ticket-unsafe-file-instruction" as never, + stepRunId: "step-run-unsafe-file-instruction" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: { file: "../t3-unsafe-instruction-escape.md" }, + }, +}; + +const symlinkFileInstructionContext: StepExecutionContext = { + ...context, + ticketId: "ticket-symlink-file-instruction" as never, + stepRunId: "step-run-symlink-file-instruction" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: { file: "symlink-instruction.md" }, + }, +}; + +const normalFileInstructionContext: StepExecutionContext = { + ...context, + ticketId: "ticket-normal-file-instruction" as never, + stepRunId: "step-run-normal-file-instruction" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: { file: "instructions/normal.md" }, + }, +}; + +const canonicalFileInstructionContext: StepExecutionContext = { + ...context, + ticketId: "ticket-canonical-file-instruction" as never, + stepRunId: "step-run-canonical-file-instruction" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: { file: "instructions/link.md" }, + }, +}; + +const templateContext: StepExecutionContext = { + ...context, + ticketId: "ticket-template" as never, + stepRunId: "step-run-template" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: + "Work on {{ticket.title}} ({{ticket.id}}). Diff base: {{ ticket.baseRef }}. Desc:[{{ticket.description}}] Keep {{ticket.unknown}} and {{other}}.", + }, +}; + +const discussionContext: StepExecutionContext = { + ...context, + ticketId: "ticket-discussion" as never, + stepRunId: "step-run-discussion" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: "Implement the ticket", + }, +}; + +const discussionPlaceholderContext: StepExecutionContext = { + ...context, + ticketId: "ticket-discussion-placeholder" as never, + stepRunId: "step-run-discussion-placeholder" as never, + step: { + key: "agent-step" as never, + type: "agent", + agent: { + instance: "codex" as never, + model: "gpt-5.5" as never, + }, + instruction: "Implement the ticket.\nDiscussion:\n{{ticket.discussion}}", + }, +}; + +const scriptContext: StepExecutionContext = { + ...context, + ticketId: "ticket-script" as never, + stepRunId: "step-run-script" as never, + step: { + key: "script-step" as never, + type: "script", + run: "echo ready", + }, +}; + +const captureContext: StepExecutionContext = { + ...context, + ticketId: "ticket-capture" as never, + stepRunId: "step-run-capture" as never, + step: { + ...context.step, + captureOutput: true, + } as never, +}; + +const checkpointCalls: Array = []; +const setupCalls: Array = []; +const capturedReadInputs: Array = []; +const dispatchStartInputs: Array = []; +const preRef = "refs/t3/tickets/dC0x/steps/c3RlcC1ydW4tMQ/pre"; +const postRef = "refs/t3/tickets/dC0x/steps/c3RlcC1ydW4tMQ/post"; + +const mergeServiceCalls: Array = []; +const StubTicketMergeServiceLayer = Layer.succeed(TicketMergeService, { + merge: (input) => + Effect.sync(() => { + mergeServiceCalls.push(input); + return { _tag: "completed" } as const; + }), +}); + +const mergeContext: StepExecutionContext = { + ...context, + ticketId: "ticket-merge-step" as never, + stepRunId: "step-run-merge-step" as never, + step: { + key: "land" as never, + type: "merge", + target: "main" as never, + }, +}; + +const realStepExecutorTestSupport = WorkflowFoundationLive.pipe( + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(ProjectionThreadMessageRepositoryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), +); + +const mk = ( + terminal: ProviderDispatchTerminalResult, + options: { + readonly projectTrusted?: boolean; + readonly scriptCommandResult?: ScriptCommandResult; + readonly fileSystemLayer?: Layer.Layer; + readonly capturedOutputForRead?: (input: { readonly threadId: string }) => unknown; + } = {}, +) => + it.layer( + RealStepExecutorLive.pipe( + Layer.provideMerge( + Layer.succeed(WorktreePort, { + ensureWorktree: () => + Effect.succeed({ + repoRoot: "/tmp/repo-ticket-1", + worktreeRef: "wt-ticket-1", + path: "/tmp/wt-ticket-1", + }), + }), + ), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge( + Layer.succeed(SetupRunService, { + runSetup: (_ticketId, _worktreeRef, worktreePath) => + Effect.sync(() => { + setupCalls.push(worktreePath); + return { status: "completed", exitCode: 0 } as const; + }), + }), + ), + Layer.provideMerge(ScriptStepExecutorLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectScriptTrust, { + isTrusted: () => Effect.succeed(options.projectTrusted ?? true), + setTrusted: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ScriptCommandRunner, { + run: () => + Effect.succeed( + options.scriptCommandResult ?? { outcome: "exited", exitCode: 0, signal: null }, + ), + }), + ), + Layer.provideMerge( + Layer.succeed(TicketCheckpointService, { + hasBaseline: (_ticketId, cwd) => + Effect.sync(() => { + checkpointCalls.push(`hasBaseline:${cwd}`); + return false; + }), + captureBaseline: (_ticketId, cwd) => + Effect.sync(() => { + checkpointCalls.push(`captureBaseline:${cwd}`); + return "refs/t3/tickets/dC0x/base"; + }), + captureStep: (_ticketId, stepRunId, cwd, kind) => + Effect.sync(() => { + checkpointCalls.push(`captureStep:${stepRunId}:${cwd}:${kind}`); + return kind === "pre" ? preRef : postRef; + }), + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderDispatchOutbox, { + confirmStep: () => Effect.void, + ensureStarted: (input) => + Effect.sync(() => { + dispatchStartInputs.push(input); + return { turnId: "turn-stub" as never }; + }), + getDispatchForStep: () => Effect.succeed(null), + awaitTerminal: () => Effect.succeed(terminal), + awaitStepTerminal: () => Effect.succeed(terminal), + recoverPending: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(CapturedStepOutputReader, { + read: (input) => + options.capturedOutputForRead === undefined + ? Effect.void + : Effect.sync(() => + options.capturedOutputForRead?.({ threadId: input.threadId as string }), + ), + }), + ), + Layer.provideMerge(StubTicketMergeServiceLayer), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(realStepExecutorTestSupport), + Layer.provideMerge(options.fileSystemLayer ?? Layer.empty), + Layer.provideMerge(NodeServices.layer), + ), + ); + +const captureLayer = (capturedOutput: unknown | undefined) => + it.layer( + RealStepExecutorLive.pipe( + Layer.provideMerge( + Layer.succeed(WorktreePort, { + ensureWorktree: () => + Effect.succeed({ + repoRoot: "/tmp/repo-ticket-1", + worktreeRef: "wt-ticket-1", + path: "/tmp/wt-ticket-1", + }), + }), + ), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge( + Layer.succeed(SetupRunService, { + runSetup: () => Effect.succeed({ status: "completed", exitCode: 0 }), + }), + ), + Layer.provideMerge(ScriptStepExecutorLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectScriptTrust, { + isTrusted: () => Effect.succeed(true), + setTrusted: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ScriptCommandRunner, { + run: () => Effect.succeed({ outcome: "exited", exitCode: 0, signal: null }), + }), + ), + Layer.provideMerge( + Layer.succeed(TicketCheckpointService, { + hasBaseline: () => Effect.succeed(false), + captureBaseline: () => Effect.succeed("refs/t3/tickets/dC0x/base"), + captureStep: (_ticketId, _stepRunId, _cwd, kind) => + Effect.succeed(kind === "pre" ? preRef : postRef), + }), + ), + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.effect( + ProviderTurnPort, + Effect.gen(function* () { + const turns = yield* ProjectionTurnRepository; + const messages = yield* ProjectionThreadMessageRepository; + return ProviderTurnPort.of({ + ensureTurnStarted: (req) => + Effect.gen(function* () { + yield* turns.upsertByTurnId({ + threadId: req.threadId, + turnId: "turn-capture" as never, + pendingMessageId: null, + sourceProposedPlanThreadId: null, + sourceProposedPlanId: null, + assistantMessageId: "message-capture" as never, + state: "completed", + requestedAt: "2026-06-07T00:00:00.000Z" as never, + startedAt: "2026-06-07T00:00:00.000Z" as never, + completedAt: "2026-06-07T00:00:01.000Z" as never, + checkpointTurnCount: null, + checkpointRef: null, + checkpointStatus: null, + checkpointFiles: [], + }); + yield* messages.upsert({ + messageId: "message-capture" as never, + threadId: req.threadId, + turnId: "turn-capture" as never, + role: "assistant", + text: "unused structured output fixture", + isStreaming: false, + createdAt: "2026-06-07T00:00:01.000Z" as never, + updatedAt: "2026-06-07T00:00:01.000Z" as never, + }); + return { turnId: "turn-capture" as never }; + }).pipe( + Effect.mapError( + (cause) => new WorkflowEventStoreError({ message: "seed turn failed", cause }), + ), + ), + }); + }), + ), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge( + Layer.succeed(CapturedStepOutputReader, { + read: (input) => + Effect.sync(() => { + capturedReadInputs.push(input); + return capturedOutput; + }), + }), + ), + Layer.provideMerge(StubTicketMergeServiceLayer), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(realStepExecutorTestSupport), + Layer.provideMerge(NodeServices.layer), + ), + ); + +const seedBoardAndTicket = (ctx: StepExecutionContext) => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + yield* registry.register(ctx.boardId, { + name: "Executor board", + lanes: [{ key: "impl", name: "Impl", entry: "manual" }], + }); + yield* read.registerBoard({ + boardId: ctx.boardId, + projectId: "project-script" as never, + name: "Script board", + workflowFilePath: ".t3/boards/script.json", + workflowVersionHash: "hash", + maxConcurrentTickets: 1, + }); + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + ${ctx.ticketId}, + ${ctx.boardId}, + 'Executor ticket', + 'impl', + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + ON CONFLICT(ticket_id) DO NOTHING + `; + }); + +const seedTicketMessages = ( + ctx: StepExecutionContext, + messages: ReadonlyArray<{ + readonly author: "agent" | "user"; + readonly body: string; + readonly attachments: number; + }>, +) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* Effect.forEach(messages, (message, index) => { + const attachments = Array.from({ length: message.attachments }, (_, i) => ({ + kind: "image" as const, + id: `attachment-${index}-${i}`, + name: `attachment-${index}-${i}.png`, + mimeType: "image/png" as const, + sizeBytes: 4, + dataUrl: "data:image/png;base64,AAAA", + })); + return sql` + INSERT INTO projection_ticket_message ( + message_id, + ticket_id, + step_run_id, + author, + body, + attachments_json, + created_at + ) + VALUES ( + ${`message-${ctx.ticketId}-${index}`}, + ${ctx.ticketId}, + NULL, + ${message.author}, + ${message.body}, + ${JSON.stringify(attachments)}, + ${`2026-06-07T00:0${index}:00.000Z`} + ) + `; + }); + }); + +const seedStepStartedFor = (ctx: StepExecutionContext, eventId: string) => + Effect.gen(function* () { + const committer = yield* WorkflowEventCommitter; + yield* seedBoardAndTicket(ctx); + yield* committer.commit({ + type: "StepStarted", + eventId: eventId as never, + ticketId: ctx.ticketId, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + pipelineRunId: ctx.pipelineRunId, + stepRunId: ctx.stepRunId, + stepKey: ctx.step.key, + stepType: ctx.step.type, + }, + }); + }); + +const seedStepStarted = seedStepStartedFor(context, "event-step-started"); + +const seedBoard = seedBoardAndTicket(context); + +const assertProjectedStepRefs = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const events = yield* sql<{ readonly type: string }>` + SELECT event_type AS "type" + FROM workflow_events + WHERE ticket_id = ${context.ticketId} + AND event_type = 'StepRefsCaptured' + `; + const rows = yield* sql<{ + readonly preCheckpointRef: string | null; + readonly postCheckpointRef: string | null; + }>` + SELECT + pre_checkpoint_ref AS "preCheckpointRef", + post_checkpoint_ref AS "postCheckpointRef" + FROM projection_step_run + WHERE step_run_id = ${context.stepRunId} + `; + + assert.equal(events.length, 1); + assert.equal(rows[0]?.preCheckpointRef, preRef); + assert.equal(rows[0]?.postCheckpointRef, postRef); +}); + +const seedFileInstructionStepStarted = seedStepStartedFor( + fileInstructionContext, + "event-step-started-file-instruction", +); +const seedUnsafeFileInstructionStepStarted = seedStepStartedFor( + unsafeFileInstructionContext, + "event-step-started-unsafe-file-instruction", +); +const seedSymlinkFileInstructionStepStarted = seedStepStartedFor( + symlinkFileInstructionContext, + "event-step-started-symlink-file-instruction", +); +const seedNormalFileInstructionStepStarted = seedStepStartedFor( + normalFileInstructionContext, + "event-step-started-normal-file-instruction", +); +const seedCanonicalFileInstructionStepStarted = seedStepStartedFor( + canonicalFileInstructionContext, + "event-step-started-canonical-file-instruction", +); + +const canonicalInstructionReadPaths: string[] = []; +const CanonicalInstructionFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return { + ...fileSystem, + realPath: (filePath) => + Effect.sync(() => { + const value = String(filePath); + if (value === "/tmp/repo-ticket-1/instructions/link.md") { + return "/tmp/repo-ticket-1/instructions/target.md"; + } + return value; + }), + readFileString: (filePath) => + Effect.sync(() => { + const value = String(filePath); + canonicalInstructionReadPaths.push(value); + return value === "/tmp/repo-ticket-1/instructions/target.md" + ? "Canonical instruction" + : "Original path instruction"; + }), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +mk({ ok: true })("RealStepExecutor success", (it) => { + it.effect("completes an agent step and releases the worktree lease", () => + Effect.gen(function* () { + checkpointCalls.length = 0; + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + yield* seedStepStarted; + + const outcome = yield* executor.execute(context); + + assert.equal(outcome._tag, "completed"); + const rows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-ticket-1' + `; + assert.equal(rows[0]?.ownerKind, "released"); + assert.deepEqual(checkpointCalls, [ + "hasBaseline:/tmp/wt-ticket-1", + "captureBaseline:/tmp/wt-ticket-1", + "captureStep:step-run-1:/tmp/wt-ticket-1:pre", + "captureStep:step-run-1:/tmp/wt-ticket-1:post", + ]); + yield* assertProjectedStepRefs; + }), + ); + + it.effect("runs merge steps through the merge service without project setup", () => + Effect.gen(function* () { + mergeServiceCalls.length = 0; + setupCalls.length = 0; + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + yield* seedStepStartedFor(mergeContext, "event-step-started-merge-step"); + + const outcome = yield* executor.execute(mergeContext); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.deepEqual(setupCalls, []); + assert.equal(mergeServiceCalls.length, 1); + const call = mergeServiceCalls[0] as { + readonly repoRoot: string; + readonly worktreeRef: string; + readonly step: { readonly target?: string }; + }; + assert.equal(call.repoRoot, "/tmp/repo-ticket-1"); + assert.equal(call.worktreeRef, "wt-ticket-1"); + assert.equal(call.step.target, "main"); + const rows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-ticket-1' + `; + assert.equal(rows[0]?.ownerKind, "released"); + }), + ); + + it.effect("blocks agent steps once the ticket's token budget is reached", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + const budgetContext = { + ...context, + ticketId: "ticket-budget" as never, + stepRunId: "step-run-budget" as never, + }; + yield* seedStepStartedFor(budgetContext, "event-step-started-budget"); + yield* sql` + UPDATE projection_ticket + SET token_budget = 1000 + WHERE ticket_id = ${budgetContext.ticketId} + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, pipeline_run_id, ticket_id, step_key, step_type, + status, started_at, finished_at, total_tokens + ) + VALUES ( + 'step-run-budget-spent', 'pipeline-budget', ${budgetContext.ticketId}, 'prior', 'agent', + 'completed', '2026-06-07T00:00:00.000Z', '2026-06-07T00:01:00.000Z', 1500 + ) + `; + + const outcome = yield* executor.execute(budgetContext); + + assert.equal(outcome._tag, "blocked"); + if (outcome._tag === "blocked") { + assert.include(outcome.reason, "token budget reached"); + assert.include(outcome.reason, "1,500"); + assert.include(outcome.reason, "1,000"); + } + // No provider dispatch may have started. + assert.equal(dispatchStartInputs.length, 0); + }), + ); + + it.effect("substitutes ticket template placeholders into the dispatched instruction", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor(templateContext, "event-step-started-template"); + + const outcome = yield* executor.execute(templateContext); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + assert.equal( + dispatched.instruction, + `Work on Executor ticket (ticket-template). Diff base: ${ticketBaseRef( + "ticket-template" as never, + )}. Desc:[] Keep {{ticket.unknown}} and {{other}}.`, + ); + }), + ); + + it.effect("appends the ticket discussion to the dispatched instruction", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor(discussionContext, "event-step-started-discussion"); + yield* seedTicketMessages(discussionContext, [ + { author: "user", body: "Use the existing retry helper", attachments: 0 }, + { author: "agent", body: "Understood", attachments: 1 }, + ]); + + const outcome = yield* executor.execute(discussionContext); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + assert.match(dispatched.instruction, /^Implement the ticket\n\n## Ticket discussion\n\n/); + assert.include(dispatched.instruction, "### User — "); + assert.include(dispatched.instruction, "Use the existing retry helper"); + assert.include(dispatched.instruction, "### Agent — "); + assert.include(dispatched.instruction, "[1 attachment omitted]"); + }), + ); + + it.effect("substitutes the discussion placeholder without appending a second section", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor( + discussionPlaceholderContext, + "event-step-started-discussion-placeholder", + ); + yield* seedTicketMessages(discussionPlaceholderContext, [ + { author: "user", body: "Ship it", attachments: 0 }, + ]); + + const outcome = yield* executor.execute(discussionPlaceholderContext); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + assert.match(dispatched.instruction, /^Implement the ticket\.\nDiscussion:\n### User — /); + assert.include(dispatched.instruction, "Ship it"); + assert.notInclude(dispatched.instruction, "## Ticket discussion"); + assert.notInclude(dispatched.instruction, "{{ticket.discussion}}"); + }), + ); + + it.effect("substitutes an empty-discussion marker when there are no messages", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor( + { ...discussionPlaceholderContext, ticketId: "ticket-discussion-empty" as never }, + "event-step-started-discussion-empty", + ); + + const outcome = yield* executor.execute({ + ...discussionPlaceholderContext, + ticketId: "ticket-discussion-empty" as never, + }); + + assert.equal(outcome._tag, "completed"); + const dispatched = dispatchStartInputs[0] as { readonly instruction: string }; + assert.include(dispatched.instruction, "Discussion:\n(no discussion yet)"); + }), + ); + + it.effect("runs a trusted script step through the shared prepared worktree path", () => + Effect.gen(function* () { + checkpointCalls.length = 0; + setupCalls.length = 0; + const fileSystem = yield* FileSystem.FileSystem; + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + yield* fileSystem.makeDirectory("/tmp/wt-ticket-1", { recursive: true }); + yield* seedBoard; + yield* seedStepStartedFor(scriptContext, "event-step-started-script"); + + const outcome = yield* executor.execute(scriptContext); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.deepEqual(setupCalls, ["/tmp/wt-ticket-1"]); + const rows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-ticket-1' + `; + assert.equal(rows[0]?.ownerKind, "released"); + assert.deepEqual(checkpointCalls, [ + "hasBaseline:/tmp/wt-ticket-1", + "captureBaseline:/tmp/wt-ticket-1", + "captureStep:step-run-script:/tmp/wt-ticket-1:pre", + "captureStep:step-run-script:/tmp/wt-ticket-1:post", + ]); + }), + ); + + it.effect("releases the worktree lease when instruction file resolution fails", () => + Effect.gen(function* () { + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + yield* seedFileInstructionStepStarted; + + const outcome = yield* executor.execute(fileInstructionContext); + + assert.equal(outcome._tag, "failed"); + assert.match((outcome as { readonly error: string }).error, /^executor error: /); + const rows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-ticket-1' + `; + assert.equal(rows[0]?.ownerKind, "released"); + }), + ); + + it.effect("fails unsafe instruction file paths without reading escaped files", () => + Effect.gen(function* () { + const escapePath = "/tmp/t3-unsafe-instruction-escape.md"; + const fileSystem = yield* FileSystem.FileSystem; + const executor = yield* StepExecutor; + yield* fileSystem.writeFileString(escapePath, "Escaped instruction"); + yield* seedUnsafeFileInstructionStepStarted; + + const outcome = yield* executor.execute(unsafeFileInstructionContext); + + assert.equal(outcome._tag, "failed"); + assert.match((outcome as { readonly error: string }).error, /^executor error: /); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem + .remove("/tmp/t3-unsafe-instruction-escape.md") + .pipe(Effect.catch(() => Effect.void)); + }), + ), + ), + ); + + it.effect("fails symlinked instruction files that resolve outside the repo root", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const repoRoot = "/tmp/repo-ticket-1"; + const escapePath = "/tmp/t3-symlink-instruction-escape.md"; + const fileSystem = yield* FileSystem.FileSystem; + const executor = yield* StepExecutor; + yield* fileSystem.makeDirectory(repoRoot, { recursive: true }); + yield* fileSystem.writeFileString(escapePath, "Escaped symlink instruction"); + yield* fileSystem.symlink(escapePath, `${repoRoot}/symlink-instruction.md`); + yield* seedSymlinkFileInstructionStepStarted; + + const outcome = yield* executor.execute(symlinkFileInstructionContext); + + assert.deepEqual(outcome, { + _tag: "failed", + error: 'Instruction file resolves outside the project root: "symlink-instruction.md"', + }); + assert.deepEqual(dispatchStartInputs, []); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem + .remove("/tmp/repo-ticket-1/symlink-instruction.md") + .pipe(Effect.catch(() => Effect.void)); + yield* fileSystem + .remove("/tmp/t3-symlink-instruction-escape.md") + .pipe(Effect.catch(() => Effect.void)); + }), + ), + ), + ); + + it.effect("forwards agent option selections to the provider dispatch", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor(optionsContext, "event-step-started-options"); + + const outcome = yield* executor.execute(optionsContext); + + assert.equal(outcome._tag, "completed"); + assert.deepEqual( + (dispatchStartInputs[0] as { readonly options?: unknown } | undefined)?.options, + optionSelections, + ); + }), + ); + + it.effect("reads normal instruction files that resolve inside the repo root", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + const repoRoot = "/tmp/repo-ticket-1"; + const instructionPath = `${repoRoot}/instructions/normal.md`; + const fileSystem = yield* FileSystem.FileSystem; + const executor = yield* StepExecutor; + yield* fileSystem.makeDirectory(`${repoRoot}/instructions`, { recursive: true }); + yield* fileSystem.writeFileString(instructionPath, "Normal in-repo instruction"); + yield* seedNormalFileInstructionStepStarted; + + const outcome = yield* executor.execute(normalFileInstructionContext); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.equal( + (dispatchStartInputs[0] as { readonly instruction?: string } | undefined)?.instruction, + "Normal in-repo instruction", + ); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem + .remove("/tmp/repo-ticket-1/instructions/normal.md") + .pipe(Effect.catch(() => Effect.void)); + }), + ), + ), + ); +}); + +mk({ ok: true }, { fileSystemLayer: CanonicalInstructionFileSystemLayer })( + "RealStepExecutor canonical instruction read", + (it) => { + it.effect("reads the canonical real instruction path after validation", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + canonicalInstructionReadPaths.length = 0; + const executor = yield* StepExecutor; + yield* seedCanonicalFileInstructionStepStarted; + + const outcome = yield* executor.execute(canonicalFileInstructionContext); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.deepEqual(canonicalInstructionReadPaths, [ + "/tmp/repo-ticket-1/instructions/target.md", + ]); + assert.equal( + (dispatchStartInputs[0] as { readonly instruction?: string } | undefined)?.instruction, + "Canonical instruction", + ); + }), + ); + }, +); + +captureLayer({ verdict: "pass", score: 0.98 })("RealStepExecutor output capture", (it) => { + it.effect("appends the capture instruction, persists it, and returns the last JSON block", () => + Effect.gen(function* () { + capturedReadInputs.length = 0; + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + yield* seedStepStartedFor(captureContext, "event-step-started-capture"); + + const outcome = yield* executor.execute(captureContext); + + assert.deepEqual(outcome, { + _tag: "completed", + output: { verdict: "pass", score: 0.98 }, + }); + + const rows = yield* sql<{ readonly instruction: string }>` + SELECT instruction + FROM workflow_dispatch_outbox + WHERE step_run_id = ${captureContext.stepRunId} + `; + assert.include(rows[0]?.instruction ?? "", "Implement the ticket"); + assert.include( + rows[0]?.instruction ?? "", + "End your final message with a single fenced ```json block containing your result object.", + ); + }), + ); + + it.effect("passes the exact started thread and turn to the output reader", () => + Effect.gen(function* () { + capturedReadInputs.length = 0; + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + yield* seedStepStartedFor(captureContext, "event-step-started-capture-exact-turn"); + + const outcome = yield* executor.execute(captureContext); + + assert.equal(outcome._tag, "completed"); + const rows = yield* sql<{ readonly threadId: string; readonly turnId: string | null }>` + SELECT thread_id AS "threadId", turn_id AS "turnId" + FROM workflow_dispatch_outbox + WHERE step_run_id = ${captureContext.stepRunId} + `; + const capturedInput = capturedReadInputs[0] as + | { readonly stepRunId: string; readonly threadId: string; readonly turnId: string | null } + | undefined; + const row = rows.find((candidate) => candidate.threadId === capturedInput?.threadId); + assert.deepEqual(capturedReadInputs, [ + { + stepRunId: captureContext.stepRunId, + threadId: row?.threadId, + turnId: row?.turnId, + }, + ]); + }), + ); +}); + +captureLayer(undefined)("RealStepExecutor missing output capture", (it) => { + it.effect("fails a captureOutput step when the assistant message has no valid JSON block", () => + Effect.gen(function* () { + const executor = yield* StepExecutor; + yield* seedStepStartedFor(captureContext, "event-step-started-capture-missing"); + + const outcome = yield* executor.execute(captureContext); + + assert.deepEqual(outcome, { + _tag: "failed", + error: "missing or invalid structured output", + }); + }), + ); +}); + +const panelVerdictQueue: unknown[] = []; +mk( + { ok: true }, + { + capturedOutputForRead: () => panelVerdictQueue.shift(), + }, +)("RealStepExecutor review panel", (it) => { + it.effect("takes the strict-majority verdict across panel reviewers", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + panelVerdictQueue.length = 0; + panelVerdictQueue.push( + { verdict: "approve", notes: "ok" }, + { verdict: "revise" }, + { verdict: "approve" }, + ); + const executor = yield* StepExecutor; + const panelContext: StepExecutionContext = { + ...context, + ticketId: "ticket-panel" as never, + stepRunId: "step-run-panel" as never, + step: { + key: "review" as never, + type: "agent", + agent: { instance: "codex" as never, model: "gpt-5.5" as never }, + instruction: "Review the work", + captureOutput: true, + panel: 3, + } as never, + }; + yield* seedStepStartedFor(panelContext, "event-step-started-panel"); + + const outcome = yield* executor.execute(panelContext); + + assert.equal(outcome._tag, "completed"); + if (outcome._tag === "completed") { + const output = outcome.output as { + readonly verdict: string; + readonly votes: ReadonlyArray<{ readonly verdict: string | null }>; + }; + assert.equal(output.verdict, "approve"); + assert.equal(output.votes.length, 3); + assert.deepEqual( + output.votes.map((vote) => vote.verdict), + ["approve", "revise", "approve"], + ); + } + assert.equal(dispatchStartInputs.length, 3); + const titles = dispatchStartInputs.map( + (input) => (input as { readonly threadTitle?: string }).threadTitle ?? "", + ); + assert.isTrue(titles.some((title) => title.includes("reviewer 1/3"))); + // Each member must run on its own dispatch thread. + const threads = new Set( + dispatchStartInputs.map((input) => (input as { readonly threadId: string }).threadId), + ); + assert.equal(threads.size, 3); + }), + ); + + it.effect("fails without a strict majority", () => + Effect.gen(function* () { + dispatchStartInputs.length = 0; + panelVerdictQueue.length = 0; + panelVerdictQueue.push({ verdict: "approve" }, { verdict: "revise" }); + const executor = yield* StepExecutor; + const panelContext: StepExecutionContext = { + ...context, + ticketId: "ticket-panel-split" as never, + stepRunId: "step-run-panel-split" as never, + step: { + key: "review" as never, + type: "agent", + agent: { instance: "codex" as never, model: "gpt-5.5" as never }, + instruction: "Review the work", + captureOutput: true, + panel: 2, + } as never, + }; + yield* seedStepStartedFor(panelContext, "event-step-started-panel-split"); + + const outcome = yield* executor.execute(panelContext); + + assert.equal(outcome._tag, "failed"); + if (outcome._tag === "failed") { + assert.include(outcome.error, "did not reach a majority"); + } + }), + ); +}); + +mk({ ok: true }, { projectTrusted: false })("RealStepExecutor untrusted script", (it) => { + it.effect("blocks before setup, lease, checkpoints, or command execution", () => + Effect.gen(function* () { + checkpointCalls.length = 0; + setupCalls.length = 0; + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + yield* seedBoard; + yield* seedStepStartedFor(scriptContext, "event-step-started-untrusted-script"); + + const outcome = yield* executor.execute(scriptContext); + + assert.deepEqual(outcome, { + _tag: "blocked", + reason: "Project not trusted to run scripts", + }); + assert.deepEqual(setupCalls, []); + assert.deepEqual(checkpointCalls, [ + "hasBaseline:/tmp/wt-ticket-1", + "captureBaseline:/tmp/wt-ticket-1", + ]); + const rows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-ticket-1' + `; + assert.deepEqual(rows, []); + }), + ); +}); + +mk({ ok: false, error: "provider failed" })("RealStepExecutor failure", (it) => { + it.effect("fails an agent step when provider dispatch fails", () => + Effect.gen(function* () { + checkpointCalls.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStarted; + + const outcome = yield* executor.execute(context); + + assert.deepEqual(outcome, { _tag: "failed", error: "provider failed" }); + assert.deepEqual(checkpointCalls, [ + "hasBaseline:/tmp/wt-ticket-1", + "captureBaseline:/tmp/wt-ticket-1", + "captureStep:step-run-1:/tmp/wt-ticket-1:pre", + "captureStep:step-run-1:/tmp/wt-ticket-1:post", + ]); + yield* assertProjectedStepRefs; + }), + ); +}); + +const preCheckpointFailureLayer = it.layer( + RealStepExecutorLive.pipe( + Layer.provideMerge( + Layer.succeed(WorktreePort, { + ensureWorktree: () => + Effect.succeed({ + repoRoot: "/tmp/repo-ticket-1", + worktreeRef: "wt-ticket-1", + path: "/tmp/wt-ticket-1", + }), + }), + ), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge( + Layer.succeed(SetupRunService, { + runSetup: () => Effect.succeed({ status: "completed", exitCode: 0 }), + }), + ), + Layer.provideMerge(ScriptStepExecutorLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectScriptTrust, { + isTrusted: () => Effect.succeed(true), + setTrusted: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ScriptCommandRunner, { + run: () => Effect.succeed({ outcome: "exited", exitCode: 0, signal: null }), + }), + ), + Layer.provideMerge( + Layer.succeed(TicketCheckpointService, { + hasBaseline: () => Effect.succeed(true), + captureBaseline: () => Effect.succeed("refs/t3/tickets/dC0x/base"), + captureStep: (_ticketId, _stepRunId, _cwd, kind) => + kind === "pre" + ? Effect.fail(new WorkflowEventStoreError({ message: "pre checkpoint failed" })) + : Effect.succeed(postRef), + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderDispatchOutbox, { + confirmStep: () => Effect.void, + ensureStarted: () => Effect.succeed({ turnId: "turn-stub" as never }), + getDispatchForStep: () => Effect.succeed(null), + awaitTerminal: () => Effect.succeed({ ok: true }), + awaitStepTerminal: () => Effect.succeed({ ok: true }), + recoverPending: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(CapturedStepOutputReader, { + read: () => Effect.void, + }), + ), + Layer.provideMerge(Layer.mergeAll(StubTicketMergeServiceLayer, WorkflowEventCommitterLive)), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(ProjectionThreadMessageRepositoryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), + ), +); + +preCheckpointFailureLayer("RealStepExecutor pre-dispatch failure", (it) => { + it.effect("releases the worktree lease when pre-step checkpoint capture fails", () => + Effect.gen(function* () { + const executor = yield* StepExecutor; + const sql = yield* SqlClient.SqlClient; + yield* seedStepStarted; + + const outcome = yield* executor.execute(context); + + assert.equal(outcome._tag, "failed"); + assert.match((outcome as { readonly error: string }).error, /^executor error: /); + const rows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-ticket-1' + `; + assert.equal(rows[0]?.ownerKind, "released"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/RealStepExecutor.ts b/apps/server/src/workflow/Layers/RealStepExecutor.ts new file mode 100644 index 00000000000..296f6bceef0 --- /dev/null +++ b/apps/server/src/workflow/Layers/RealStepExecutor.ts @@ -0,0 +1,665 @@ +import { + TrimmedNonEmptyString, + type ProjectId, + type StepOutcome, + type TurnId, + type WorkflowStepUsage, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { CapturedStepOutputReader } from "../Services/CapturedStepOutputReader.ts"; +import { ProjectScriptTrust } from "../Services/ProjectScriptTrust.ts"; +import { + ProviderDispatchOutbox, + type ProviderDispatchTerminalResult, +} from "../Services/ProviderDispatchOutbox.ts"; +import { ScriptStepExecutor } from "../Services/ScriptStepExecutor.ts"; +import { SetupRunService } from "../Services/SetupRunService.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { StepUsageReader } from "../Services/StepUsageReader.ts"; +import { TicketCheckpointService } from "../Services/TicketCheckpointService.ts"; +import { TicketMergeService } from "../Services/TicketMergeService.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorktreeLeaseService } from "../Services/WorktreeLeaseService.ts"; +import { + WorktreePort, + type WorktreeHandle, + type WorktreePortShape, +} from "../Services/WorktreePort.ts"; +import { + containsRealPath, + resolveWorkflowInstructionPath, + unsafeWorkflowInstructionPathMessage, +} from "../instructionPath.ts"; +import { + applyInstructionTemplate, + DISCUSSION_MESSAGE_CAP, + hasDiscussionPlaceholder, + renderTicketDiscussion, +} from "../instructionTemplate.ts"; +import { ticketBaseRef } from "../ticketRefs.ts"; + +const toExecutorError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrapSql = (message: string, effect: Effect.Effect) => + effect.pipe(Effect.mapError(toExecutorError(message))); + +const executorErrorDetail = (error: unknown): string => { + if (typeof error === "object" && error !== null) { + const candidate = error as { readonly message?: unknown; readonly cause?: unknown }; + const message = typeof candidate.message === "string" ? candidate.message : String(error); + const cause = + typeof candidate.cause === "object" && candidate.cause !== null + ? (candidate.cause as { readonly message?: unknown }) + : null; + return typeof cause?.message === "string" && cause.message.length > 0 + ? `${message}: ${cause.message}` + : message; + } + return String(error); +}; + +const CAPTURE_OUTPUT_INSTRUCTION = + "End your final message with a single fenced ```json block containing your result object. " + + "This requirement overrides any skill, workflow, or output format your other instructions ask for — " + + "whatever else you produce, the fenced json block must be the last thing you write."; + +const appendCaptureOutputInstruction = (instruction: string) => + `${instruction.trimEnd()}\n\n${CAPTURE_OUTPUT_INSTRUCTION}`; + +interface TicketProjectRow { + readonly repoRoot: string; + readonly projectId: string; +} + +const make = Effect.gen(function* () { + const worktrees = yield* WorktreePort; + const lease = yield* WorktreeLeaseService; + const setup = yield* SetupRunService; + const dispatch = yield* ProviderDispatchOutbox; + const ids = yield* WorkflowIds; + const read = yield* WorkflowReadModel; + const scriptExecutor = yield* ScriptStepExecutor; + const scriptTrust = yield* ProjectScriptTrust; + const capturedOutputs = yield* CapturedStepOutputReader; + const merges = yield* TicketMergeService; + const ticketCheckpoints = yield* TicketCheckpointService; + const committer = yield* WorkflowEventCommitter; + const fileSystem = yield* FileSystem.FileSystem; + // Optional: token-usage capture is best-effort telemetry, absent in older + // test stacks. + const usageReader = Context.getOption( + (yield* Effect.context()) as Context.Context, + StepUsageReader, + ); + const readStepUsage = (threadId: string) => + Option.isNone(usageReader) + ? Effect.succeed(undefined) + : usageReader.value.read(threadId as never); + + const prepareWorktreeStep = ( + ctx: Parameters[0], + body: (worktree: WorktreeHandle) => Effect.Effect, + options?: { + readonly preSetupGuard?: ( + worktree: WorktreeHandle, + ) => Effect.Effect; + readonly skipSetup?: boolean; + }, + ) => + Effect.gen(function* () { + const worktree = yield* worktrees.ensureWorktree(ctx.ticketId); + const hasBaseline = yield* ticketCheckpoints.hasBaseline(ctx.ticketId, worktree.path); + if (!hasBaseline) { + yield* ticketCheckpoints.captureBaseline(ctx.ticketId, worktree.path); + } + + const guarded = yield* options?.preSetupGuard?.(worktree) ?? Effect.succeed(null); + if (guarded !== null) { + return guarded; + } + + if (options?.skipSetup !== true) { + const setupRunId = yield* ids.eventId(); + const setupResult = yield* setup.runSetup( + ctx.ticketId, + worktree.worktreeRef, + worktree.path, + setupRunId as never, + worktree.projectId, + ); + if (setupResult.status !== "completed") { + return { _tag: "failed", error: `setup ${setupResult.status}` } satisfies StepOutcome; + } + } + + const acquired = yield* lease.acquire(worktree.worktreeRef, "step", ctx.stepRunId as string); + const releaseIfStillOwner = lease.isValid(worktree.worktreeRef, acquired.fenceToken).pipe( + Effect.flatMap((valid) => + valid ? lease.release(worktree.worktreeRef, acquired.fenceToken) : Effect.void, + ), + Effect.orElseSucceed(() => undefined), + ); + + const result = yield* Effect.gen(function* () { + const preRef = yield* ticketCheckpoints.captureStep( + ctx.ticketId, + ctx.stepRunId, + worktree.path, + "pre", + ); + const bodyExit = yield* body(worktree).pipe(Effect.exit); + const postRef = yield* ticketCheckpoints.captureStep( + ctx.ticketId, + ctx.stepRunId, + worktree.path, + "post", + ); + const eventId = yield* ids.eventId(); + const occurredAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + yield* committer.commit({ + type: "StepRefsCaptured", + eventId: eventId as never, + ticketId: ctx.ticketId, + occurredAt: occurredAt as never, + payload: { stepRunId: ctx.stepRunId, preRef, postRef }, + }); + if (Exit.isFailure(bodyExit)) { + return yield* Effect.failCause(bodyExit.cause); + } + return bodyExit.value; + }).pipe(Effect.ensuring(releaseIfStillOwner)); + + return result; + }); + + const providerServiceOption = Effect.serviceOption(ProviderService); + + const cleanupPanelMemberSession = (threadId: string, turnId: TurnId) => + Effect.gen(function* () { + const provider = yield* providerServiceOption; + if (Option.isNone(provider)) { + return; + } + yield* provider.value + .interruptTurn({ threadId: threadId as never, turnId: turnId as never }) + .pipe(Effect.catch(() => Effect.void)); + yield* provider.value + .stopSession({ threadId: threadId as never }) + .pipe(Effect.catch(() => Effect.void)); + }); + + const sumUsage = ( + total: WorkflowStepUsage | undefined, + next: WorkflowStepUsage | undefined, + ): WorkflowStepUsage | undefined => { + if (next === undefined) { + return total; + } + if (total === undefined) { + return next; + } + const add = (a: number | undefined, b: number | undefined) => + a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0); + return { + ...(add(total.inputTokens, next.inputTokens) === undefined + ? {} + : { inputTokens: add(total.inputTokens, next.inputTokens) }), + ...(add(total.cachedInputTokens, next.cachedInputTokens) === undefined + ? {} + : { cachedInputTokens: add(total.cachedInputTokens, next.cachedInputTokens) }), + ...(add(total.outputTokens, next.outputTokens) === undefined + ? {} + : { outputTokens: add(total.outputTokens, next.outputTokens) }), + ...(add(total.totalTokens, next.totalTokens) === undefined + ? {} + : { totalTokens: add(total.totalTokens, next.totalTokens) }), + }; + }; + + const verdictOf = (output: unknown): string | null => { + if (typeof output !== "object" || output === null || Array.isArray(output)) { + return null; + } + const verdict = (output as Record)["verdict"]; + return typeof verdict === "string" ? verdict : null; + }; + + // Fan out `panelSize` independent turns of the same review step and take + // the strict-majority verdict. A member that fails, stalls on a question, + // or returns unusable output simply contributes no vote; without a strict + // majority the step fails (never silently picks a side). + const runReviewPanel = ( + ctx: Parameters[0], + step: Extract[0]["step"], { readonly type: "agent" }>, + panelSize: number, + runTurn: ( + turnIds: { readonly dispatchId: string; readonly threadId: string }, + titleSuffix: string, + ) => Effect.Effect< + { + readonly terminal: ProviderDispatchTerminalResult; + readonly turnId: TurnId; + readonly threadId: string; + }, + WorkflowEventStoreError + >, + ) => + Effect.gen(function* () { + const memberIds = yield* Effect.forEach( + Array.from({ length: panelSize }, (_, index) => index), + () => + Effect.all({ + dispatchId: ids.eventId().pipe(Effect.map((id) => id as string)), + threadId: ids.eventId().pipe(Effect.map((id) => id as string)), + }), + ); + // Members run sequentially: they share the ticket worktree, and two + // concurrent full-access agents in one tree can corrupt each other's + // view. Review steps are read-mostly, so serial members are safe even + // if one misbehaves and writes. + const members = yield* Effect.all( + memberIds.map((turnIds, index) => + runTurn(turnIds, ` (reviewer ${index + 1}/${panelSize})`), + ), + { concurrency: 1 }, + ); + + let usage: WorkflowStepUsage | undefined; + const votes: Array<{ + readonly reviewer: number; + readonly verdict: string | null; + readonly output: unknown; + readonly error?: string; + }> = []; + for (const [index, member] of members.entries()) { + usage = sumUsage(usage, yield* readStepUsage(member.threadId)); + if (!member.terminal.ok) { + votes.push({ + reviewer: index + 1, + verdict: null, + output: null, + error: + "awaitingUser" in member.terminal + ? "reviewer asked a question" + : (member.terminal.error ?? "turn failed"), + }); + continue; + } + const output = yield* capturedOutputs.read({ + stepRunId: ctx.stepRunId, + threadId: member.threadId as never, + turnId: member.turnId, + }); + votes.push({ + reviewer: index + 1, + verdict: verdictOf(output), + output: output ?? null, + }); + } + + // A member that stalled on a question (or failed mid-turn) leaves a + // live provider session and an unconfirmed outbox row nobody is meant + // to answer — stop the session and settle every member row so restart + // recovery never re-monitors a decided panel. + for (const member of members) { + if (member.terminal.ok) { + continue; + } + yield* cleanupPanelMemberSession(member.threadId, member.turnId).pipe( + Effect.catch(() => Effect.void), + ); + } + yield* dispatch.confirmStep(ctx.stepRunId).pipe(Effect.catch(() => Effect.void)); + + const counts = new Map(); + for (const vote of votes) { + if (vote.verdict !== null) { + counts.set(vote.verdict, (counts.get(vote.verdict) ?? 0) + 1); + } + } + let winner: string | null = null; + let winnerCount = 0; + for (const [verdict, count] of counts) { + if (count > winnerCount) { + winner = verdict; + winnerCount = count; + } + } + if (winner !== null && winnerCount * 2 > panelSize) { + return { + _tag: "completed", + output: { verdict: winner, votes }, + ...(usage === undefined ? {} : { usage }), + } satisfies StepOutcome; + } + return { + _tag: "failed", + error: `review panel did not reach a majority (${votes + .map((vote) => vote.verdict ?? "no vote") + .join(", ")})`, + ...(usage === undefined ? {} : { usage }), + } satisfies StepOutcome; + }); + + const executeAgentStep = ( + ctx: Parameters[0], + worktree: WorktreeHandle, + step: Extract[0]["step"], { readonly type: "agent" }>, + ) => + Effect.gen(function* () { + // Budget gate: once the ticket's usage roll-up reaches its budget, no + // further provider turns start — the step blocks (not fails) so a human + // can raise the budget or move the ticket on. + const budgetDetail = yield* read.getTicketDetail(ctx.ticketId); + const tokenBudget = budgetDetail?.ticket.tokenBudget; + const usedTokens = budgetDetail?.ticket.totalTokens ?? 0; + if (typeof tokenBudget === "number" && usedTokens >= tokenBudget) { + return { + _tag: "blocked", + reason: `token budget reached (${usedTokens.toLocaleString("en-US")} of ${tokenBudget.toLocaleString("en-US")} tokens used)`, + } satisfies StepOutcome; + } + const dispatchId = yield* ids.eventId(); + const threadId = yield* ids.eventId(); + const resolvedInstruction = yield* Effect.gen(function* () { + if (typeof step.instruction === "string") { + return step.instruction; + } + + const instructionFile = step.instruction.file; + const instructionPath = resolveWorkflowInstructionPath(worktree.repoRoot, instructionFile); + if (instructionPath === null) { + return yield* new WorkflowEventStoreError({ + message: unsafeWorkflowInstructionPathMessage(instructionFile), + }); + } + + const realRepoRoot = yield* fileSystem + .realPath(worktree.repoRoot) + .pipe(Effect.mapError(toExecutorError("instruction file realpath check failed"))); + const realInstructionPath = yield* fileSystem + .realPath(instructionPath) + .pipe(Effect.mapError(toExecutorError("instruction file realpath check failed"))); + if (!containsRealPath(realRepoRoot, realInstructionPath)) { + return yield* Effect.succeed({ + _tag: "failed", + error: `Instruction file resolves outside the project root: "${instructionFile}"`, + } satisfies StepOutcome); + } + + return yield* fileSystem + .readFileString(realInstructionPath) + .pipe(Effect.mapError(toExecutorError("instruction file read failed"))); + }); + if (typeof resolvedInstruction !== "string") { + return resolvedInstruction; + } + // Attachment-count-only query capped one past the renderer's message + // budget, so long threads never decode attachment data URLs here. + const discussion = renderTicketDiscussion( + yield* read.listTicketDiscussion(ctx.ticketId, DISCUSSION_MESSAGE_CAP + 1), + ); + const templatedInstruction = resolvedInstruction.includes("{{") + ? yield* Effect.gen(function* () { + const detail = yield* read.getTicketDetail(ctx.ticketId); + return applyInstructionTemplate(resolvedInstruction, { + title: detail?.ticket.title ?? "", + description: detail?.ticket.description ?? "", + id: ctx.ticketId as string, + baseRef: ticketBaseRef(ctx.ticketId), + discussion: discussion === "" ? "(no discussion yet)" : discussion, + }); + }) + : resolvedInstruction; + // Comments always reach the next agent step: unless the instruction + // already placed the transcript via {{ticket.discussion}}, append it. + const instructionWithDiscussion = + discussion !== "" && !hasDiscussionPlaceholder(resolvedInstruction) + ? `${templatedInstruction}\n\n## Ticket discussion\n\n${discussion}` + : templatedInstruction; + const instruction = + step.captureOutput === true + ? appendCaptureOutputInstruction(instructionWithDiscussion) + : instructionWithDiscussion; + const runTurn = ( + turnIds: { readonly dispatchId: string; readonly threadId: string }, + titleSuffix: string, + ) => + Effect.gen(function* () { + const started = yield* dispatch.ensureStarted({ + dispatchId: turnIds.dispatchId as never, + ticketId: ctx.ticketId, + stepRunId: ctx.stepRunId, + threadId: turnIds.threadId as never, + providerInstance: step.agent.instance as string, + model: step.agent.model as string, + instruction, + worktreePath: worktree.path, + ...(step.agent.options === undefined ? {} : { options: step.agent.options }), + ...(worktree.projectId === undefined ? {} : { projectId: worktree.projectId }), + threadTitle: `Workflow step ${step.key}${titleSuffix} · ${ctx.ticketId}`, + }); + const terminal = yield* dispatch.awaitTerminal( + turnIds.dispatchId as never, + turnIds.threadId as never, + ); + return { terminal, turnId: started.turnId, threadId: turnIds.threadId }; + }); + + const panelSize = step.panel ?? 0; + if (panelSize >= 2 && step.captureOutput === true) { + return yield* runReviewPanel(ctx, step, panelSize, runTurn); + } + + const result = yield* runTurn( + { dispatchId: dispatchId as string, threadId: threadId as string }, + "", + ); + + if (result.terminal.ok) { + const usage = yield* readStepUsage(threadId as string); + if (step.captureOutput === true) { + const output = yield* capturedOutputs.read({ + stepRunId: ctx.stepRunId, + threadId: threadId as never, + turnId: result.turnId, + }); + if (output === undefined) { + return { + _tag: "failed", + error: "missing or invalid structured output", + ...(usage === undefined ? {} : { usage }), + } satisfies StepOutcome; + } + return { + _tag: "completed", + output, + ...(usage === undefined ? {} : { usage }), + } satisfies StepOutcome; + } + return { + _tag: "completed", + ...(usage === undefined ? {} : { usage }), + } satisfies StepOutcome; + } + if ("awaitingUser" in result.terminal) { + return { + _tag: "awaiting_user", + waitingReason: result.terminal.waitingReason, + providerThreadId: result.terminal.providerThreadId, + providerRequestId: result.terminal.providerRequestId, + providerResponseKind: result.terminal.providerResponseKind, + ...(result.terminal.providerQuestionId === undefined + ? {} + : { providerQuestionId: result.terminal.providerQuestionId }), + } satisfies StepOutcome; + } + const failureUsage = yield* readStepUsage(threadId as string); + return { + _tag: "failed", + error: result.terminal.error ?? "turn failed", + ...(failureUsage === undefined ? {} : { usage: failureUsage }), + } satisfies StepOutcome; + }); + + const scriptTrustGuard = (ctx: Parameters[0]) => + Effect.gen(function* () { + const board = yield* read.getBoard(ctx.boardId); + if (board === null) { + return { _tag: "failed", error: "workflow board not found" } satisfies StepOutcome; + } + const trusted = yield* scriptTrust.isTrusted(board.projectId as ProjectId); + if (!trusted) { + return { + _tag: "blocked", + reason: "Project not trusted to run scripts", + } satisfies StepOutcome; + } + return null; + }); + + const execute: StepExecutorShape["execute"] = (ctx) => + Effect.gen(function* () { + const step = ctx.step; + if (step.type === "approval") { + return { _tag: "completed" } satisfies StepOutcome; + } + if (step.type === "script") { + return yield* prepareWorktreeStep( + ctx, + (worktree) => scriptExecutor.execute({ ctx, step, worktree }), + { preSetupGuard: () => scriptTrustGuard(ctx) }, + ); + } + if (step.type === "merge") { + return yield* prepareWorktreeStep( + ctx, + (worktree) => + merges.merge({ + ticketId: ctx.ticketId, + repoRoot: worktree.repoRoot, + worktreePath: worktree.path, + worktreeRef: worktree.worktreeRef, + step, + }), + // Merging needs no project dependencies installed in the worktree. + { skipSetup: true }, + ); + } + return yield* prepareWorktreeStep(ctx, (worktree) => executeAgentStep(ctx, worktree, step)); + }).pipe( + // Keep the executor total, but surface the underlying cause — a bare + // "executor error" is undiagnosable from the board. + Effect.catch((error) => + Effect.succeed({ + _tag: "failed", + error: `executor error: ${executorErrorDetail(error)}`, + }), + ), + ); + + return { execute } satisfies StepExecutorShape; +}); + +export const RealStepExecutorLive = Layer.effect(StepExecutor, make); + +export const WorktreePortLive = Layer.effect( + WorktreePort, + Effect.gen(function* () { + const git = yield* GitWorkflowService; + const sql = yield* SqlClient.SqlClient; + const fileSystem = yield* FileSystem.FileSystem; + + const canonicalizeExistingPath = (value: string) => + fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); + + const repoRootForTicket = (ticketId: string) => + wrapSql( + "ticket project lookup failed", + sql` + SELECT + projects.workspace_root AS "repoRoot", + projects.project_id AS "projectId" + FROM projection_ticket AS ticket + INNER JOIN projection_board AS board + ON board.board_id = ticket.board_id + INNER JOIN projection_projects AS projects + ON projects.project_id = board.project_id + WHERE ticket.ticket_id = ${ticketId} + LIMIT 1 + `, + ).pipe( + Effect.flatMap((rows) => { + const row = rows[0]; + return row?.repoRoot + ? Effect.succeed(row) + : Effect.fail( + new WorkflowEventStoreError({ + message: `project repo root not found for ticket ${ticketId}`, + }), + ); + }), + ); + + const ensureWorktree: WorktreePortShape["ensureWorktree"] = (ticketId) => + Effect.gen(function* () { + const project = yield* repoRootForTicket(ticketId as string); + const repoRoot = yield* canonicalizeExistingPath(project.repoRoot); + const projectId = project.projectId; + const worktreeRef = `workflow/${ticketId}`; + const refs = yield* git + .listRefs({ cwd: TrimmedNonEmptyString.make(repoRoot) }) + .pipe(Effect.mapError(toExecutorError("worktree ref lookup failed"))); + const existing = refs.refs.find((ref) => !ref.isRemote && ref.name === worktreeRef); + if (existing?.worktreePath) { + return { + repoRoot, + worktreeRef, + path: yield* canonicalizeExistingPath(existing.worktreePath), + projectId, + } satisfies WorktreeHandle; + } + + const result = yield* git + .createWorktree( + existing + ? { + cwd: TrimmedNonEmptyString.make(repoRoot), + refName: TrimmedNonEmptyString.make(worktreeRef), + path: null, + } + : { + cwd: TrimmedNonEmptyString.make(repoRoot), + refName: TrimmedNonEmptyString.make("HEAD"), + newRefName: TrimmedNonEmptyString.make(worktreeRef), + path: null, + }, + ) + .pipe(Effect.mapError(toExecutorError("worktree creation failed"))); + + return { + repoRoot, + worktreeRef: result.worktree.refName, + path: yield* canonicalizeExistingPath(result.worktree.path), + projectId, + } satisfies WorktreeHandle; + }); + + return { ensureWorktree } satisfies WorktreePortShape; + }), +); diff --git a/apps/server/src/workflow/Layers/ScriptCancelRegistry.test.ts b/apps/server/src/workflow/Layers/ScriptCancelRegistry.test.ts new file mode 100644 index 00000000000..dd9e5ab2c67 --- /dev/null +++ b/apps/server/src/workflow/Layers/ScriptCancelRegistry.test.ts @@ -0,0 +1,43 @@ +import { assert, it } from "@effect/vitest"; +import { StepRunId, type TerminalCloseInput } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { ScriptCancelRegistryLive } from "./ScriptCancelRegistry.ts"; + +const layer = it.layer( + ScriptCancelRegistryLive.pipe( + Layer.provide( + Layer.succeed(TerminalManager, { + close: (input: TerminalCloseInput) => + Effect.sync(() => { + closed.push(`${input.threadId}:${input.terminalId ?? "*"}`); + }), + } as never), + ), + ), +); + +const closed: string[] = []; + +layer("ScriptCancelRegistryLive", (it) => { + it.effect("closes the registered script terminal and forgets it after unregister", () => + Effect.gen(function* () { + closed.length = 0; + const registry = yield* ScriptCancelRegistry; + const stepRunId = StepRunId.make("step-run-cancel"); + + yield* registry.register(stepRunId, { + scriptThreadId: "workflow-script:script-run-cancel" as never, + terminalId: "script-script-run-cancel", + }); + yield* registry.cancel(stepRunId); + yield* registry.unregister(stepRunId); + yield* registry.cancel(stepRunId); + + assert.deepEqual(closed, ["workflow-script:script-run-cancel:script-script-run-cancel"]); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/ScriptCancelRegistry.ts b/apps/server/src/workflow/Layers/ScriptCancelRegistry.ts new file mode 100644 index 00000000000..359b0df7dda --- /dev/null +++ b/apps/server/src/workflow/Layers/ScriptCancelRegistry.ts @@ -0,0 +1,44 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + ScriptCancelRegistry, + type ScriptCancelHandle, + type ScriptCancelRegistryShape, +} from "../Services/ScriptCancelRegistry.ts"; + +const toCancelError = (cause: unknown) => + new WorkflowEventStoreError({ message: "script cancel failed", cause }); + +const make = Effect.gen(function* () { + const terminals = yield* TerminalManager; + const handles = yield* Ref.make(new Map()); + + const register: ScriptCancelRegistryShape["register"] = (stepRunId, handle) => + Ref.update(handles, (current) => new Map(current).set(stepRunId as string, handle)); + + const unregister: ScriptCancelRegistryShape["unregister"] = (stepRunId) => + Ref.update(handles, (current) => { + const next = new Map(current); + next.delete(stepRunId as string); + return next; + }); + + const cancel: ScriptCancelRegistryShape["cancel"] = (stepRunId) => + Effect.gen(function* () { + const handle = (yield* Ref.get(handles)).get(stepRunId as string); + if (!handle) { + return; + } + yield* terminals + .close({ threadId: handle.scriptThreadId, terminalId: handle.terminalId }) + .pipe(Effect.mapError(toCancelError)); + }); + + return { register, unregister, cancel } satisfies ScriptCancelRegistryShape; +}); + +export const ScriptCancelRegistryLive = Layer.effect(ScriptCancelRegistry, make); diff --git a/apps/server/src/workflow/Layers/ScriptCommandRunner.test.ts b/apps/server/src/workflow/Layers/ScriptCommandRunner.test.ts new file mode 100644 index 00000000000..48fa53519a0 --- /dev/null +++ b/apps/server/src/workflow/Layers/ScriptCommandRunner.test.ts @@ -0,0 +1,239 @@ +import { assert, it } from "@effect/vitest"; +import type { TerminalEvent, TerminalSessionSnapshot } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; + +import { TerminalManager, type TerminalManagerShape } from "../../terminal/Services/Manager.ts"; +import { ScriptCommandRunner } from "../Services/ScriptCommandRunner.ts"; +import { ScriptCommandRunnerLive } from "./ScriptCommandRunner.ts"; + +const snapshot = (input: { + readonly threadId: string; + readonly terminalId: string; + readonly cwd: string; +}): TerminalSessionSnapshot => ({ + threadId: input.threadId, + terminalId: input.terminalId, + cwd: input.cwd, + worktreePath: null, + status: "running", + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "script", + updatedAt: "2026-06-07T00:00:00.000Z", +}); + +const layerWithTerminal = (manager: TerminalManagerShape) => + ScriptCommandRunnerLive.pipe(Layer.provideMerge(Layer.succeed(TerminalManager, manager))); + +it.effect( + "subscribes before writing, wraps the command, and filters exit events by thread and terminal", + () => + Effect.gen(function* () { + const calls: string[] = []; + let listener: ((event: TerminalEvent) => Effect.Effect) | null = null; + const layer = layerWithTerminal({ + open: (input) => + Effect.sync(() => { + calls.push(`open:${input.threadId}:${input.terminalId}:${input.cwd}`); + return snapshot(input); + }), + attachStream: () => Effect.succeed(() => undefined), + attachHistoryStream: () => Effect.succeed(() => undefined), + write: (input) => + Effect.gen(function* () { + calls.push(`write:${input.data}`); + if (listener === null) { + assert.fail("terminal listener was not installed before write"); + } + yield* listener({ + type: "exited", + threadId: "other-thread", + terminalId: input.terminalId, + exitCode: 99, + exitSignal: null, + }); + yield* listener({ + type: "exited", + threadId: input.threadId, + terminalId: "other-terminal", + exitCode: 98, + exitSignal: null, + }); + yield* listener({ + type: "exited", + threadId: input.threadId, + terminalId: input.terminalId, + exitCode: 7, + exitSignal: 15, + }); + }), + resize: () => Effect.void, + clear: () => Effect.void, + restart: (input) => Effect.succeed(snapshot(input)), + close: (input) => + Effect.sync(() => { + calls.push(`close:${input.threadId}:${input.terminalId ?? "*"}`); + }), + subscribe: (next) => + Effect.sync(() => { + calls.push("subscribe"); + listener = next; + return () => { + calls.push("unsubscribe"); + }; + }), + subscribeMetadata: () => Effect.succeed(() => undefined), + }); + + const result = yield* Effect.gen(function* () { + const runner = yield* ScriptCommandRunner; + return yield* runner.run({ + scriptThreadId: "script-thread" as never, + terminalId: "script-terminal", + cwd: "/tmp/worktree", + run: "exit 7", + timeout: Duration.seconds(1), + }); + }).pipe(Effect.provide(layer)); + + assert.deepEqual(result, { outcome: "exited", exitCode: 7, signal: 15 }); + assert.deepEqual(calls, [ + "subscribe", + "open:script-thread:script-terminal:/tmp/worktree", + "write:exit 7\nexit $?\r", + "unsubscribe", + ]); + }), +); + +it.effect("closes the terminal and resolves timeout when no terminal event arrives", () => + Effect.gen(function* () { + const calls: string[] = []; + const layer = layerWithTerminal({ + open: (input) => Effect.succeed(snapshot(input)), + attachStream: () => Effect.succeed(() => undefined), + attachHistoryStream: () => Effect.succeed(() => undefined), + write: () => Effect.void, + resize: () => Effect.void, + clear: () => Effect.void, + restart: (input) => Effect.succeed(snapshot(input)), + close: (input) => + Effect.sync(() => { + calls.push(`close:${input.threadId}:${input.terminalId ?? "*"}`); + }), + subscribe: () => Effect.succeed(() => undefined), + subscribeMetadata: () => Effect.succeed(() => undefined), + }); + + const result = yield* Effect.gen(function* () { + const runner = yield* ScriptCommandRunner; + const fiber = yield* Effect.forkChild( + runner.run({ + scriptThreadId: "timeout-thread" as never, + terminalId: "timeout-terminal", + cwd: "/tmp/worktree", + run: "sleep 10", + timeout: Duration.millis(10), + }), + ); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(10)); + return yield* Fiber.join(fiber); + }).pipe(Effect.provide(Layer.merge(layer, TestClock.layer()))); + + assert.deepEqual(result, { outcome: "timeout", exitCode: null, signal: null }); + assert.deepEqual(calls, ["close:timeout-thread:timeout-terminal"]); + }), +); + +it.effect("treats a closed terminal event as cooperative cancellation", () => + Effect.gen(function* () { + let listener: ((event: TerminalEvent) => Effect.Effect) | null = null; + const layer = layerWithTerminal({ + open: (input) => Effect.succeed(snapshot(input)), + attachStream: () => Effect.succeed(() => undefined), + attachHistoryStream: () => Effect.succeed(() => undefined), + write: (input) => + Effect.gen(function* () { + if (listener === null) { + assert.fail("terminal listener was not installed before write"); + } + yield* listener({ + type: "closed", + threadId: input.threadId, + terminalId: input.terminalId, + }); + }), + resize: () => Effect.void, + clear: () => Effect.void, + restart: (input) => Effect.succeed(snapshot(input)), + close: () => Effect.void, + subscribe: (next) => + Effect.sync(() => { + listener = next; + return () => undefined; + }), + subscribeMetadata: () => Effect.succeed(() => undefined), + }); + + const result = yield* Effect.gen(function* () { + const runner = yield* ScriptCommandRunner; + return yield* runner.run({ + scriptThreadId: "cancel-thread" as never, + terminalId: "cancel-terminal", + cwd: "/tmp/worktree", + run: "sleep 10", + timeout: Duration.seconds(1), + }); + }).pipe(Effect.provide(layer)); + + assert.deepEqual(result, { outcome: "cancelled", exitCode: null, signal: null }); + }), +); + +it.effect("closes the terminal when the runner fiber is interrupted", () => + Effect.gen(function* () { + const written = yield* Deferred.make(); + const calls: string[] = []; + const layer = layerWithTerminal({ + open: (input) => Effect.succeed(snapshot(input)), + attachStream: () => Effect.succeed(() => undefined), + attachHistoryStream: () => Effect.succeed(() => undefined), + write: () => Deferred.succeed(written, undefined).pipe(Effect.asVoid), + resize: () => Effect.void, + clear: () => Effect.void, + restart: (input) => Effect.succeed(snapshot(input)), + close: (input) => + Effect.sync(() => { + calls.push(`close:${input.threadId}:${input.terminalId ?? "*"}`); + }), + subscribe: () => Effect.succeed(() => undefined), + subscribeMetadata: () => Effect.succeed(() => undefined), + }); + + const fiber = yield* Effect.forkChild( + Effect.gen(function* () { + const runner = yield* ScriptCommandRunner; + return yield* runner.run({ + scriptThreadId: "interrupt-thread" as never, + terminalId: "interrupt-terminal", + cwd: "/tmp/worktree", + run: "sleep 10", + timeout: Duration.seconds(10), + }); + }).pipe(Effect.provide(layer)), + ); + yield* Deferred.await(written); + + yield* Fiber.interrupt(fiber); + + assert.deepEqual(calls, ["close:interrupt-thread:interrupt-terminal"]); + }), +); diff --git a/apps/server/src/workflow/Layers/ScriptCommandRunner.ts b/apps/server/src/workflow/Layers/ScriptCommandRunner.ts new file mode 100644 index 00000000000..4f3a1a1fb39 --- /dev/null +++ b/apps/server/src/workflow/Layers/ScriptCommandRunner.ts @@ -0,0 +1,104 @@ +import type { TerminalEvent } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + ScriptCommandRunner, + type ScriptCommandResult, + type ScriptCommandRunnerShape, +} from "../Services/ScriptCommandRunner.ts"; + +const toRunnerError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const timeoutResult = { + outcome: "timeout", + exitCode: null, + signal: null, +} satisfies ScriptCommandResult; + +const cancelledResult = { + outcome: "cancelled", + exitCode: null, + signal: null, +} satisfies ScriptCommandResult; + +const wrapShellCommand = (run: string) => `${run}\nexit $?\r`; + +const matchesRun = ( + event: TerminalEvent, + input: { + readonly scriptThreadId: string; + readonly terminalId: string; + }, +) => event.threadId === input.scriptThreadId && event.terminalId === input.terminalId; + +const make = Effect.gen(function* () { + const terminals = yield* TerminalManager; + + const run: ScriptCommandRunnerShape["run"] = (input) => + Effect.gen(function* () { + const done = yield* Deferred.make(); + const complete = (result: ScriptCommandResult) => + Deferred.succeed(done, result).pipe(Effect.asVoid); + const closeTerminal = terminals + .close({ threadId: input.scriptThreadId, terminalId: input.terminalId }) + .pipe(Effect.ignore); + + const unsubscribe = yield* terminals.subscribe((event) => { + if (!matchesRun(event, input)) { + return Effect.void; + } + if (event.type === "exited") { + return complete({ + outcome: "exited", + exitCode: event.exitCode ?? 1, + signal: event.exitSignal, + }); + } + if (event.type === "closed") { + return complete(cancelledResult); + } + return Effect.void; + }); + + const awaitTerminal = Deferred.await(done).pipe( + Effect.timeoutOption(input.timeout), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => closeTerminal.pipe(Effect.as(timeoutResult)), + onSome: Effect.succeed, + }), + ), + ); + + return yield* Effect.gen(function* () { + yield* terminals + .open({ + threadId: input.scriptThreadId, + terminalId: input.terminalId, + cwd: input.cwd, + }) + .pipe(Effect.mapError(toRunnerError("script terminal open failed"))); + yield* terminals + .write({ + threadId: input.scriptThreadId, + terminalId: input.terminalId, + data: wrapShellCommand(input.run), + }) + .pipe(Effect.mapError(toRunnerError("script terminal write failed"))); + return yield* awaitTerminal; + }).pipe( + Effect.onInterrupt(() => closeTerminal), + Effect.ensuring(Effect.sync(unsubscribe)), + ); + }); + + return { run } satisfies ScriptCommandRunnerShape; +}); + +export const ScriptCommandRunnerLive = Layer.effect(ScriptCommandRunner, make); diff --git a/apps/server/src/workflow/Layers/ScriptStepExecutor.test.ts b/apps/server/src/workflow/Layers/ScriptStepExecutor.test.ts new file mode 100644 index 00000000000..500c3c62362 --- /dev/null +++ b/apps/server/src/workflow/Layers/ScriptStepExecutor.test.ts @@ -0,0 +1,250 @@ +// @effect-diagnostics nodeBuiltinImport:off +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { ScriptCommandRunner, type ScriptCommandResult } from "../Services/ScriptCommandRunner.ts"; +import { ScriptStepExecutor } from "../Services/ScriptStepExecutor.ts"; +import type { StepExecutionContext } from "../Services/StepExecutor.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { ScriptStepExecutorLive } from "./ScriptStepExecutor.ts"; + +const context: StepExecutionContext = { + ticketId: "ticket-script" as never, + boardId: "board-script" as never, + pipelineRunId: "pipeline-script" as never, + stepRunId: "step-run-script" as never, + laneEntryToken: "lane-token-script" as never, + step: { + key: "tests" as never, + type: "script", + run: "pnpm test", + cwd: "packages/app", + }, +}; + +const layer = ( + commandResult: ScriptCommandResult, + inputs: Array<{ + readonly scriptThreadId: string; + readonly terminalId: string; + readonly cwd: string; + readonly run: string; + }>, + cancelEvents: string[] = [], +) => + ScriptStepExecutorLive.pipe( + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: (stepRunId, handle) => + Effect.sync(() => { + cancelEvents.push( + `register:${stepRunId}:${handle.scriptThreadId}:${handle.terminalId}`, + ); + }), + unregister: (stepRunId) => + Effect.sync(() => { + cancelEvents.push(`unregister:${stepRunId}`); + }), + cancel: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ScriptCommandRunner, { + run: (input) => + Effect.sync(() => { + inputs.push({ + scriptThreadId: input.scriptThreadId, + terminalId: input.terminalId, + cwd: input.cwd, + run: input.run, + }); + return commandResult; + }), + }), + ), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), + ); + +const makeWorktree = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const worktreePath = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-script-step-" }); + const cwd = path.join(worktreePath, "packages", "app"); + yield* fileSystem.makeDirectory(cwd, { recursive: true }); + return { + repoRoot: worktreePath, + worktreeRef: "workflow/ticket-script", + path: worktreePath, + cwd, + }; +}); + +const seedTicket = Effect.gen(function* () { + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + yield* registry.register(context.boardId, { + name: "Script board", + lanes: [{ key: "impl", name: "Impl", entry: "manual" }], + }); + yield* read.registerBoard({ + boardId: context.boardId, + projectId: "project-script" as never, + name: "Script board", + workflowFilePath: ".t3/boards/script.json", + workflowVersionHash: "hash-script", + maxConcurrentTickets: 1, + }); + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + ${context.ticketId}, + ${context.boardId}, + 'Script ticket', + 'impl', + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + ON CONFLICT(ticket_id) DO NOTHING + `; +}); + +it.effect("runs a script command in a contained cwd and commits start and exit events", () => { + const inputs: Array<{ + readonly scriptThreadId: string; + readonly terminalId: string; + readonly cwd: string; + readonly run: string; + }> = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const worktree = yield* makeWorktree; + const expectedCwd = yield* fileSystem.realPath(worktree.cwd); + const executor = yield* ScriptStepExecutor; + const store = yield* WorkflowEventStore; + yield* seedTicket; + + const outcome = yield* executor.execute({ + ctx: context, + step: context.step as Extract, + worktree, + }); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.deepEqual(inputs, [ + { + scriptThreadId: "workflow-script:scriptrun-1", + terminalId: "script-scriptrun-1", + cwd: expectedCwd, + run: "pnpm test", + }, + ]); + + const events = yield* Stream.runCollect(store.readByTicket(context.ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const started = events.find((event) => event.type === "ScriptStepStarted"); + const exited = events.find((event) => event.type === "ScriptStepExited"); + assert.equal(started?.payload.scriptRunId, "scriptrun-1"); + assert.equal(started?.payload.scriptThreadId, "workflow-script:scriptrun-1"); + assert.equal(started?.payload.terminalId, "script-scriptrun-1"); + assert.equal(exited?.payload.scriptRunId, "scriptrun-1"); + assert.equal(exited?.payload.exitCode, 0); + assert.equal(exited?.payload.outcome, "exited"); + }).pipe(Effect.provide(layer({ outcome: "exited", exitCode: 0, signal: null }, inputs))); +}); + +it.effect("registers the script terminal as cancellable while the command is running", () => { + const inputs: Array<{ + readonly scriptThreadId: string; + readonly terminalId: string; + readonly cwd: string; + readonly run: string; + }> = []; + const cancelEvents: string[] = []; + return Effect.gen(function* () { + const worktree = yield* makeWorktree; + const executor = yield* ScriptStepExecutor; + yield* seedTicket; + + const outcome = yield* executor.execute({ + ctx: context, + step: context.step as Extract, + worktree, + }); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.deepEqual(cancelEvents, [ + "register:step-run-script:workflow-script:scriptrun-1:script-scriptrun-1", + "unregister:step-run-script", + ]); + }).pipe( + Effect.provide(layer({ outcome: "exited", exitCode: 0, signal: null }, inputs, cancelEvents)), + ); +}); + +it.effect("rejects a script cwd that escapes the worktree before running a command", () => { + const inputs: Array<{ + readonly scriptThreadId: string; + readonly terminalId: string; + readonly cwd: string; + readonly run: string; + }> = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const worktree = yield* makeWorktree; + const outside = path.join(path.dirname(worktree.path), "outside"); + yield* fileSystem.makeDirectory(outside, { recursive: true }); + const executor = yield* ScriptStepExecutor; + + const outcome = yield* executor.execute({ + ctx: { + ...context, + step: { + ...context.step, + cwd: "../outside", + } as Extract, + }, + step: { + ...context.step, + cwd: "../outside", + } as Extract, + worktree, + }); + + assert.deepEqual(outcome, { _tag: "failed", error: "script cwd escapes worktree" }); + assert.deepEqual(inputs, []); + }).pipe(Effect.provide(layer({ outcome: "exited", exitCode: 0, signal: null }, inputs))); +}); diff --git a/apps/server/src/workflow/Layers/ScriptStepExecutor.ts b/apps/server/src/workflow/Layers/ScriptStepExecutor.ts new file mode 100644 index 00000000000..f37bf38b0ff --- /dev/null +++ b/apps/server/src/workflow/Layers/ScriptStepExecutor.ts @@ -0,0 +1,150 @@ +import { ThreadId, type StepOutcome, type WorkflowEventId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { ScriptCommandRunner } from "../Services/ScriptCommandRunner.ts"; +import { + ScriptStepExecutor, + type ScriptStepExecutorShape, +} from "../Services/ScriptStepExecutor.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { type WorkflowEventInput } from "../Services/WorkflowEventStore.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import type { WorktreeHandle } from "../Services/WorktreePort.ts"; + +const DEFAULT_SCRIPT_TIMEOUT = Duration.minutes(10); + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +const toScriptExecutorError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const isContainedPath = ( + path: Path.Path, + input: { + readonly root: string; + readonly candidate: string; + }, +) => { + const relative = path.relative(input.root, input.candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +}; + +const mapCommandResult = ( + result: { + readonly outcome: "exited" | "timeout" | "cancelled"; + readonly exitCode: number | null; + }, + allowFailure: boolean, +): StepOutcome => { + if (result.outcome === "timeout") { + return { _tag: "failed", error: "script timed out" }; + } + if (result.outcome === "cancelled") { + // User-initiated cancellation: never auto-retried. + return { _tag: "failed", error: "script cancelled", retryable: false }; + } + if (result.exitCode === 0 || allowFailure) { + return { _tag: "completed" }; + } + return { _tag: "failed", error: `script exited with code ${result.exitCode ?? 1}` }; +}; + +const make = Effect.gen(function* () { + const cancels = yield* ScriptCancelRegistry; + const commands = yield* ScriptCommandRunner; + const committer = yield* WorkflowEventCommitter; + const fileSystem = yield* FileSystem.FileSystem; + const ids = yield* WorkflowIds; + const path = yield* Path.Path; + + const commit = ( + event: Omit, + ): Effect.Effect => + Effect.gen(function* () { + const eventId = yield* ids.eventId(); + yield* committer.commit({ + ...event, + eventId: eventId as WorkflowEventId, + occurredAt: (yield* nowIso) as never, + } as WorkflowEventInput); + }); + + const resolveContainedCwd = (worktree: WorktreeHandle, cwd: string | undefined) => + Effect.gen(function* () { + const requested = cwd ?? "."; + const absolute = path.resolve(worktree.path, requested); + const worktreeRoot = yield* fileSystem + .realPath(worktree.path) + .pipe(Effect.mapError(toScriptExecutorError("script worktree realpath failed"))); + const resolved = yield* fileSystem + .realPath(absolute) + .pipe(Effect.mapError(toScriptExecutorError("script cwd realpath failed"))); + if (!isContainedPath(path, { root: worktreeRoot, candidate: resolved })) { + return { _tag: "failed", error: "script cwd escapes worktree" } as const; + } + return { _tag: "success", cwd: resolved } as const; + }).pipe( + Effect.catch(() => Effect.succeed({ _tag: "failed", error: "script cwd invalid" } as const)), + ); + + const execute: ScriptStepExecutorShape["execute"] = (input) => + Effect.gen(function* () { + const cwd = yield* resolveContainedCwd(input.worktree, input.step.cwd); + if (cwd._tag === "failed") { + return { _tag: "failed", error: cwd.error } satisfies StepOutcome; + } + + const scriptRunId = yield* ids.scriptRunId(); + const scriptThreadId = ThreadId.make(`workflow-script:${scriptRunId}`); + const terminalId = `script-${scriptRunId}`; + + yield* cancels.register(input.ctx.stepRunId, { scriptThreadId, terminalId }); + + const result = yield* Effect.gen(function* () { + yield* commit({ + type: "ScriptStepStarted", + ticketId: input.ctx.ticketId, + payload: { + scriptRunId, + stepRunId: input.ctx.stepRunId, + scriptThreadId, + terminalId, + }, + }); + + const commandResult = yield* commands.run({ + scriptThreadId, + terminalId, + cwd: cwd.cwd, + run: input.step.run, + timeout: input.step.timeout ?? DEFAULT_SCRIPT_TIMEOUT, + }); + + yield* commit({ + type: "ScriptStepExited", + ticketId: input.ctx.ticketId, + payload: { + scriptRunId, + exitCode: commandResult.exitCode, + signal: commandResult.signal, + outcome: commandResult.outcome, + }, + }); + + return commandResult; + }).pipe(Effect.ensuring(cancels.unregister(input.ctx.stepRunId))); + + return mapCommandResult(result, input.step.allowFailure ?? false); + }); + + return { execute } satisfies ScriptStepExecutorShape; +}); + +export const ScriptStepExecutorLive = Layer.effect(ScriptStepExecutor, make); diff --git a/apps/server/src/workflow/Layers/SetupRunService.test.ts b/apps/server/src/workflow/Layers/SetupRunService.test.ts new file mode 100644 index 00000000000..ae2ce54b0b2 --- /dev/null +++ b/apps/server/src/workflow/Layers/SetupRunService.test.ts @@ -0,0 +1,52 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { SetupRunService, SetupTerminalPort } from "../Services/SetupRunService.ts"; +import { SetupRunServiceLive } from "./SetupRunService.ts"; + +const stubTerminal = (exitCode: number) => + Layer.succeed(SetupTerminalPort, { + launch: () => Effect.succeed({ terminalId: "term-1" }), + awaitExit: () => Effect.succeed({ exitCode }), + }); + +const layerForExit = (exitCode: number) => + it.layer( + SetupRunServiceLive.pipe( + Layer.provideMerge(stubTerminal(exitCode)), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ); + +layerForExit(0)("SetupRunService success", (it) => { + it.effect("completes on exit 0", () => + Effect.gen(function* () { + const setup = yield* SetupRunService; + const sql = yield* SqlClient.SqlClient; + const result = yield* setup.runSetup("t-1" as never, "wt-1", "/tmp/wt-1", "setup-1" as never); + + assert.equal(result.status, "completed"); + const rows = yield* sql<{ readonly status: string }>` + SELECT status FROM workflow_setup_run WHERE ticket_id = 't-1' + `; + assert.equal(rows[0]?.status, "completed"); + }), + ); +}); + +layerForExit(1)("SetupRunService failure", (it) => { + it.effect("fails on non-zero exit", () => + Effect.gen(function* () { + const setup = yield* SetupRunService; + const result = yield* setup.runSetup("t-2" as never, "wt-2", "/tmp/wt-2", "setup-2" as never); + + assert.equal(result.status, "failed"); + assert.equal(result.exitCode, 1); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/SetupRunService.ts b/apps/server/src/workflow/Layers/SetupRunService.ts new file mode 100644 index 00000000000..70203022dc8 --- /dev/null +++ b/apps/server/src/workflow/Layers/SetupRunService.ts @@ -0,0 +1,182 @@ +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { + ProjectSetupScriptRunner, + type ProjectSetupScriptRunnerInput, +} from "../../project/Services/ProjectSetupScriptRunner.ts"; +import { TerminalManager, type TerminalManagerShape } from "../../terminal/Services/Manager.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + SetupRunService, + SetupTerminalPort, + type SetupRunServiceShape, + type SetupTerminalPortShape, + type SetupStatus, +} from "../Services/SetupRunService.ts"; + +const SETUP_TIMEOUT_MS = 10 * 60 * 1000; + +const toSetupError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrapSql = (effect: Effect.Effect) => + effect.pipe(Effect.mapError(toSetupError("setup op failed"))); + +interface SetupRunRow { + readonly status: string; + readonly exitCode: number | null; +} + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +const normalizeStatus = (exitCode: number): SetupStatus => + exitCode === 0 ? "completed" : exitCode === -1 ? "timed_out" : "failed"; + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const terminal = yield* SetupTerminalPort; + + const runSetup: SetupRunServiceShape["runSetup"] = ( + ticketId, + worktreeRef, + worktreePath, + setupRunId, + projectId, + ) => + Effect.gen(function* () { + const existing = yield* wrapSql(sql` + SELECT + status, + exit_code AS "exitCode" + FROM workflow_setup_run + WHERE ticket_id = ${ticketId} + `); + if (existing[0]?.status === "completed") { + return { status: "completed", exitCode: existing[0].exitCode }; + } + + yield* wrapSql(sql` + INSERT INTO workflow_setup_run ( + setup_run_id, + ticket_id, + worktree_ref, + status, + started_at + ) + VALUES (${setupRunId}, ${ticketId}, ${worktreeRef}, 'running', ${yield* nowIso}) + ON CONFLICT(ticket_id) DO UPDATE SET + setup_run_id = excluded.setup_run_id, + worktree_ref = excluded.worktree_ref, + status = 'running', + started_at = excluded.started_at, + finished_at = NULL, + exit_code = NULL + `); + + const { terminalId } = yield* terminal.launch({ + worktreePath, + ...(projectId === undefined ? {} : { projectId }), + }); + const exit = + terminalId === null + ? { exitCode: 0 } + : yield* terminal + .awaitExit({ terminalId, timeoutMs: SETUP_TIMEOUT_MS }) + .pipe(Effect.orElseSucceed(() => ({ exitCode: -1 }))); + const status = normalizeStatus(exit.exitCode); + + yield* wrapSql(sql` + UPDATE workflow_setup_run + SET status = ${status}, + exit_code = ${exit.exitCode}, + finished_at = ${yield* nowIso} + WHERE ticket_id = ${ticketId} + `); + + return { status, exitCode: exit.exitCode }; + }); + + return { runSetup } satisfies SetupRunServiceShape; +}); + +export const SetupRunServiceLive = Layer.effect(SetupRunService, make); + +const awaitTerminalExit = ( + terminals: TerminalManagerShape, + input: { readonly terminalId: string | null; readonly timeoutMs?: number }, +): Effect.Effect<{ readonly exitCode: number }, WorkflowEventStoreError> => { + if (input.terminalId === null) { + return Effect.succeed({ exitCode: 0 }); + } + + return Effect.gen(function* () { + const done = yield* Deferred.make<{ readonly exitCode: number }>(); + const unsubscribe = yield* terminals.subscribe((event) => { + if (event.type !== "exited" || event.terminalId !== input.terminalId) { + return Effect.void; + } + return Deferred.succeed(done, { exitCode: event.exitCode ?? 1 }).pipe(Effect.asVoid); + }); + const wait = Deferred.await(done); + const timed = + input.timeoutMs === undefined + ? wait + : wait.pipe( + Effect.timeoutOption(Duration.millis(input.timeoutMs)), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail( + new WorkflowEventStoreError({ + message: "setup terminal wait timed out", + }), + ), + onSome: Effect.succeed, + }), + ), + ); + return yield* timed.pipe( + Effect.mapError(toSetupError("setup terminal wait failed")), + Effect.ensuring(Effect.sync(unsubscribe)), + ); + }); +}; + +export const SetupTerminalPortLive = Layer.effect( + SetupTerminalPort, + Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner; + const terminals = yield* TerminalManager; + + return { + launch: (input) => { + const setupInput = { + threadId: input.threadId ?? `workflow-setup:${input.worktreePath}`, + worktreePath: input.worktreePath, + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), + ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), + ...(input.preferredTerminalId === undefined + ? {} + : { preferredTerminalId: input.preferredTerminalId }), + } satisfies ProjectSetupScriptRunnerInput; + + return runner.runForThread(setupInput).pipe( + Effect.map((result) => + result.status === "no-script" + ? { terminalId: null } + : { terminalId: result.terminalId }, + ), + Effect.mapError(toSetupError("setup launch failed")), + ); + }, + awaitExit: (input) => awaitTerminalExit(terminals, input), + } satisfies SetupTerminalPortShape; + }), +); diff --git a/apps/server/src/workflow/Layers/StepUsageReader.test.ts b/apps/server/src/workflow/Layers/StepUsageReader.test.ts new file mode 100644 index 00000000000..f3c9375f0b9 --- /dev/null +++ b/apps/server/src/workflow/Layers/StepUsageReader.test.ts @@ -0,0 +1,94 @@ +import { assert, it } from "@effect/vitest"; +import type { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { + ProjectionThreadActivityRepository, + type ProjectionThreadActivity, +} from "../../persistence/Services/ProjectionThreadActivities.ts"; +import { StepUsageReader } from "../Services/StepUsageReader.ts"; +import { StepUsageReaderLive } from "./StepUsageReader.ts"; + +const threadId = "thread-usage" as ThreadId; + +const activity = (overrides: Partial): ProjectionThreadActivity => + ({ + activityId: "act-1" as never, + threadId, + turnId: null, + tone: "info", + kind: "context-window.updated", + summary: "Context window updated", + payload: {}, + createdAt: "2026-06-09T00:00:00.000Z", + ...overrides, + }) as ProjectionThreadActivity; + +const layerWith = (rows: ReadonlyArray) => + StepUsageReaderLive.pipe( + Layer.provideMerge( + Layer.succeed(ProjectionThreadActivityRepository, { + upsert: () => Effect.void, + listByThreadId: () => Effect.succeed(rows), + deleteByThreadId: () => Effect.void, + }), + ), + ); + +const readUsage = (rows: ReadonlyArray) => + Effect.gen(function* () { + const reader = yield* StepUsageReader; + return yield* reader.read(threadId); + }).pipe(Effect.provide(layerWith(rows))); + +it.effect("maps the latest context-window snapshot to workflow usage", () => + Effect.gen(function* () { + const usage = yield* readUsage([ + activity({ + activityId: "act-1" as never, + payload: { usedTokens: 100, inputTokens: 80, outputTokens: 20 }, + }), + activity({ + activityId: "act-2" as never, + payload: { + usedTokens: 500, + totalProcessedTokens: 1200, + inputTokens: 900, + cachedInputTokens: 300, + outputTokens: 250, + }, + }), + ]); + + assert.deepEqual(usage, { + inputTokens: 900, + cachedInputTokens: 300, + outputTokens: 250, + totalTokens: 1200, + }); + }), +); + +it.effect("ignores other activity kinds and malformed payloads", () => + Effect.gen(function* () { + const usage = yield* readUsage([ + activity({ activityId: "act-1" as never, payload: { usedTokens: 42, inputTokens: 30 } }), + activity({ + activityId: "act-2" as never, + kind: "tool.completed", + payload: { usedTokens: 999999 }, + }), + activity({ activityId: "act-3" as never, payload: { usedTokens: "not-a-number" } }), + ]); + + assert.deepEqual(usage, { inputTokens: 30, totalTokens: 42 }); + }), +); + +it.effect("returns undefined when no usage was emitted", () => + Effect.gen(function* () { + const usage = yield* readUsage([]); + assert.equal(usage, undefined); + }), +); diff --git a/apps/server/src/workflow/Layers/StepUsageReader.ts b/apps/server/src/workflow/Layers/StepUsageReader.ts new file mode 100644 index 00000000000..582137da007 --- /dev/null +++ b/apps/server/src/workflow/Layers/StepUsageReader.ts @@ -0,0 +1,47 @@ +import { ThreadTokenUsageSnapshot, type WorkflowStepUsage } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts"; +import { StepUsageReader, type StepUsageReaderShape } from "../Services/StepUsageReader.ts"; + +const decodeUsageSnapshot = Schema.decodeUnknownEffect(ThreadTokenUsageSnapshot); + +const toWorkflowUsage = (snapshot: ThreadTokenUsageSnapshot): WorkflowStepUsage | undefined => { + const usage = { + ...(snapshot.inputTokens === undefined ? {} : { inputTokens: snapshot.inputTokens }), + ...(snapshot.cachedInputTokens === undefined + ? {} + : { cachedInputTokens: snapshot.cachedInputTokens }), + ...(snapshot.outputTokens === undefined ? {} : { outputTokens: snapshot.outputTokens }), + totalTokens: snapshot.totalProcessedTokens ?? snapshot.usedTokens, + } satisfies WorkflowStepUsage; + return usage.totalTokens === 0 && usage.inputTokens === undefined ? undefined : usage; +}; + +const make = Effect.gen(function* () { + const activities = yield* ProjectionThreadActivityRepository; + + const read: StepUsageReaderShape["read"] = (threadId) => + Effect.gen(function* () { + const rows = yield* activities.listByThreadId({ threadId }); + for (let index = rows.length - 1; index >= 0; index -= 1) { + const row = rows[index]; + if (row?.kind !== "context-window.updated") { + continue; + } + const snapshot = yield* decodeUsageSnapshot(row.payload).pipe( + Effect.orElseSucceed(() => null), + ); + if (snapshot !== null) { + return toWorkflowUsage(snapshot); + } + } + return undefined; + }).pipe(Effect.orElseSucceed(() => undefined)); + + return { read } satisfies StepUsageReaderShape; +}); + +export const StepUsageReaderLive = Layer.effect(StepUsageReader, make); diff --git a/apps/server/src/workflow/Layers/StubStepExecutor.test.ts b/apps/server/src/workflow/Layers/StubStepExecutor.test.ts new file mode 100644 index 00000000000..891c6668eff --- /dev/null +++ b/apps/server/src/workflow/Layers/StubStepExecutor.test.ts @@ -0,0 +1,29 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { StepExecutor } from "../Services/StepExecutor.ts"; +import { makeStubStepExecutor } from "./StubStepExecutor.ts"; + +const layer = it.layer(makeStubStepExecutor({ default: { _tag: "completed" } })); + +layer("StubStepExecutor", (it) => { + it.effect("returns the scripted default outcome", () => + Effect.gen(function* () { + const executor = yield* StepExecutor; + const outcome = yield* executor.execute({ + ticketId: "t-1" as never, + boardId: "b-1" as never, + pipelineRunId: "pr-1" as never, + stepRunId: "sr-1" as never, + laneEntryToken: "tok-1" as never, + step: { + key: "code" as never, + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "x", + }, + }); + assert.equal(outcome._tag, "completed"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/StubStepExecutor.ts b/apps/server/src/workflow/Layers/StubStepExecutor.ts new file mode 100644 index 00000000000..f7f1e6d0cbf --- /dev/null +++ b/apps/server/src/workflow/Layers/StubStepExecutor.ts @@ -0,0 +1,15 @@ +import type { StepOutcome } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; + +export interface StubScript { + readonly default: StepOutcome; + readonly byStepKey?: Record; +} + +export const makeStubStepExecutor = (script: StubScript): Layer.Layer => + Layer.succeed(StepExecutor, { + execute: (ctx) => Effect.succeed(script.byStepKey?.[ctx.step.key as string] ?? script.default), + } satisfies StepExecutorShape); diff --git a/apps/server/src/workflow/Layers/TicketCheckpointService.migration.test.ts b/apps/server/src/workflow/Layers/TicketCheckpointService.migration.test.ts new file mode 100644 index 00000000000..e7d793a4cfd --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketCheckpointService.migration.test.ts @@ -0,0 +1,21 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("step refs migration", (it) => { + it.effect("projection_step_run has pre/post ref columns", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const cols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_step_run)`; + const names = new Set(cols.map((column) => column.name)); + assert.isTrue(names.has("pre_checkpoint_ref")); + assert.isTrue(names.has("post_checkpoint_ref")); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/TicketCheckpointService.test.ts b/apps/server/src/workflow/Layers/TicketCheckpointService.test.ts new file mode 100644 index 00000000000..36938c56157 --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketCheckpointService.test.ts @@ -0,0 +1,99 @@ +// @effect-diagnostics nodeBuiltinImport:off +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Scope from "effect/Scope"; + +import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; +import { ServerConfig } from "../../config.ts"; +import type { VcsError } from "@t3tools/contracts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { TicketCheckpointService } from "../Services/TicketCheckpointService.ts"; +import { TicketCheckpointServiceLive } from "./TicketCheckpointService.ts"; + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-ticket-checkpoint-test-", +}); +const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); +const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcessTestLayer)); + +const layer = it.layer( + TicketCheckpointServiceLive.pipe( + Layer.provideMerge(CheckpointStoreLive), + Layer.provideMerge(VcsDriverTestLayer), + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + ), +); + +const makeTmpDir = ( + prefix = "ticket-checkpoint-test-", +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); + +const writeTextFile = ( + filePath: string, + contents: string, +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(filePath, contents); + }); + +const git = ( + cwd: string, + args: ReadonlyArray, +): Effect.Effect => + Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const result = yield* process.run({ + operation: "TicketCheckpointService.test.git", + command: "git", + cwd, + args, + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +const initRepoWithCommit = ( + cwd: string, +): Effect.Effect< + void, + VcsError | PlatformError.PlatformError, + VcsProcess.VcsProcess | FileSystem.FileSystem +> => + Effect.gen(function* () { + yield* git(cwd, ["init"]); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial commit"]); + }); + +layer("TicketCheckpointService", (it) => { + it.effect("captures a baseline ref that exists", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const service = yield* TicketCheckpointService; + + const ref = yield* service.captureBaseline("t-1" as never, tmp); + const exists = yield* service.hasBaseline("t-1" as never, tmp); + + assert.equal(ref, "refs/t3/tickets/dC0x/base"); + assert.equal(exists, true); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/TicketCheckpointService.ts b/apps/server/src/workflow/Layers/TicketCheckpointService.ts new file mode 100644 index 00000000000..7b5639391a8 --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketCheckpointService.ts @@ -0,0 +1,61 @@ +import { CheckpointRef } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + TicketCheckpointService, + type TicketCheckpointServiceShape, +} from "../Services/TicketCheckpointService.ts"; +import { ticketBaseRef, ticketStepRef } from "../ticketRefs.ts"; + +const toCheckpointError = (cause: unknown) => + new WorkflowEventStoreError({ message: "checkpoint op failed", cause }); + +const wrap = (effect: Effect.Effect) => effect.pipe(Effect.mapError(toCheckpointError)); + +const make = Effect.gen(function* () { + const checkpoints = yield* CheckpointStore; + + const captureBaseline: TicketCheckpointServiceShape["captureBaseline"] = (ticketId, cwd) => + Effect.gen(function* () { + const ref = ticketBaseRef(ticketId); + yield* wrap( + checkpoints.captureCheckpoint({ + cwd, + checkpointRef: CheckpointRef.make(ref), + }), + ); + return ref; + }); + + const hasBaseline: TicketCheckpointServiceShape["hasBaseline"] = (ticketId, cwd) => + wrap( + checkpoints.hasCheckpointRef({ + cwd, + checkpointRef: CheckpointRef.make(ticketBaseRef(ticketId)), + }), + ); + + const captureStep: TicketCheckpointServiceShape["captureStep"] = ( + ticketId, + stepRunId, + cwd, + kind, + ) => + Effect.gen(function* () { + const ref = ticketStepRef(ticketId, stepRunId, kind); + yield* wrap( + checkpoints.captureCheckpoint({ + cwd, + checkpointRef: CheckpointRef.make(ref), + }), + ); + return ref; + }); + + return { captureBaseline, hasBaseline, captureStep } satisfies TicketCheckpointServiceShape; +}); + +export const TicketCheckpointServiceLive = Layer.effect(TicketCheckpointService, make); diff --git a/apps/server/src/workflow/Layers/TicketDiffQuery.test.ts b/apps/server/src/workflow/Layers/TicketDiffQuery.test.ts new file mode 100644 index 00000000000..c3e011a0048 --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketDiffQuery.test.ts @@ -0,0 +1,125 @@ +// @effect-diagnostics nodeBuiltinImport:off +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import type { VcsError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Scope from "effect/Scope"; + +import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { TicketCheckpointService } from "../Services/TicketCheckpointService.ts"; +import { TicketDiffQuery } from "../Services/TicketDiffQuery.ts"; +import { TicketCheckpointServiceLive } from "./TicketCheckpointService.ts"; +import { TicketDiffQueryLive, WorktreeDiffPortLive } from "./TicketDiffQuery.ts"; + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-ticket-diff-test-", +}); +const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); +const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcessTestLayer)); +const GitVcsDriverTestLayer = GitVcsDriver.layer.pipe( + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(NodeServices.layer), +); + +const layer = it.layer( + TicketDiffQueryLive.pipe( + Layer.provideMerge(WorktreeDiffPortLive), + Layer.provideMerge(TicketCheckpointServiceLive), + Layer.provideMerge(CheckpointStoreLive), + Layer.provideMerge(GitVcsDriverTestLayer), + Layer.provideMerge(VcsDriverTestLayer), + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + ), +); + +const makeTmpDir = ( + prefix = "ticket-diff-test-", +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); + +const writeTextFile = ( + filePath: string, + contents: string, +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(filePath, contents); + }); + +const git = ( + cwd: string, + args: ReadonlyArray, +): Effect.Effect => + Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const result = yield* process.run({ + operation: "TicketDiffQuery.test.git", + command: "git", + cwd, + args, + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +const initRepoWithCommit = ( + cwd: string, +): Effect.Effect< + void, + VcsError | PlatformError.PlatformError, + VcsProcess.VcsProcess | FileSystem.FileSystem +> => + Effect.gen(function* () { + yield* git(cwd, ["init"]); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(path.join(cwd, "README.md"), "# original\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial commit"]); + }); + +layer("TicketDiffQuery", (it) => { + it.effect("returns accumulated base-to-worktree diff for tracked and untracked files", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointService = yield* TicketCheckpointService; + const query = yield* TicketDiffQuery; + const ticketId = "t-1" as never; + + const baseRef = yield* checkpointService.captureBaseline(ticketId, tmp); + yield* writeTextFile(path.join(tmp, "README.md"), "# changed\n"); + yield* writeTextFile(path.join(tmp, "notes.txt"), "new note\n"); + + const diff = yield* query.getTicketDiff(ticketId, tmp, baseRef); + + assert.equal(diff.ticketId, ticketId); + assert.equal(diff.baseRef, baseRef); + assert.equal(diff.truncated, false); + assert.include(diff.patch, "diff --git"); + assert.include(diff.patch, "README.md"); + assert.include(diff.patch, "notes.txt"); + assert.deepEqual( + new Map(diff.files.map((file) => [file.path, file])), + new Map([ + ["README.md", { path: "README.md", additions: 1, deletions: 1 }], + ["notes.txt", { path: "notes.txt", additions: 1, deletions: 0 }], + ]), + ); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/TicketDiffQuery.ts b/apps/server/src/workflow/Layers/TicketDiffQuery.ts new file mode 100644 index 00000000000..867a549dd84 --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketDiffQuery.ts @@ -0,0 +1,99 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts"; +import { GitVcsDriver } from "../../vcs/GitVcsDriver.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + TicketDiffQuery, + WorktreeDiffPort, + type TicketDiffQueryShape, + type WorktreeDiffPortShape, +} from "../Services/TicketDiffQuery.ts"; + +const make = Effect.gen(function* () { + const port = yield* WorktreeDiffPort; + + const getTicketDiff: TicketDiffQueryShape["getTicketDiff"] = (ticketId, cwd, baseRef) => + Effect.gen(function* () { + const { patch, truncated } = yield* port.diffRefToWorktree({ cwd, baseRef }); + const files = parseTurnDiffFilesFromUnifiedDiff(patch); + + return { + ticketId, + baseRef, + patch, + files, + truncated, + }; + }); + + return { getTicketDiff } satisfies TicketDiffQueryShape; +}); + +export const TicketDiffQueryLive = Layer.effect(TicketDiffQuery, make); + +export const WorktreeDiffPortLive = Layer.effect( + WorktreeDiffPort, + Effect.gen(function* () { + const git = yield* GitVcsDriver; + + const diffRefToWorktree: WorktreeDiffPortShape["diffRefToWorktree"] = ({ cwd, baseRef }) => + Effect.gen(function* () { + const tracked = yield* git.execute({ + operation: "WorkflowTicketDiff.tracked", + cwd, + args: ["diff", "--patch", "--minimal", `${baseRef}^{commit}`, "--"], + maxOutputBytes: 120_000, + appendTruncationMarker: true, + }); + const untrackedList = yield* git + .execute({ + operation: "WorkflowTicketDiff.untracked.list", + cwd, + args: ["ls-files", "--others", "--exclude-standard", "-z"], + maxOutputBytes: 120_000, + appendTruncationMarker: true, + }) + .pipe( + Effect.orElseSucceed(() => ({ + stdout: "", + stdoutTruncated: false, + })), + ); + const untrackedPaths = untrackedList.stdout.split("\0").filter((path) => path.length > 0); + const untrackedDiffs = yield* Effect.forEach( + untrackedPaths, + (path) => + git.execute({ + operation: "WorkflowTicketDiff.untracked.diff", + cwd, + args: ["diff", "--no-index", "--patch", "--minimal", "--", "/dev/null", path], + allowNonZeroExit: true, + maxOutputBytes: 120_000, + appendTruncationMarker: true, + }), + { concurrency: 4 }, + ); + + return { + patch: [ + tracked.stdout.trimEnd(), + ...untrackedDiffs.map((result) => result.stdout.trimEnd()), + ] + .filter((part) => part.length > 0) + .join("\n"), + truncated: + tracked.stdoutTruncated || + untrackedList.stdoutTruncated || + untrackedDiffs.some((result) => result.stdoutTruncated), + }; + }).pipe( + Effect.mapError( + (cause) => new WorkflowEventStoreError({ message: "ticket diff failed", cause }), + ), + ); + + return { diffRefToWorktree } satisfies WorktreeDiffPortShape; + }), +); diff --git a/apps/server/src/workflow/Layers/TicketMergeService.test.ts b/apps/server/src/workflow/Layers/TicketMergeService.test.ts new file mode 100644 index 00000000000..88aea136740 --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketMergeService.test.ts @@ -0,0 +1,253 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { MergeStep, TicketId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { + MergeGitPort, + TicketMergeService, + type MergeGitResult, +} from "../Services/TicketMergeService.ts"; +import { WorkflowReadModel, type WorkflowReadModelShape } from "../Services/WorkflowReadModel.ts"; +import { TicketMergeServiceLive } from "./TicketMergeService.ts"; + +interface RecordedGitCall { + readonly cwd: string; + readonly args: ReadonlyArray; +} + +interface GitScript { + readonly worktreeStatus?: string; + readonly repoStatus?: string; + readonly branch?: string; + readonly aheadCount?: string; + readonly mergeResult?: MergeGitResult; +} + +const mergeInput = (step: Partial = {}) => ({ + ticketId: "ticket-merge" as TicketId, + repoRoot: "/repo", + worktreePath: "/repo-worktrees/ticket-merge", + worktreeRef: "workflow/ticket-merge", + step: { + key: "land" as never, + type: "merge" as const, + ...step, + }, +}); + +const stubReadModel = Layer.succeed(WorkflowReadModel, { + getTicketDetail: () => + Effect.succeed({ + ticket: { + ticketId: "ticket-merge", + boardId: "board-1", + title: "Fix login", + description: null, + currentLaneKey: "land", + currentLaneEntryToken: "token-1", + queuedAt: null, + status: "running", + }, + steps: [], + messages: [], + }), +} as unknown as WorkflowReadModelShape); + +const makeHarness = (script: GitScript) => { + const calls: Array = []; + const layer = TicketMergeServiceLive.pipe( + Layer.provideMerge( + Layer.succeed(MergeGitPort, { + run: (input) => + Effect.sync(() => { + calls.push({ cwd: input.cwd, args: input.args }); + const command = input.args[0]; + if (command === "status") { + return { + exitCode: 0, + stdout: + input.cwd === "/repo" ? (script.repoStatus ?? "") : (script.worktreeStatus ?? ""), + stderr: "", + }; + } + if (command === "rev-parse") { + return { exitCode: 0, stdout: `${script.branch ?? "main"}\n`, stderr: "" }; + } + if (command === "rev-list") { + return { exitCode: 0, stdout: `${script.aheadCount ?? "1"}\n`, stderr: "" }; + } + if (command === "merge" && input.args[1] !== "--abort") { + return script.mergeResult ?? { exitCode: 0, stdout: "", stderr: "" }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }), + }), + ), + Layer.provideMerge(stubReadModel), + ); + return { calls, layer }; +}; + +describe("TicketMergeService", () => { + it.effect("merges the ticket branch into the checked-out branch", () => + Effect.gen(function* () { + const harness = makeHarness({}); + const outcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge(mergeInput()); + }).pipe(Effect.provide(harness.layer)); + + assert.deepEqual(outcome, { _tag: "completed" }); + const mergeCall = harness.calls.find( + (call) => call.args[0] === "merge" && call.args[1] !== "--abort", + ); + assert.deepEqual(mergeCall?.args, [ + "merge", + "--no-ff", + "--no-verify", + "-m", + "Fix login (ticket-merge)", + "workflow/ticket-merge", + ]); + assert.equal(mergeCall?.cwd, "/repo"); + }), + ); + + it.effect("snapshots dirty worktree changes before merging", () => + Effect.gen(function* () { + const harness = makeHarness({ worktreeStatus: " M src/app.ts\n" }); + const outcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge(mergeInput({ commitMessage: "Land it" })); + }).pipe(Effect.provide(harness.layer)); + + assert.deepEqual(outcome, { _tag: "completed" }); + const commitCall = harness.calls.find((call) => call.args[0] === "commit"); + assert.equal(commitCall?.cwd, "/repo-worktrees/ticket-merge"); + assert.deepEqual(commitCall?.args, ["commit", "--no-verify", "-m", "Land it"]); + assert.ok(harness.calls.some((call) => call.args[0] === "add")); + }), + ); + + it.effect("blocks when the repo working tree is dirty", () => + Effect.gen(function* () { + const harness = makeHarness({ repoStatus: " M README.md\n" }); + const outcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge(mergeInput()); + }).pipe(Effect.provide(harness.layer)); + + assert.equal(outcome._tag, "blocked"); + assert.ok(harness.calls.every((call) => call.args[0] !== "merge")); + }), + ); + + it.effect("blocks on detached HEAD or mismatched target branch", () => + Effect.gen(function* () { + const detached = makeHarness({ branch: "HEAD" }); + const detachedOutcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge(mergeInput()); + }).pipe(Effect.provide(detached.layer)); + assert.equal(detachedOutcome._tag, "blocked"); + + const mismatch = makeHarness({ branch: "feature/x" }); + const mismatchOutcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge(mergeInput({ target: "main" })); + }).pipe(Effect.provide(mismatch.layer)); + assert.equal(mismatchOutcome._tag, "blocked"); + assert.ok(mismatchOutcome._tag === "blocked" && mismatchOutcome.reason.includes("feature/x")); + }), + ); + + it.effect("completes without merging when there is nothing to merge", () => + Effect.gen(function* () { + const harness = makeHarness({ aheadCount: "0" }); + const outcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge(mergeInput()); + }).pipe(Effect.provide(harness.layer)); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.ok(harness.calls.every((call) => call.args[0] !== "merge")); + }), + ); + + it.effect("aborts and blocks on merge conflicts", () => + Effect.gen(function* () { + const harness = makeHarness({ + mergeResult: { + exitCode: 1, + stdout: "CONFLICT (content): Merge conflict in src/app.ts\n", + stderr: "", + }, + }); + const outcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge(mergeInput()); + }).pipe(Effect.provide(harness.layer)); + + assert.equal(outcome._tag, "blocked"); + assert.ok(outcome._tag === "blocked" && outcome.reason.includes("src/app.ts")); + assert.ok( + harness.calls.some((call) => call.args[0] === "merge" && call.args[1] === "--abort"), + ); + }), + ); +}); + +describe("TicketMergeService cleanup", () => { + it.effect("removes cleanup paths from the worktree before the snapshot commit", () => + Effect.gen(function* () { + const harness = makeHarness({ worktreeStatus: " M src/app.ts\n" }); + const outcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge(mergeInput({ cleanupPaths: ["PLAN.md", "REVIEW.md"] as never })); + }).pipe(Effect.provide(harness.layer)); + + assert.deepEqual(outcome, { _tag: "completed" }); + const cleanupCalls = harness.calls.filter( + (call) => call.args[0] === "rm" || call.args[0] === "clean", + ); + assert.equal(cleanupCalls.length, 4); + assert.ok(cleanupCalls.every((call) => call.cwd === "/repo-worktrees/ticket-merge")); + assert.ok( + cleanupCalls.some((call) => call.args[0] === "rm" && call.args.includes("PLAN.md")), + ); + assert.ok( + cleanupCalls.some((call) => call.args[0] === "clean" && call.args.includes("REVIEW.md")), + ); + const firstCommitIndex = harness.calls.findIndex((call) => call.args[0] === "commit"); + const lastCleanupIndex = harness.calls.reduce( + (latest, call, index) => + call.args[0] === "rm" || call.args[0] === "clean" ? index : latest, + -1, + ); + assert.ok(lastCleanupIndex < firstCommitIndex); + }), + ); +}); + +describe("TicketMergeService cleanup templating", () => { + it.effect("substitutes the ticket id into cleanup paths and removes directories", () => + Effect.gen(function* () { + const harness = makeHarness({}); + const outcome = yield* Effect.gen(function* () { + const merges = yield* TicketMergeService; + return yield* merges.merge( + mergeInput({ cleanupPaths: [".t3/ticket/{{ticket.id}}"] as never }), + ); + }).pipe(Effect.provide(harness.layer)); + + assert.deepEqual(outcome, { _tag: "completed" }); + const rmCall = harness.calls.find((call) => call.args[0] === "rm"); + assert.ok(rmCall?.args.includes(".t3/ticket/ticket-merge")); + assert.ok(rmCall?.args.includes("-r")); + const cleanCall = harness.calls.find((call) => call.args[0] === "clean"); + assert.ok(cleanCall?.args.includes(".t3/ticket/ticket-merge")); + assert.ok(cleanCall?.args.includes("-d")); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/TicketMergeService.ts b/apps/server/src/workflow/Layers/TicketMergeService.ts new file mode 100644 index 00000000000..94480618a48 --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketMergeService.ts @@ -0,0 +1,170 @@ +import type { StepOutcome } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { GitVcsDriver } from "../../vcs/GitVcsDriver.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + MergeGitPort, + TicketMergeService, + type MergeGitPortShape, + type TicketMergeServiceShape, +} from "../Services/TicketMergeService.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; + +const blocked = (reason: string): StepOutcome => ({ _tag: "blocked", reason }); +const completed: StepOutcome = { _tag: "completed" }; + +const firstLine = (text: string) => text.trim().split("\n")[0] ?? ""; + +// Only the path-safe ticket id is templated into cleanup paths — titles and +// other free text could smuggle path segments into a git rm. +const resolveCleanupPath = (path: string, ticketId: string): string => + path.replace(/\{\{\s*ticket\.id\s*\}\}/g, ticketId); + +const conflictSummary = (output: string) => { + const lines = output + .split("\n") + .filter((line) => line.includes("CONFLICT")) + .slice(0, 5); + return lines.join("; "); +}; + +const make = Effect.gen(function* () { + const git = yield* MergeGitPort; + const read = yield* WorkflowReadModel; + + const merge: TicketMergeServiceShape["merge"] = (input) => + Effect.gen(function* () { + const detail = yield* read.getTicketDetail(input.ticketId); + const rawMessage = input.step.commitMessage?.trim(); + const message = + rawMessage !== undefined && rawMessage.length > 0 + ? rawMessage + : `${detail?.ticket.title ?? "workflow ticket"} (${input.ticketId})`; + + // Working files like PLAN.md / REVIEW.md are pipeline scratch space — + // drop them before the snapshot so they never land in the target branch. + for (const rawCleanupPath of input.step.cleanupPaths ?? []) { + const cleanupPath = resolveCleanupPath(rawCleanupPath as string, input.ticketId as string); + // rm covers tracked files, clean covers untracked ones (-d so a + // per-ticket scratch directory disappears with its files). + yield* git + .run({ + cwd: input.worktreePath, + args: ["rm", "-r", "-f", "--ignore-unmatch", "--", cleanupPath], + allowNonZeroExit: true, + }) + .pipe(Effect.ignore); + yield* git + .run({ + cwd: input.worktreePath, + args: ["clean", "-f", "-d", "--", cleanupPath], + allowNonZeroExit: true, + }) + .pipe(Effect.ignore); + } + + // Snapshot any uncommitted agent work onto the ticket branch so the + // merge carries the full accumulated state, not just prior commits. + const worktreeStatus = yield* git.run({ + cwd: input.worktreePath, + args: ["status", "--porcelain"], + }); + if (worktreeStatus.stdout.trim().length > 0) { + yield* git.run({ cwd: input.worktreePath, args: ["add", "-A"] }); + yield* git.run({ + cwd: input.worktreePath, + args: ["commit", "--no-verify", "-m", message], + }); + } + + // Preconditions on the repo checkout. Anything a human can fix by + // tidying the repo is blocked (not failed) and never mutates state: + // we refuse to touch a dirty tree and never switch the user's branch. + const repoStatus = yield* git.run({ + cwd: input.repoRoot, + args: ["status", "--porcelain"], + }); + if (repoStatus.stdout.trim().length > 0) { + return blocked( + "Repo working tree has uncommitted changes; commit or stash them, then re-run the lane.", + ); + } + + const branch = (yield* git.run({ + cwd: input.repoRoot, + args: ["rev-parse", "--abbrev-ref", "HEAD"], + })).stdout.trim(); + if (branch === "HEAD") { + return blocked("Repo is on a detached HEAD; check out a branch first."); + } + if (input.step.target !== undefined && branch !== input.step.target) { + return blocked( + `Repo has "${branch}" checked out but this step merges into "${input.step.target}".`, + ); + } + + const ahead = (yield* git.run({ + cwd: input.repoRoot, + args: ["rev-list", "--count", `HEAD..${input.worktreeRef}`], + })).stdout.trim(); + if (ahead === "0") { + return completed; + } + + const result = yield* git.run({ + cwd: input.repoRoot, + args: ["merge", "--no-ff", "--no-verify", "-m", message, input.worktreeRef], + allowNonZeroExit: true, + }); + if (result.exitCode !== 0) { + yield* git + .run({ cwd: input.repoRoot, args: ["merge", "--abort"], allowNonZeroExit: true }) + .pipe(Effect.ignore); + const conflicts = conflictSummary(`${result.stdout}\n${result.stderr}`); + return blocked( + conflicts.length > 0 + ? `Merge conflict: ${conflicts}` + : `Merge failed: ${firstLine(result.stderr) || firstLine(result.stdout) || "unknown git error"}`, + ); + } + + return completed; + }); + + return { merge } satisfies TicketMergeServiceShape; +}); + +export const TicketMergeServiceLive = Layer.effect(TicketMergeService, make); + +export const MergeGitPortLive = Layer.effect( + MergeGitPort, + Effect.gen(function* () { + const git = yield* GitVcsDriver; + + const run: MergeGitPortShape["run"] = (input) => + git + .execute({ + operation: "WorkflowTicketMerge", + cwd: input.cwd, + args: [...input.args], + ...(input.allowNonZeroExit === undefined + ? {} + : { allowNonZeroExit: input.allowNonZeroExit }), + }) + .pipe( + Effect.map((result) => ({ + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + })), + Effect.mapError( + (cause) => + new WorkflowEventStoreError({ message: "workflow merge git command failed", cause }), + ), + ); + + return { run } satisfies MergeGitPortShape; + }), +); diff --git a/apps/server/src/workflow/Layers/TurnStateReader.test.ts b/apps/server/src/workflow/Layers/TurnStateReader.test.ts new file mode 100644 index 00000000000..fc371692fce --- /dev/null +++ b/apps/server/src/workflow/Layers/TurnStateReader.test.ts @@ -0,0 +1,102 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { TurnProjectionPort, TurnStateReader } from "../Services/TurnStateReader.ts"; +import { TurnProjectionPortLive, TurnStateReaderLive } from "./TurnStateReader.ts"; + +const stub = (state: string) => + Layer.succeed(TurnProjectionPort, { + getLatestTurnState: () => + Effect.succeed({ state, completed: state === "completed" || state === "error" }), + }); + +const mk = (state: string) => + it.layer( + TurnStateReaderLive.pipe( + Layer.provideMerge(stub(state)), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ); + +mk("completed")("TurnStateReader completed", (it) => { + it.effect("maps completed", () => + Effect.gen(function* () { + const reader = yield* TurnStateReader; + const result = yield* reader.read("thread-1" as never); + assert.equal(result._tag, "completed"); + }), + ); +}); + +mk("error")("TurnStateReader error", (it) => { + it.effect("maps error to failed", () => + Effect.gen(function* () { + const reader = yield* TurnStateReader; + const result = yield* reader.read("thread-1" as never); + assert.equal(result._tag, "failed"); + }), + ); +}); + +mk("running")("TurnStateReader running", (it) => { + it.effect("maps running", () => + Effect.gen(function* () { + const reader = yield* TurnStateReader; + const result = yield* reader.read("thread-1" as never); + assert.equal(result._tag, "running"); + }), + ); +}); + +const liveProjectionLayer = it.layer( + TurnStateReaderLive.pipe( + Layer.provideMerge(TurnProjectionPortLive), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +liveProjectionLayer("TurnStateReader live projection", (it) => { + it.effect("maps running completed and error through the live turn projection", () => + Effect.gen(function* () { + const turns = yield* ProjectionTurnRepository; + const reader = yield* TurnStateReader; + const upsert = (threadId: string, turnId: string, state: "running" | "completed" | "error") => + turns.upsertByTurnId({ + threadId: threadId as never, + turnId: turnId as never, + pendingMessageId: null, + sourceProposedPlanThreadId: null, + sourceProposedPlanId: null, + assistantMessageId: null, + state, + requestedAt: "2026-06-07T00:00:00.000Z" as never, + startedAt: "2026-06-07T00:00:00.000Z" as never, + completedAt: state === "running" ? null : ("2026-06-07T00:00:01.000Z" as never), + checkpointTurnCount: null, + checkpointRef: null, + checkpointStatus: null, + checkpointFiles: [], + }); + + yield* upsert("thread-live-running", "turn-live-running", "running"); + yield* upsert("thread-live-completed", "turn-live-completed", "completed"); + yield* upsert("thread-live-error", "turn-live-error", "error"); + + assert.equal((yield* reader.read("thread-live-running" as never))._tag, "running"); + assert.equal((yield* reader.read("thread-live-completed" as never))._tag, "completed"); + const failed = yield* reader.read("thread-live-error" as never); + assert.equal(failed._tag, "failed"); + if (failed._tag === "failed") { + assert.equal(failed.error, "error"); + } + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/TurnStateReader.ts b/apps/server/src/workflow/Layers/TurnStateReader.ts new file mode 100644 index 00000000000..d29a4e48af7 --- /dev/null +++ b/apps/server/src/workflow/Layers/TurnStateReader.ts @@ -0,0 +1,158 @@ +import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; +import { + TurnProjectionPort, + TurnStateReader, + type TurnProjectionPortShape, + type TurnState, + type TurnStateReaderShape, +} from "../Services/TurnStateReader.ts"; + +interface PendingProviderRequestRow { + readonly requestId: string; +} + +interface PendingUserInputRow { + readonly requestId: string; + readonly prompt: string | null; + readonly questionId: string | null; +} + +const toTurnState = (state: string): TurnState => { + if (state === "completed") { + return { _tag: "completed" }; + } + if (state === "error" || state === "interrupted") { + return { _tag: "failed", error: state }; + } + return { _tag: "running" }; +}; + +const make = Effect.gen(function* () { + const port = yield* TurnProjectionPort; + const sql = yield* SqlClient.SqlClient; + + const pendingProviderRequest = (threadId: ThreadId) => + sql` + SELECT request_id AS "requestId" + FROM projection_pending_approvals + WHERE thread_id = ${threadId} + AND status = 'pending' + ORDER BY created_at ASC + LIMIT 1 + `.pipe( + Effect.map((rows) => rows[0] ?? null), + Effect.orElseSucceed(() => null), + ); + + const pendingUserInputRequest = (threadId: ThreadId) => + sql` + WITH latest_user_input_states AS ( + SELECT + latest.request_id AS "requestId", + latest.question_id AS "questionId", + latest.prompt, + latest.kind, + latest.detail + FROM ( + SELECT + json_extract(activity.payload_json, '$.requestId') AS request_id, + json_extract(activity.payload_json, '$.questions[0].id') AS question_id, + json_extract(activity.payload_json, '$.questions[0].question') AS prompt, + activity.kind, + lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) AS detail, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(activity.payload_json, '$.requestId') + ORDER BY activity.created_at DESC, activity.activity_id DESC + ) AS row_number + FROM projection_thread_activities AS activity + WHERE activity.thread_id = ${threadId} + AND json_extract(activity.payload_json, '$.requestId') IS NOT NULL + AND activity.kind IN ( + 'user-input.requested', + 'user-input.resolved', + 'provider.user-input.respond.failed' + ) + ) AS latest + WHERE latest.row_number = 1 + ) + SELECT "requestId" + , "questionId" + , prompt + FROM latest_user_input_states + WHERE kind = 'user-input.requested' + OR ( + kind = 'provider.user-input.respond.failed' + AND detail NOT LIKE '%stale pending user-input request%' + AND detail NOT LIKE '%unknown pending user-input request%' + ) + ORDER BY "requestId" ASC + LIMIT 1 + `.pipe( + Effect.map((rows) => rows[0] ?? null), + Effect.orElseSucceed(() => null), + ); + + const read: TurnStateReaderShape["read"] = (threadId) => + Effect.gen(function* () { + const { state } = yield* port.getLatestTurnState(threadId); + const turnState = toTurnState(state); + if (turnState._tag !== "running") { + return turnState; + } + + const pending = yield* pendingProviderRequest(threadId); + if (pending) { + return { + _tag: "awaiting_user", + waitingReason: "Provider is waiting for user input", + providerThreadId: threadId, + providerRequestId: ApprovalRequestId.make(pending.requestId), + providerResponseKind: "request", + } satisfies TurnState; + } + const pendingUserInput = yield* pendingUserInputRequest(threadId); + if (pendingUserInput) { + return { + _tag: "awaiting_user", + waitingReason: pendingUserInput.prompt ?? "Provider is waiting for user input", + providerThreadId: threadId, + providerRequestId: ApprovalRequestId.make(pendingUserInput.requestId), + providerResponseKind: "user-input", + ...(pendingUserInput.questionId === null + ? {} + : { providerQuestionId: pendingUserInput.questionId }), + } satisfies TurnState; + } + return turnState; + }); + + return { read } satisfies TurnStateReaderShape; +}); + +export const TurnStateReaderLive = Layer.effect(TurnStateReader, make); + +export const TurnProjectionPortLive = Layer.effect( + TurnProjectionPort, + Effect.gen(function* () { + const turns = yield* ProjectionTurnRepository; + + const getLatestTurnState: TurnProjectionPortShape["getLatestTurnState"] = (threadId) => + turns.listByThreadId({ threadId }).pipe( + Effect.map((rows) => rows.at(-1)), + Effect.map((turn) => ({ + state: turn?.state ?? "pending", + // Mirrors toTurnState: interrupted turns are terminal too. + completed: + turn?.state === "completed" || turn?.state === "error" || turn?.state === "interrupted", + })), + Effect.orElseSucceed(() => ({ state: "pending", completed: false })), + ); + + return { getLatestTurnState } satisfies TurnProjectionPortShape; + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowBoardEvents.test.ts b/apps/server/src/workflow/Layers/WorkflowBoardEvents.test.ts new file mode 100644 index 00000000000..b4c02e50366 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardEvents.test.ts @@ -0,0 +1,64 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowBoardEvents } from "../Services/WorkflowBoardEvents.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { WorkflowBoardEventsLive } from "./WorkflowBoardEvents.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; + +const layer = it.layer( + WorkflowEventCommitterLive.pipe( + Layer.provideMerge(WorkflowBoardEventsLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowBoardEvents", (it) => { + it.effect("publishes a ticket delta after the committer projects a ticket event", () => + Effect.gen(function* () { + const events = yield* WorkflowBoardEvents; + const committer = yield* WorkflowEventCommitter; + const registry = yield* BoardRegistry; + yield* registry.register("b-1" as never, { + name: "Board events", + lanes: [{ key: "backlog", name: "Backlog", entry: "manual" }], + }); + const deltasFiber = yield* events + .stream("b-1" as never) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped); + yield* Effect.yieldNow; + + yield* committer.commit({ + type: "TicketCreated", + eventId: "e1" as never, + ticketId: "t-1" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-1" as never, + title: "Board delta" as never, + laneKey: "backlog" as never, + }, + }); + + const deltas = Array.from(yield* Fiber.join(deltasFiber)); + assert.equal(deltas[0]?.ticketId, "t-1"); + assert.equal(deltas[0]?.boardId, "b-1"); + assert.equal(deltas[0]?.title, "Board delta"); + assert.equal(deltas[0]?.currentLaneKey, "backlog"); + assert.equal(deltas[0]?.status, "idle"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowBoardEvents.ts b/apps/server/src/workflow/Layers/WorkflowBoardEvents.ts new file mode 100644 index 00000000000..429cf04f280 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardEvents.ts @@ -0,0 +1,23 @@ +import type { BoardTicketView } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Stream from "effect/Stream"; + +import { + WorkflowBoardEvents, + type WorkflowBoardEventsShape, +} from "../Services/WorkflowBoardEvents.ts"; + +const make = Effect.gen(function* () { + const pubsub = yield* PubSub.unbounded(); + + const publish: WorkflowBoardEventsShape["publish"] = (ticket) => + PubSub.publish(pubsub, ticket).pipe(Effect.asVoid); + const stream: WorkflowBoardEventsShape["stream"] = (boardId) => + Stream.fromPubSub(pubsub).pipe(Stream.filter((ticket) => ticket.boardId === boardId)); + + return { publish, stream } satisfies WorkflowBoardEventsShape; +}); + +export const WorkflowBoardEventsLive = Layer.effect(WorkflowBoardEvents, make); diff --git a/apps/server/src/workflow/Layers/WorkflowBoardSaveLocks.ts b/apps/server/src/workflow/Layers/WorkflowBoardSaveLocks.ts new file mode 100644 index 00000000000..bcdd14c2d21 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardSaveLocks.ts @@ -0,0 +1,44 @@ +import type { BoardId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Semaphore from "effect/Semaphore"; +import * as SynchronizedRef from "effect/SynchronizedRef"; + +import { + WorkflowBoardSaveLocks, + type WorkflowBoardSaveLocksShape, +} from "../Services/WorkflowBoardSaveLocks.ts"; + +export const makeWorkflowBoardSaveLocks = Effect.gen(function* () { + const saveSemaphores = yield* SynchronizedRef.make>(new Map()); + + const semaphoreFor = (boardId: BoardId) => + SynchronizedRef.modifyEffect(saveSemaphores, (current) => { + const key = boardId as string; + const existing = current.get(key); + if (existing) { + return Effect.succeed([existing, current] as const); + } + + return Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(key, semaphore); + return [semaphore, next] as const; + }), + ); + }); + + const withSaveLock: WorkflowBoardSaveLocksShape["withSaveLock"] = (boardId, effect) => + Effect.gen(function* () { + const semaphore = yield* semaphoreFor(boardId); + return yield* semaphore.withPermits(1)(effect); + }); + + return { withSaveLock } satisfies WorkflowBoardSaveLocksShape; +}); + +export const WorkflowBoardSaveLocksLive = Layer.effect( + WorkflowBoardSaveLocks, + makeWorkflowBoardSaveLocks, +); diff --git a/apps/server/src/workflow/Layers/WorkflowBoardVersionStore.test.ts b/apps/server/src/workflow/Layers/WorkflowBoardVersionStore.test.ts new file mode 100644 index 00000000000..877166d2e6b --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardVersionStore.test.ts @@ -0,0 +1,88 @@ +import { BoardId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { WorkflowBoardVersionStore } from "../Services/WorkflowBoardVersionStore.ts"; +import { WorkflowBoardVersionStoreLive } from "./WorkflowBoardVersionStore.ts"; + +const storeLayer = it.layer( + WorkflowBoardVersionStoreLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +storeLayer("WorkflowBoardVersionStore", (it) => { + it.effect("dedups only consecutive hashes and keeps A-B-A versions distinct", () => + Effect.gen(function* () { + const store = yield* WorkflowBoardVersionStore; + const boardId = BoardId.make("board-history"); + const otherBoardId = BoardId.make("board-history-other"); + + yield* store.record({ + boardId, + versionHash: "hash-a", + contentJson: '{"name":"A"}\n', + source: "create", + }); + yield* store.record({ + boardId, + versionHash: "hash-a", + contentJson: '{"name":"A"}\n', + source: "save", + }); + yield* store.record({ + boardId, + versionHash: "hash-b", + contentJson: '{"name":"B"}\n', + source: "save", + }); + yield* store.record({ + boardId: otherBoardId, + versionHash: "hash-other", + contentJson: '{"name":"other"}\n', + source: "create", + }); + yield* store.record({ + boardId, + versionHash: "hash-a", + contentJson: '{"name":"A"}\n', + source: "revert", + }); + + const versions = yield* store.list(boardId); + assert.equal(versions.length, 3); + assert.deepEqual( + versions.map((version) => version.versionHash), + ["hash-a", "hash-b", "hash-a"], + ); + assert.deepEqual( + versions.map((version) => version.source), + ["revert", "save", "create"], + ); + assert.deepEqual(new Set(versions.map((version) => version.versionId)).size, versions.length); + assert.isTrue(versions.every((version) => version.createdAt.length > 0)); + + const newest = versions[0]; + assert.isDefined(newest); + const loaded = yield* store.get(boardId, newest.versionId); + assert.deepEqual(loaded, { + versionId: newest.versionId, + versionHash: "hash-a", + contentJson: '{"name":"A"}\n', + source: "revert", + createdAt: newest.createdAt, + }); + + const wrongBoard = yield* store.get(otherBoardId, newest.versionId); + assert.isNull(wrongBoard); + + yield* store.deleteForBoard(boardId); + assert.deepEqual(yield* store.list(boardId), []); + assert.equal((yield* store.list(otherBoardId)).length, 1); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowBoardVersionStore.ts b/apps/server/src/workflow/Layers/WorkflowBoardVersionStore.ts new file mode 100644 index 00000000000..8d1f2e67e9c --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardVersionStore.ts @@ -0,0 +1,100 @@ +import type { BoardId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorkflowBoardVersionStore, + type WorkflowBoardVersionRow, + type WorkflowBoardVersionStoreShape, + type WorkflowBoardVersionSummaryRow, +} from "../Services/WorkflowBoardVersionStore.ts"; + +const toStoreError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrap = (message: string, effect: Effect.Effect) => + effect.pipe(Effect.mapError(toStoreError(message))); + +const boardIdValue = (boardId: BoardId) => String(boardId); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const record: WorkflowBoardVersionStoreShape["record"] = (input) => + Effect.gen(function* () { + const boardId = boardIdValue(input.boardId); + const newest = yield* wrap( + "WorkflowBoardVersionStore.record:readNewest", + sql<{ readonly versionHash: string }>` + SELECT version_hash AS "versionHash" + FROM workflow_board_version + WHERE board_id = ${boardId} + ORDER BY version_id DESC + LIMIT 1 + `, + ); + if (newest[0]?.versionHash === input.versionHash) { + return; + } + + const createdAt = DateTime.formatIso(yield* DateTime.now); + yield* wrap( + "WorkflowBoardVersionStore.record:insert", + sql` + INSERT INTO workflow_board_version + (board_id, version_hash, content_json, source, created_at) + VALUES + (${boardId}, ${input.versionHash}, ${input.contentJson}, ${input.source}, ${createdAt}) + `, + ); + }); + + const list: WorkflowBoardVersionStoreShape["list"] = (boardId) => + wrap( + "WorkflowBoardVersionStore.list", + sql` + SELECT + version_id AS "versionId", + version_hash AS "versionHash", + source, + created_at AS "createdAt" + FROM workflow_board_version + WHERE board_id = ${boardIdValue(boardId)} + ORDER BY version_id DESC + `, + ); + + const get: WorkflowBoardVersionStoreShape["get"] = (boardId, versionId) => + wrap( + "WorkflowBoardVersionStore.get", + sql` + SELECT + version_id AS "versionId", + version_hash AS "versionHash", + content_json AS "contentJson", + source, + created_at AS "createdAt" + FROM workflow_board_version + WHERE board_id = ${boardIdValue(boardId)} + AND version_id = ${versionId} + LIMIT 1 + `, + ).pipe(Effect.map((rows) => rows[0] ?? null)); + + const deleteForBoard: WorkflowBoardVersionStoreShape["deleteForBoard"] = (boardId) => + wrap( + "WorkflowBoardVersionStore.deleteForBoard", + sql` + DELETE FROM workflow_board_version + WHERE board_id = ${boardIdValue(boardId)} + `, + ).pipe(Effect.asVoid); + + return { record, list, get, deleteForBoard } satisfies WorkflowBoardVersionStoreShape; +}); + +export const WorkflowBoardVersionStoreLive = Layer.effect(WorkflowBoardVersionStore, make); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.concurrency.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.concurrency.test.ts new file mode 100644 index 00000000000..aa0c1891ade --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.concurrency.test.ts @@ -0,0 +1,647 @@ +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { ProviderSessionNotFoundError } from "../../provider/Errors.ts"; +import { + ProviderService, + type ProviderServiceShape, +} from "../../provider/Services/ProviderService.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +const definition = { + name: "limited", + settings: { maxConcurrentTickets: 1 }, + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +let activeExecutions = 0; +let maxActiveExecutions = 0; + +const countingExecutor = Layer.succeed(StepExecutor, { + execute: () => + Effect.gen(function* () { + activeExecutions += 1; + maxActiveExecutions = Math.max(maxActiveExecutions, activeExecutions); + yield* Effect.sleep("20 millis"); + activeExecutions -= 1; + return { _tag: "completed" as const }; + }), +} satisfies StepExecutorShape); + +const layer = it.layer( + WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(countingExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowEngine concurrency", (it) => { + it.effect("caps simultaneously running tickets per board", () => + Effect.gen(function* () { + activeExecutions = 0; + maxActiveExecutions = 0; + + const registry = yield* BoardRegistry; + yield* registry.register("b-limit" as never, definition); + const engine = yield* WorkflowEngine; + + yield* Effect.all( + [ + engine.createTicket({ + boardId: "b-limit" as never, + title: "First", + initialLane: "impl" as never, + }), + engine.createTicket({ + boardId: "b-limit" as never, + title: "Second", + initialLane: "impl" as never, + }), + ], + { concurrency: "unbounded" }, + ); + + assert.equal(maxActiveExecutions, 1); + }), + ); + + it.effect("rejects createTicket that races after a board delete under the save lock", () => + Effect.gen(function* () { + const boardId = "b-delete-race" as never; + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const eventStore = yield* WorkflowEventStore; + const saveLocks = yield* WorkflowBoardSaveLocks; + const sql = yield* SqlClient.SqlClient; + const deleteReady = yield* Deferred.make(); + const releaseDelete = yield* Deferred.make(); + + yield* registry.register(boardId, { + name: "delete-race", + lanes: [{ key: "todo", name: "Todo", entry: "manual" }], + }); + + const deleteFiber = yield* saveLocks + .withSaveLock( + boardId, + Effect.gen(function* () { + yield* registry.unregister(boardId); + yield* eventStore.deleteForBoard(boardId); + yield* Deferred.succeed(deleteReady, undefined); + yield* Deferred.await(releaseDelete); + }), + ) + .pipe(Effect.forkChild); + + yield* Deferred.await(deleteReady); + const createFiber = yield* engine + .createTicket({ + boardId, + title: "Should not survive", + initialLane: "todo" as never, + }) + .pipe(Effect.exit, Effect.forkChild); + + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseDelete, undefined); + yield* Fiber.join(deleteFiber); + + const createResult = yield* Fiber.join(createFiber); + assert.isTrue(Exit.isFailure(createResult)); + + const counts = yield* sql<{ readonly tableName: string; readonly count: number }>` + SELECT 'projection_ticket' AS tableName, COUNT(*) AS count + FROM projection_ticket + WHERE board_id = ${boardId} + UNION ALL + SELECT 'workflow_events' AS tableName, COUNT(*) AS count + FROM workflow_events + WHERE json_extract(payload_json, '$.boardId') = ${boardId} + `; + + assert.deepEqual( + counts.map((row) => [row.tableName, row.count]), + [ + ["projection_ticket", 0], + ["workflow_events", 0], + ], + ); + }), + ); + + it.effect("does not orphan ticket messages when answerTicketStep races board delete", () => + Effect.gen(function* () { + const boardId = "b-answer-delete-race" as never; + const ticketId = "ticket-answer-delete-race" as never; + const stepRunId = "step-answer-delete-race" as never; + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const eventStore = yield* WorkflowEventStore; + const saveLocks = yield* WorkflowBoardSaveLocks; + const sql = yield* SqlClient.SqlClient; + const deleteReady = yield* Deferred.make(); + const releaseDelete = yield* Deferred.make(); + + yield* registry.register(boardId, definition); + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + ${ticketId}, + ${boardId}, + 'Delete race', + 'impl', + 'waiting_on_user', + '2026-06-08T00:00:00.000Z', + '2026-06-08T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + status, + waiting_reason, + provider_response_kind, + started_at + ) + VALUES ( + ${stepRunId}, + 'pipeline-answer-delete-race', + ${ticketId}, + 'code', + 'agent', + 'awaiting_user', + 'Need answer', + 'user-input', + '2026-06-08T00:00:00.000Z' + ) + `; + + const deleteFiber = yield* saveLocks + .withSaveLock( + boardId, + Effect.gen(function* () { + yield* registry.unregister(boardId); + yield* eventStore.deleteForBoard(boardId); + yield* sql`DELETE FROM projection_ticket WHERE board_id = ${boardId}`; + yield* Deferred.succeed(deleteReady, undefined); + yield* Deferred.await(releaseDelete); + }), + ) + .pipe(Effect.forkChild); + + yield* Deferred.await(deleteReady); + const answerFiber = yield* engine + .answerTicketStep({ + stepRunId, + text: "Use the sandbox endpoint.", + attachments: [], + }) + .pipe(Effect.exit, Effect.forkChild); + + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseDelete, undefined); + yield* Fiber.join(deleteFiber); + + const answerResult = yield* Fiber.join(answerFiber); + assert.isTrue(Exit.isSuccess(answerResult)); + + const counts = yield* sql<{ readonly tableName: string; readonly count: number }>` + SELECT 'projection_ticket' AS tableName, COUNT(*) AS count + FROM projection_ticket + WHERE board_id = ${boardId} + UNION ALL + SELECT 'projection_ticket_message' AS tableName, COUNT(*) AS count + FROM projection_ticket_message + WHERE ticket_id = ${ticketId} + UNION ALL + SELECT 'workflow_events' AS tableName, COUNT(*) AS count + FROM workflow_events + WHERE ticket_id = ${ticketId} + `; + + assert.deepEqual( + counts.map((row) => [row.tableName, row.count]), + [ + ["projection_ticket", 0], + ["projection_ticket_message", 0], + ["workflow_events", 0], + ], + ); + }), + ); +}); + +it.effect("cancelBoardPipelines interrupts and stops active provider turns for board tickets", () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make>([]); + const testLayer = WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(countingExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: (input) => + Ref.update(providerCalls, (calls) => [...calls, { kind: "interrupt", input }]), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: () => Effect.die("unused"), + stopSession: (input) => + Ref.update(providerCalls, (calls) => [...calls, { kind: "stop", input }]), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die("unused"), + getInstanceInfo: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + streamEvents: Stream.empty, + } satisfies ProviderServiceShape), + ), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const engine = yield* WorkflowEngine; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES + ('ticket-active-provider', 'board-provider-cancel', 'Active provider', 'impl', 'running', ${now}, ${now}), + ('ticket-other-provider', 'board-other-provider', 'Other provider', 'impl', 'running', ${now}, ${now}) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + turn_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at, + started_at + ) + VALUES + ('dispatch-active-provider', 'ticket-active-provider', 'step-active-provider', 'thread-active-provider', 'turn-active-provider', 'codex', 'gpt-5.5', 'cancel me', '/tmp/active-provider', 'started', ${now}, ${now}), + ('dispatch-other-provider', 'ticket-other-provider', 'step-other-provider', 'thread-other-provider', 'turn-other-provider', 'codex', 'gpt-5.5', 'keep me', '/tmp/other-provider', 'started', ${now}, ${now}), + ('dispatch-pending-provider', 'ticket-active-provider', 'step-pending-provider', 'thread-pending-provider', NULL, 'codex', 'gpt-5.5', 'not started', '/tmp/pending-provider', 'pending', ${now}, NULL) + `; + + yield* engine + .cancelBoardPipelines("board-provider-cancel" as never) + .pipe(Effect.timeout("1 second")); + + assert.deepEqual(yield* Ref.get(providerCalls), [ + { + kind: "interrupt", + input: { + threadId: "thread-active-provider", + turnId: "turn-active-provider", + }, + }, + { + kind: "stop", + input: { + threadId: "thread-active-provider", + }, + }, + { + kind: "stop", + input: { + threadId: "thread-pending-provider", + }, + }, + ]); + }).pipe(Effect.provide(testLayer)); + }), +); + +it.effect("cancelTicketPipelines interrupts and stops active provider turns for one ticket", () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make>([]); + const testLayer = WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(countingExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: (input) => + Ref.update(providerCalls, (calls) => [...calls, { kind: "interrupt", input }]), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: () => Effect.die("unused"), + stopSession: (input) => + Ref.update(providerCalls, (calls) => [...calls, { kind: "stop", input }]), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die("unused"), + getInstanceInfo: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + streamEvents: Stream.empty, + } satisfies ProviderServiceShape), + ), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const engine = yield* WorkflowEngine; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES + ('ticket-provider-delete-one', 'board-provider-delete-one', 'Delete provider', 'impl', 'running', ${now}, ${now}), + ('ticket-provider-keep-one', 'board-provider-delete-one', 'Keep provider', 'impl', 'running', ${now}, ${now}) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + turn_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at, + started_at + ) + VALUES + ('dispatch-provider-delete-one', 'ticket-provider-delete-one', 'step-provider-delete-one', 'thread-provider-delete-one', 'turn-provider-delete-one', 'codex', 'gpt-5.5', 'cancel me', '/tmp/delete-one', 'started', ${now}, ${now}), + ('dispatch-provider-keep-one', 'ticket-provider-keep-one', 'step-provider-keep-one', 'thread-provider-keep-one', 'turn-provider-keep-one', 'codex', 'gpt-5.5', 'keep me', '/tmp/keep-one', 'started', ${now}, ${now}), + ('dispatch-provider-pending-one', 'ticket-provider-delete-one', 'step-provider-pending-one', 'thread-provider-pending-one', NULL, 'codex', 'gpt-5.5', 'not started', '/tmp/pending-one', 'pending', ${now}, NULL) + `; + + yield* engine + .cancelTicketPipelines("ticket-provider-delete-one" as never) + .pipe(Effect.timeout("1 second")); + + assert.deepEqual(yield* Ref.get(providerCalls), [ + { + kind: "interrupt", + input: { + threadId: "thread-provider-delete-one", + turnId: "turn-provider-delete-one", + }, + }, + { + kind: "stop", + input: { + threadId: "thread-provider-delete-one", + }, + }, + { + kind: "stop", + input: { + threadId: "thread-provider-pending-one", + }, + }, + ]); + }).pipe(Effect.provide(testLayer)); + }), +); + +it.effect("cancelBoardPipelines treats already-stopped provider sessions as cleanup success", () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make>([]); + const testLayer = WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(countingExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: (input) => + Ref.update(providerCalls, (calls) => [...calls, { kind: "interrupt", input }]).pipe( + Effect.andThen( + Effect.fail(new ProviderSessionNotFoundError({ threadId: input.threadId })), + ), + ), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: () => Effect.die("unused"), + stopSession: (input) => + Ref.update(providerCalls, (calls) => [...calls, { kind: "stop", input }]).pipe( + Effect.andThen( + Effect.fail(new ProviderSessionNotFoundError({ threadId: input.threadId })), + ), + ), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die("unused"), + getInstanceInfo: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + streamEvents: Stream.empty, + } satisfies ProviderServiceShape), + ), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const engine = yield* WorkflowEngine; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-stale-provider', + 'board-stale-provider', + 'Stale provider', + 'impl', + 'running', + ${now}, + ${now} + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + turn_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at, + started_at + ) + VALUES ( + 'dispatch-stale-provider', + 'ticket-stale-provider', + 'step-stale-provider', + 'thread-stale-provider', + 'turn-stale-provider', + 'codex', + 'gpt-5.5', + 'already gone', + '/tmp/stale-provider', + 'started', + ${now}, + ${now} + ) + `; + + yield* engine + .cancelBoardPipelines("board-stale-provider" as never) + .pipe(Effect.timeout("1 second")); + + assert.deepEqual(yield* Ref.get(providerCalls), [ + { + kind: "interrupt", + input: { + threadId: "thread-stale-provider", + turnId: "turn-stale-provider", + }, + }, + { + kind: "stop", + input: { + threadId: "thread-stale-provider", + }, + }, + ]); + }).pipe(Effect.provide(testLayer)); + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.dependencies.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.dependencies.test.ts new file mode 100644 index 00000000000..6e5461ce4c9 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.dependencies.test.ts @@ -0,0 +1,212 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +const succeedingExecutor = Layer.succeed(StepExecutor, { + execute: () => Effect.succeed({ _tag: "completed" as const }), +} satisfies StepExecutorShape); + +const layer = it.layer( + WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(succeedingExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const dependencyDefinition = { + name: "deps", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "work", + name: "Work", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const awaitTicketWhere = (ticketId: string, predicate: (detail: TicketDetail | null) => boolean) => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + for (let attempt = 0; attempt < 100; attempt += 1) { + const detail = yield* read.getTicketDetail(ticketId as never); + if (predicate(detail)) { + return detail; + } + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 10))); + yield* Effect.yieldNow; + } + return yield* read.getTicketDetail(ticketId as never); + }); + +layer("WorkflowEngine ticket dependencies", (it) => { + it.effect("queues a dependent in an auto lane and releases it when the dependency lands", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-deps" as never, dependencyDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + + const blocker = yield* engine.createTicket({ + boardId: "b-deps" as never, + title: "Blocker", + initialLane: "backlog" as never, + }); + const dependent = yield* engine.createTicket({ + boardId: "b-deps" as never, + title: "Dependent", + initialLane: "work" as never, + dependsOn: [blocker], + }); + + const queued = yield* read.getTicketDetail(dependent); + assert.equal(queued?.ticket.status, "queued"); + assert.isNotNull(queued?.ticket.queuedAt); + assert.deepEqual(queued?.ticket.dependsOn, [blocker as string]); + assert.equal(queued?.ticket.unresolvedDependencyCount, 1); + + // Manual run is refused while the dependency is open. + const refusal = yield* engine.runLane(dependent).pipe(Effect.flip); + assert.include(refusal.message, "waiting on 1 unresolved dependency"); + + // No pipeline may have started for the dependent yet. + const eventsBefore = yield* Stream.runCollect(store.readByTicket(dependent)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isFalse(eventsBefore.some((event) => event.type === "PipelineStarted")); + + // Landing the blocker in the terminal lane auto-releases the dependent. + yield* engine.moveTicket(blocker, "done" as never); + const released = yield* awaitTicketWhere( + dependent as string, + (detail) => detail?.ticket.currentLaneKey === "done", + ); + assert.equal(released?.ticket.currentLaneKey, "done"); + assert.equal(released?.ticket.unresolvedDependencyCount, 0); + + const eventsAfter = yield* Stream.runCollect(store.readByTicket(dependent)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isTrue(eventsAfter.some((event) => event.type === "TicketAdmitted")); + assert.isTrue(eventsAfter.some((event) => event.type === "PipelineStarted")); + }), + ); + + it.effect("releases a queued dependent when an edit clears its last dependency", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-deps-edit" as never, dependencyDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const blocker = yield* engine.createTicket({ + boardId: "b-deps-edit" as never, + title: "Blocker", + initialLane: "backlog" as never, + }); + const dependent = yield* engine.createTicket({ + boardId: "b-deps-edit" as never, + title: "Dependent", + initialLane: "work" as never, + dependsOn: [blocker], + }); + const queued = yield* read.getTicketDetail(dependent); + assert.equal(queued?.ticket.status, "queued"); + + yield* engine.editTicket({ ticketId: dependent, dependsOn: [] }); + + const released = yield* awaitTicketWhere( + dependent as string, + (detail) => detail?.ticket.currentLaneKey === "done", + ); + assert.equal(released?.ticket.currentLaneKey, "done"); + }), + ); + + it.effect("rejects circular and invalid dependencies", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-deps-cycle" as never, dependencyDefinition); + const engine = yield* WorkflowEngine; + + const first = yield* engine.createTicket({ + boardId: "b-deps-cycle" as never, + title: "First", + initialLane: "backlog" as never, + }); + const second = yield* engine.createTicket({ + boardId: "b-deps-cycle" as never, + title: "Second", + initialLane: "backlog" as never, + }); + + yield* engine.editTicket({ ticketId: first, dependsOn: [second] }); + const cycle = yield* engine + .editTicket({ ticketId: second, dependsOn: [first] }) + .pipe(Effect.flip); + assert.include(cycle.message, "circular"); + + const selfDependency = yield* engine + .editTicket({ ticketId: first, dependsOn: [first] }) + .pipe(Effect.flip); + assert.include(selfDependency.message, "depend on itself"); + + const missing = yield* engine + .createTicket({ + boardId: "b-deps-cycle" as never, + title: "Broken", + initialLane: "backlog" as never, + dependsOn: ["ticket-i-do-not-exist" as never], + }) + .pipe(Effect.flip); + assert.include(missing.message, "was not found"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.externalEvents.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.externalEvents.test.ts new file mode 100644 index 00000000000..27b2590221e --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.externalEvents.test.ts @@ -0,0 +1,214 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +const succeedingExecutor = Layer.succeed(StepExecutor, { + execute: () => Effect.succeed({ _tag: "completed" as const }), +} satisfies StepExecutorShape); + +const layer = it.layer( + WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(succeedingExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const eventDefinition = { + name: "events", + lanes: [ + { + key: "review", + name: "Review", + entry: "manual", + onEvent: [ + { + name: "ci.passed", + when: { "==": [{ var: "event.payload.status" }, "green"] }, + to: "done", + }, + { name: "ci.failed", to: "work" }, + ], + }, + { + key: "work", + name: "Work", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "review" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +layer("WorkflowEngine external events", (it) => { + it.effect("moves a ticket when name and predicate match and records the decision", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-events" as never, eventDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-events" as never, + title: "Ship it", + initialLane: "review" as never, + }); + + // Wrong name: no-op. + const wrongName = yield* engine.ingestExternalEvent({ + boardId: "b-events" as never, + name: "deploy.finished", + ticketId, + payload: { status: "green" }, + }); + assert.equal(wrongName.outcome, "noop"); + + // Matching name but failing predicate: no-op. + const failingPredicate = yield* engine.ingestExternalEvent({ + boardId: "b-events" as never, + name: "ci.passed", + ticketId, + payload: { status: "red" }, + }); + assert.equal(failingPredicate.outcome, "noop"); + + const moved = yield* engine.ingestExternalEvent({ + boardId: "b-events" as never, + name: "ci.passed", + ticketId, + payload: { status: "green" }, + }); + assert.equal(moved.outcome, "moved"); + assert.equal(moved.toLane, "done"); + + const detail = yield* read.getTicketDetail(ticketId); + assert.equal(detail?.ticket.currentLaneKey, "done"); + + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const decision = events.find((event) => event.type === "TicketRouteDecided"); + assert.isDefined(decision); + if (decision?.type === "TicketRouteDecided") { + assert.equal(decision.payload.source, "external_event"); + assert.equal(decision.payload.toLane, "done"); + } + const externalMove = events.find( + (event) => + event.type === "TicketMovedToLane" && + event.payload.reason === "external" && + event.payload.toLane === ("done" as string), + ); + assert.isDefined(externalMove); + + const decisions = yield* read.listTicketRouteDecisions(ticketId); + const externalDecision = decisions.find((row) => row.source === "external_event"); + assert.equal(externalDecision?.eventName, "ci.passed"); + assert.equal(externalDecision?.toLane, "done"); + }), + ); + + it.effect("an event into an auto lane starts the pipeline", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-events-auto" as never, eventDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-events-auto" as never, + title: "Send back", + initialLane: "review" as never, + }); + + const moved = yield* engine.ingestExternalEvent({ + boardId: "b-events-auto" as never, + name: "ci.failed", + ticketId, + payload: null, + }); + assert.equal(moved.outcome, "moved"); + assert.equal(moved.toLane, "work"); + + // The auto pipeline runs and routes onward to review. + for (let attempt = 0; attempt < 100; attempt += 1) { + const detail = yield* read.getTicketDetail(ticketId); + if (detail?.ticket.currentLaneKey === "review") { + return; + } + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 10))); + } + const detail = yield* read.getTicketDetail(ticketId); + assert.equal(detail?.ticket.currentLaneKey, "review"); + }), + ); + + it.effect("rejects events for tickets on other boards", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-events-a" as never, eventDefinition); + yield* registry.register("b-events-b" as never, eventDefinition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-events-a" as never, + title: "Mine", + initialLane: "review" as never, + }); + + const refused = yield* engine + .ingestExternalEvent({ + boardId: "b-events-b" as never, + name: "ci.passed", + ticketId, + payload: { status: "green" }, + }) + .pipe(Effect.flip); + assert.include(refused.message, "not found"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts new file mode 100644 index 00000000000..e3a97e16fdc --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts @@ -0,0 +1,2020 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { CapturedStepOutputReader } from "../Services/CapturedStepOutputReader.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { ProviderTurnPort } from "../Services/ProviderDispatchOutbox.ts"; +import { + ProviderResponsePort, + type ProviderResponseInput, +} from "../Services/ProviderResponsePort.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { ProviderDispatchOutboxLive } from "./ProviderDispatchOutbox.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; +import { makeStubStepExecutor } from "./StubStepExecutor.ts"; + +const definition = { + name: "wf", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "done", failure: "needs" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const baseLayer = ( + executor: Layer.Layer, + boardRegistry: Layer.Layer = BoardRegistryLive, +) => + WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(executor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(boardRegistry), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + +const awaitLane = (ticketId: string, laneKey: string) => + awaitTicketWhere(ticketId, (detail) => detail?.ticket.currentLaneKey === laneKey); + +const awaitStatus = (ticketId: string, status: string) => + awaitTicketWhere(ticketId, (detail) => detail?.ticket.status === status); + +const awaitTicketWhere = (ticketId: string, predicate: (detail: TicketDetail | null) => boolean) => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + for (let attempt = 0; attempt < 50; attempt += 1) { + const detail = yield* read.getTicketDetail(ticketId as never); + if (predicate(detail)) { + return detail; + } + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 10))); + yield* Effect.yieldNow; + } + return yield* read.getTicketDetail(ticketId as never); + }); + +const awaitDeferredWithinYields = (deferred: Deferred.Deferred, label: string) => + Effect.gen(function* () { + const fiber = yield* Effect.forkChild(Deferred.await(deferred)); + for (let attempt = 0; attempt < 50; attempt += 1) { + const exit = yield* Effect.sync(() => fiber.pollUnsafe()); + if (exit !== undefined) { + return yield* Fiber.join(fiber); + } + yield* Effect.yieldNow; + } + yield* Fiber.interrupt(fiber); + assert.fail(`Timed out waiting for ${label}`); + }); + +const successLayer = it.layer(baseLayer(makeStubStepExecutor({ default: { _tag: "completed" } }))); + +successLayer("WorkflowEngine integration", (it) => { + it.effect("auto lane runs the pipeline and routes to done", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-1" as never, definition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-1" as never, + title: "Export", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "done"); + assert.equal(detail?.ticket.currentLaneKey, "done"); + assert.equal( + detail?.steps.some((step) => step.status === "completed"), + true, + ); + }), + ); + + it.effect("edits ticket title and description metadata", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-edit" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-edit" as never, + title: "Original title", + description: "Original description", + initialLane: "backlog" as never, + }); + + yield* engine.editTicket({ + ticketId, + title: " Updated title ", + description: "", + }); + + const detail = yield* read.getTicketDetail(ticketId); + assert.equal(detail?.ticket.title, "Updated title"); + assert.equal(detail?.ticket.description, ""); + }), + ); +}); + +const failLayer = it.layer( + baseLayer(makeStubStepExecutor({ default: { _tag: "failed", error: "boom" } })), +); + +failLayer("WorkflowEngine integration failure path", (it) => { + it.effect("failed step routes to the failure lane", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-fail" as never, definition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-fail" as never, + title: "Fix", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.equal( + detail?.steps.some((step) => step.status === "failed"), + true, + ); + }), + ); +}); + +const stepOnDefinition = { + name: "step-on-wf", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "first", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "first", + on: { success: "needs" }, + }, + { + key: "second", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "second", + }, + ], + on: { success: "done" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const transitionDefinition = { + name: "transition-wf", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "review", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "review", + captureOutput: true, + }, + ], + transitions: [ + { when: { "==": [{ var: "steps.review.output.verdict" }, "pass"] }, to: "done" }, + { when: { "==": [{ var: "steps.review.output.verdict" }, "block"] }, to: "needs" }, + ], + on: { success: "done" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const recoveredCaptureReadErrorDefinition = { + name: "recovered-capture-read-error-wf", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "review", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "review", + captureOutput: true, + }, + ], + on: { success: "done", failure: "needs" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const noRouteFailureDefinition = { + name: "no-route-wf", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "fail", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "fail", + }, + ], + }, + ], +}; + +const routeDecisionLayer = it.layer( + baseLayer( + makeStubStepExecutor({ + default: { _tag: "completed" }, + byStepKey: { + review: { _tag: "completed", output: { verdict: "block" } }, + fail: { _tag: "failed", error: "boom" }, + }, + }), + ), +); + +const providerContinuationLayer = it.layer( + baseLayer(makeStubStepExecutor({ default: { _tag: "completed" } })).pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(CapturedStepOutputReader, { + read: () => Effect.succeed({ verdict: "block" }), + }), + ), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(ProjectionThreadMessageRepositoryLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.die("unused provider turn start"), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const recoveredCaptureReadErrorLayer = it.layer( + baseLayer(makeStubStepExecutor({ default: { _tag: "completed" } })).pipe( + Layer.provideMerge( + Layer.succeed(CapturedStepOutputReader, { + read: () => + Effect.fail(new WorkflowEventStoreError({ message: "simulated repository failure" })), + }), + ), + ), +); + +routeDecisionLayer("WorkflowEngine smart route decisions", (it) => { + it.effect("step on success short-circuits remaining steps and emits route audit", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-step-on" as never, stepOnDefinition); + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-step-on" as never, + title: "Step route", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.deepEqual( + detail?.steps.map((step) => step.stepKey), + ["first"], + ); + + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const routeIndex = events.findIndex((event) => event.type === "TicketRouteDecided"); + const moveIndex = events.findIndex( + (event) => event.type === "TicketMovedToLane" && event.payload.reason === "routed", + ); + const audit = events.find((event) => event.type === "TicketRouteDecided"); + assert.isTrue(routeIndex >= 0); + assert.equal(moveIndex, routeIndex + 1); + assert.equal(audit?.type, "TicketRouteDecided"); + if (audit?.type !== "TicketRouteDecided") { + assert.fail("expected TicketRouteDecided"); + } + assert.equal(audit.payload.source, "step_on"); + assert.equal(audit.payload.toLane, "needs"); + }), + ); + + it.effect("lane transitions first-match before lane on fallback", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-transition" as never, transitionDefinition); + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-transition" as never, + title: "Transition route", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const audit = events.find((event) => event.type === "TicketRouteDecided"); + assert.equal(audit?.type, "TicketRouteDecided"); + if (audit?.type !== "TicketRouteDecided") { + assert.fail("expected TicketRouteDecided"); + } + assert.equal(audit.payload.source, "lane_transition"); + assert.equal(audit.payload.matchedTransitionIndex, 1); + assert.deepEqual((audit.payload.contextSnapshot as any).steps.review.output, { + verdict: "block", + }); + }), + ); + + it.effect("lane on fallback still emits route audit", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-lane-on-audit" as never, definition); + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-lane-on-audit" as never, + title: "Lane route", + initialLane: "impl" as never, + }); + + yield* awaitLane(ticketId as string, "done"); + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const audit = events.find((event) => event.type === "TicketRouteDecided"); + assert.equal(audit?.type, "TicketRouteDecided"); + if (audit?.type !== "TicketRouteDecided") { + assert.fail("expected TicketRouteDecided"); + } + assert.equal(audit.payload.source, "lane_on"); + assert.equal(audit.payload.toLane, "done"); + }), + ); + + it.effect("failure with no route keeps TicketBlocked and emits no route audit", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-no-route" as never, noRouteFailureDefinition); + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-no-route" as never, + title: "No route", + initialLane: "impl" as never, + }); + + const detail = yield* awaitStatus(ticketId as string, "blocked"); + assert.equal(detail?.ticket.status, "blocked"); + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isFalse(events.some((event) => event.type === "TicketRouteDecided")); + assert.isTrue( + events.some( + (event) => + event.type === "TicketBlocked" && + event.payload.reason === "pipeline failure with no route", + ), + ); + }), + ); + + it.effect("recovered step on success short-circuits remaining steps", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-recovered-step-on" as never, stepOnDefinition); + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + const store = yield* WorkflowEventStore; + + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-recovered-ticket" as never, + ticketId: "ticket-recovered-step-on" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-recovered-step-on" as never, + title: "Recovered step", + laneKey: "impl" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-recovered-move-in" as never, + ticketId: "ticket-recovered-step-on" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "impl" as never, + laneEntryToken: "tok-recovered-step-on" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-recovered-pipeline" as never, + ticketId: "ticket-recovered-step-on" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-recovered-step-on" as never, + laneKey: "impl" as never, + laneEntryToken: "tok-recovered-step-on" as never, + }, + } as never); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-recovered-step" as never, + ticketId: "ticket-recovered-step-on" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-recovered-step-on" as never, + stepRunId: "step-recovered-step-on" as never, + stepKey: "first" as never, + stepType: "agent", + }, + } as never); + + yield* engine.completeRecoveredStep("step-recovered-step-on" as never, { + _tag: "completed", + }); + + const detail = yield* awaitLane("ticket-recovered-step-on", "needs"); + assert.deepEqual( + detail?.steps.map((step) => step.stepKey), + ["first"], + ); + const events = yield* Stream.runCollect( + store.readByTicket("ticket-recovered-step-on" as never), + ).pipe(Effect.map((chunk) => Array.from(chunk))); + const audit = events.find((event) => event.type === "TicketRouteDecided"); + assert.equal(audit?.type, "TicketRouteDecided"); + if (audit?.type !== "TicketRouteDecided") { + assert.fail("expected TicketRouteDecided"); + } + assert.equal(audit.payload.source, "step_on"); + assert.equal(audit.payload.toLane, "needs"); + }), + ); + + it.effect("stale recovered completion emits no route audit after token supersede", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-stale-token" as never, stepOnDefinition); + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + const store = yield* WorkflowEventStore; + + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-stale-token-ticket" as never, + ticketId: "ticket-stale-token" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-stale-token" as never, + title: "Stale token", + laneKey: "impl" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-stale-token-move-in" as never, + ticketId: "ticket-stale-token" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "impl" as never, + laneEntryToken: "tok-stale-token-old" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-stale-token-pipeline" as never, + ticketId: "ticket-stale-token" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-stale-token" as never, + laneKey: "impl" as never, + laneEntryToken: "tok-stale-token-old" as never, + }, + } as never); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-stale-token-step" as never, + ticketId: "ticket-stale-token" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-stale-token" as never, + stepRunId: "step-stale-token" as never, + stepKey: "first" as never, + stepType: "agent", + }, + } as never); + + yield* engine.moveTicket("ticket-stale-token" as never, "needs" as never); + yield* engine.completeRecoveredStep("step-stale-token" as never, { + _tag: "completed", + }); + + const detail = yield* awaitLane("ticket-stale-token", "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + + const events = yield* Stream.runCollect( + store.readByTicket("ticket-stale-token" as never), + ).pipe(Effect.map((chunk) => Array.from(chunk))); + assert.isFalse(events.some((event) => event.type === "TicketRouteDecided")); + assert.isFalse( + events.some( + (event) => event.type === "TicketMovedToLane" && event.payload.reason === "routed", + ), + ); + }), + ); +}); + +recoveredCaptureReadErrorLayer("WorkflowEngine recovered capture output failures", (it) => { + it.effect("terminalizes recovered captureOutput steps when structured output lookup fails", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register( + "b-recovered-capture-read-error" as never, + recoveredCaptureReadErrorDefinition, + ); + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + const store = yield* WorkflowEventStore; + + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-recovered-capture-read-error-ticket" as never, + ticketId: "ticket-recovered-capture-read-error" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-recovered-capture-read-error" as never, + title: "Recovered capture read error", + laneKey: "impl" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-recovered-capture-read-error-move-in" as never, + ticketId: "ticket-recovered-capture-read-error" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "impl" as never, + laneEntryToken: "tok-recovered-capture-read-error" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-recovered-capture-read-error-pipeline" as never, + ticketId: "ticket-recovered-capture-read-error" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-recovered-capture-read-error" as never, + laneKey: "impl" as never, + laneEntryToken: "tok-recovered-capture-read-error" as never, + }, + } as never); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-recovered-capture-read-error-step" as never, + ticketId: "ticket-recovered-capture-read-error" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-recovered-capture-read-error" as never, + stepRunId: "step-recovered-capture-read-error" as never, + stepKey: "review" as never, + stepType: "agent", + }, + } as never); + + const exit = yield* engine + .completeRecoveredStep( + "step-recovered-capture-read-error" as never, + { _tag: "completed" }, + { + threadId: "thread-recovered-capture-read-error" as never, + turnId: "turn-recovered-capture-read-error" as never, + }, + ) + .pipe(Effect.exit); + + assert.isTrue(Exit.isSuccess(exit)); + + const detail = yield* awaitLane("ticket-recovered-capture-read-error", "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.equal(detail?.steps.find((step) => step.stepKey === "review")?.status, "failed"); + + const events = yield* Stream.runCollect( + store.readByTicket("ticket-recovered-capture-read-error" as never), + ).pipe(Effect.map((chunk) => Array.from(chunk))); + assert.isTrue( + events.some( + (event) => + event.type === "StepFailed" && + event.payload.stepRunId === "step-recovered-capture-read-error" && + event.payload.error === "structured output lookup failed", + ), + ); + assert.isTrue( + events.some( + (event) => event.type === "PipelineCompleted" && event.payload.result === "failure", + ), + ); + assert.isTrue( + events.some( + (event) => + event.type === "TicketRouteDecided" && + event.payload.source === "lane_on" && + event.payload.toLane === "needs", + ), + ); + }), + ); +}); + +providerContinuationLayer("WorkflowEngine provider continuation routing", (it) => { + it.effect("routes recovered provider approval continuation with captured output", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-provider-continuation" as never, transitionDefinition); + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const providerOutput = 'Review complete.\n```json\n{"verdict":"block"}\n```'; + + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-provider-ticket" as never, + ticketId: "ticket-provider-continuation" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-provider-continuation" as never, + title: "Provider continuation", + laneKey: "impl" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-provider-move" as never, + ticketId: "ticket-provider-continuation" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "impl" as never, + laneEntryToken: "tok-provider-continuation" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-provider-pipeline" as never, + ticketId: "ticket-provider-continuation" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-provider-continuation" as never, + laneKey: "impl" as never, + laneEntryToken: "tok-provider-continuation" as never, + }, + } as never); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-provider-step" as never, + ticketId: "ticket-provider-continuation" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-provider-continuation" as never, + stepRunId: "step-provider-continuation" as never, + stepKey: "review" as never, + stepType: "agent", + }, + } as never); + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "evt-provider-await" as never, + ticketId: "ticket-provider-continuation" as never, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + stepRunId: "step-provider-continuation" as never, + waitingReason: "Provider needs approval", + providerThreadId: "thread-provider-continuation" as never, + providerRequestId: "request-provider-continuation" as never, + providerResponseKind: "request", + }, + } as never); + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + turn_id, + created_at, + started_at + ) + VALUES ( + 'dispatch-provider-continuation', + 'ticket-provider-continuation', + 'step-provider-continuation', + 'thread-provider-continuation', + 'codex', + 'gpt-5.5', + 'Review the test result', + '/tmp/wt-provider-continuation', + 'started', + 'turn-provider-continuation', + '2026-06-07T00:00:05.000Z', + '2026-06-07T00:00:05.000Z' + ) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES ( + 'assistant-provider-continuation', + 'thread-provider-continuation', + 'turn-provider-continuation', + 'assistant', + ${providerOutput}, + NULL, + 0, + '2026-06-07T00:00:06.000Z', + '2026-06-07T00:00:06.000Z' + ) + `; + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + 'thread-provider-continuation', + 'turn-provider-continuation', + NULL, + NULL, + NULL, + 'assistant-provider-continuation', + 'completed', + '2026-06-07T00:00:05.000Z', + '2026-06-07T00:00:05.000Z', + '2026-06-07T00:00:06.000Z', + NULL, + NULL, + NULL, + '[]' + ) + `; + + yield* engine.resolveApproval("step-provider-continuation" as never, true); + + const detail = yield* awaitLane("ticket-provider-continuation", "needs"); + assert.deepEqual(detail?.steps.find((step) => step.stepKey === "review")?.output, { + verdict: "block", + }); + assert.equal( + (yield* read.getTicketDetail("ticket-provider-continuation" as never))?.ticket + .currentLaneKey, + "needs", + ); + }), + ); +}); + +const blockedDefinition = { + name: "blocked-wf", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "done", failure: "needs", blocked: "trust" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "trust", name: "Trust", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const blockedLayer = it.layer( + baseLayer( + makeStubStepExecutor({ + default: { _tag: "blocked", reason: "Project not trusted to run scripts" } as never, + }), + ), +); + +blockedLayer("WorkflowEngine integration blocked path", (it) => { + it.effect("blocked step routes through the lane blocked target and records its reason", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-blocked" as never, blockedDefinition); + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-blocked" as never, + title: "Trust", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "trust"); + assert.equal(detail?.ticket.currentLaneKey, "trust"); + assert.equal(detail?.steps[0]?.status, "blocked"); + assert.equal(detail?.steps[0]?.blockedReason, "Project not trusted to run scripts"); + + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isTrue( + events.some( + (event) => + event.type === "StepBlocked" && + event.payload.reason === "Project not trusted to run scripts", + ), + ); + assert.isTrue( + events.some( + (event) => event.type === "PipelineCompleted" && event.payload.result === "blocked", + ), + ); + }), + ); +}); + +const explodingExecutor = Layer.succeed(StepExecutor, { + execute: () => + Effect.fail(new WorkflowEventStoreError({ message: "executor exploded" })) as never, +} satisfies StepExecutorShape); + +const explodingLayer = it.layer(baseLayer(explodingExecutor)); + +explodingLayer("WorkflowEngine pipeline error handling", (it) => { + it.effect("records a failed step and routes when the executor effect fails", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-explodes" as never, definition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-explodes" as never, + title: "Explode", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.equal(detail?.steps[0]?.status, "failed"); + }), + ); +}); + +const failingDefinitionRegistry = Layer.succeed(BoardRegistry, { + register: () => Effect.succeed(definition as never), + unregister: () => Effect.void, + getLane: (_boardId, laneKey) => + Effect.succeed((definition.lanes.find((lane) => lane.key === laneKey) ?? null) as never), + getDefinition: () => Effect.die("definition unavailable"), + listDefinitions: () => Effect.succeed([]), +}); + +const pipelineFailureLayer = it.layer( + baseLayer(makeStubStepExecutor({ default: { _tag: "completed" } }), failingDefinitionRegistry), +); + +pipelineFailureLayer("WorkflowEngine orchestration error handling", (it) => { + it.effect("blocks and logs when pipeline orchestration fails before the first step", () => { + const messages: string[] = []; + const logger = Logger.make(({ message }) => { + messages.push(String(message)); + }); + + return Effect.gen(function* () { + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-pipeline-fails" as never, + title: "Pipeline fails", + initialLane: "impl" as never, + }); + + const detail = yield* awaitStatus(ticketId as string, "blocked"); + assert.equal(detail?.ticket.status, "blocked"); + assert.equal(detail?.steps.length, 0); + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const blocked = events.find((event) => event.type === "TicketBlocked"); + assert.include(blocked?.payload.reason ?? "", "definition unavailable"); + assert.isTrue( + messages.some((message) => message.includes("workflow pipeline orchestration failed")), + ); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); +}); + +const approvalDefinition = { + name: "approval-wf", + lanes: [ + { + key: "review", + name: "Review", + entry: "auto", + pipeline: [{ key: "ok", type: "approval", prompt: "Approve?" }], + on: { success: "done", failure: "needs" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +successLayer("WorkflowEngine approval gate", (it) => { + it.effect("parks on approval then routes on approve", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-approval" as never, approvalDefinition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-approval" as never, + title: "Approve me", + initialLane: "review" as never, + }); + + const waitingDetail = yield* awaitStatus(ticketId as string, "waiting_on_user"); + assert.equal(waitingDetail?.ticket.status, "waiting_on_user"); + const stepRunId = waitingDetail?.steps[0]?.stepRunId; + assert.isString(stepRunId); + + yield* engine.resolveApproval(stepRunId as never, true); + const doneDetail = yield* awaitLane(ticketId as string, "done"); + assert.equal(doneDetail?.ticket.currentLaneKey, "done"); + }), + ); +}); + +const awaitingUserDefinition = { + name: "awaiting-user-wf", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "question", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "ask", + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +it.effect("answerTicketStep posts both messages, delivers text, and resumes the parked turn", () => + Effect.gen(function* () { + const providerResponses = yield* Ref.make>([]); + const answerLayer = baseLayer( + makeStubStepExecutor({ + default: { + _tag: "awaiting_user", + waitingReason: "Which API should I use?", + providerThreadId: "thread-ticket-answer" as never, + providerRequestId: "request-ticket-answer" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-api-choice", + } as never, + }), + ).pipe( + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(providerResponses, (calls) => [...calls, input]), + }), + ), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ticket-answer" as never, awaitingUserDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-ticket-answer" as never, + title: "Answer me", + initialLane: "impl" as never, + }); + const waitingDetail = yield* awaitStatus(ticketId as string, "waiting_on_user"); + const stepRunId = waitingDetail?.steps[0]?.stepRunId; + assert.isString(stepRunId); + assert.deepEqual( + waitingDetail?.messages.map((message) => [message.author, message.body]), + [["agent", "Which API should I use?"]], + ); + + yield* engine.answerTicketStep({ + stepRunId: stepRunId as never, + text: "Use the sandbox endpoint.", + attachments: [], + }); + + const doneDetail = yield* awaitLane(ticketId as string, "done"); + const calls = yield* Ref.get(providerResponses); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.responseKind, "user-input"); + assert.equal( + (calls[0] as { readonly questionId?: string } | undefined)?.questionId, + "question-api-choice", + ); + assert.equal(calls[0]?.text, "Use the sandbox endpoint."); + assert.deepEqual( + (yield* read.getTicketDetail(ticketId))?.messages.map((message) => [ + message.author, + message.body, + ]), + [ + ["agent", "Which API should I use?"], + ["user", "Use the sandbox endpoint."], + ], + ); + assert.equal(doneDetail?.ticket.currentLaneKey, "done"); + }).pipe(Effect.provide(answerLayer)); + }), +); + +it.effect( + "answerTicketStep rejects stale provider user-input waits until a live request is visible", + () => + Effect.gen(function* () { + const providerResponses = yield* Ref.make>([]); + const providerWaitState = yield* Ref.make<"stale" | "live">("stale"); + const staleGuardLayer = baseLayer( + makeStubStepExecutor({ default: { _tag: "completed" } }), + ).pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.die("unused provider turn start"), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: (threadId) => + Ref.get(providerResponses).pipe( + Effect.zip(Ref.get(providerWaitState)), + Effect.map(([responses, state]) => { + if (responses.length > 0) { + return { _tag: "completed" as const }; + } + if (state === "live") { + return { + _tag: "awaiting_user" as const, + waitingReason: "Live provider question", + providerThreadId: threadId, + providerRequestId: "request-live-answer" as never, + providerResponseKind: "user-input" as const, + providerQuestionId: "question-live-answer", + }; + } + return { _tag: "running" as const }; + }), + ), + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(providerResponses, (calls) => [...calls, input]), + }), + ), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + yield* registry.register("b-stale-answer" as never, awaitingUserDefinition); + + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-stale-answer-created" as never, + ticketId: "ticket-stale-answer" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-stale-answer" as never, + title: "Stale answer", + laneKey: "impl" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-stale-answer-moved" as never, + ticketId: "ticket-stale-answer" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "impl" as never, + laneEntryToken: "token-stale-answer" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-stale-answer-pipeline" as never, + ticketId: "ticket-stale-answer" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-stale-answer" as never, + laneKey: "impl" as never, + laneEntryToken: "token-stale-answer" as never, + }, + } as never); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-stale-answer-step" as never, + ticketId: "ticket-stale-answer" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-stale-answer" as never, + stepRunId: "step-stale-answer" as never, + stepKey: "question" as never, + stepType: "agent", + }, + } as never); + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "evt-stale-answer-await" as never, + ticketId: "ticket-stale-answer" as never, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + stepRunId: "step-stale-answer" as never, + waitingReason: "Stale provider question", + providerThreadId: "thread-stale-answer" as never, + providerRequestId: "request-stale-answer" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-stale-answer", + }, + } as never); + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + turn_id, + created_at, + started_at + ) + VALUES ( + 'dispatch-stale-answer', + 'ticket-stale-answer', + 'step-stale-answer', + 'thread-stale-answer', + 'codex', + 'gpt-5.5', + 'ask', + '/tmp/stale-answer', + 'started', + 'turn-stale-answer', + '2026-06-07T00:00:04.000Z', + '2026-06-07T00:00:04.000Z' + ) + `; + + const staleExit = yield* Effect.exit( + engine.answerTicketStep({ + stepRunId: "step-stale-answer" as never, + text: "Use the stale answer.", + }), + ); + assert.isTrue(Exit.isFailure(staleExit)); + if (Exit.isFailure(staleExit)) { + assert.include(String(staleExit.cause), "retry"); + } + assert.deepEqual(yield* Ref.get(providerResponses), []); + + const detailAfterStaleAnswer = yield* read.getTicketDetail("ticket-stale-answer" as never); + assert.equal(detailAfterStaleAnswer?.ticket.status, "waiting_on_user"); + assert.equal(detailAfterStaleAnswer?.steps[0]?.status, "awaiting_user"); + assert.isFalse( + detailAfterStaleAnswer?.messages.some((message) => message.author === "user") ?? false, + ); + const resolvedBeforeLive = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_events + WHERE ticket_id = 'ticket-stale-answer' + AND event_type = 'StepUserResolved' + `; + assert.equal(resolvedBeforeLive[0]?.count, 0); + + yield* Ref.set(providerWaitState, "live"); + yield* sql` + UPDATE workflow_dispatch_outbox + SET turn_id = 'turn-live-answer' + WHERE dispatch_id = 'dispatch-stale-answer' + `; + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "evt-live-answer-await" as never, + ticketId: "ticket-stale-answer" as never, + occurredAt: "2026-06-07T00:00:05.000Z" as never, + payload: { + stepRunId: "step-stale-answer" as never, + waitingReason: "Live provider question", + providerThreadId: "thread-stale-answer" as never, + providerRequestId: "request-live-answer" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-live-answer", + }, + } as never); + + yield* engine.answerTicketStep({ + stepRunId: "step-stale-answer" as never, + text: "Use the live answer.", + }); + + assert.deepEqual( + (yield* Ref.get(providerResponses)).map((response) => ({ + requestId: response.requestId as string, + questionId: response.questionId, + text: response.text, + })), + [ + { + requestId: "request-live-answer", + questionId: "question-live-answer", + text: "Use the live answer.", + }, + ], + ); + }).pipe(Effect.provide(staleGuardLayer)); + }), +); + +it.effect("truncates over-long provider prompts before posting agent ticket messages", () => + Effect.gen(function* () { + const longPrompt = `${"x".repeat(8_010)} tail`; + const promptLayer = baseLayer( + makeStubStepExecutor({ + default: { + _tag: "awaiting_user", + waitingReason: longPrompt, + providerThreadId: "thread-ticket-long-prompt" as never, + providerRequestId: "request-ticket-long-prompt" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-ticket-long-prompt", + } as never, + }), + ).pipe( + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: () => Effect.void, + }), + ), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ticket-long-prompt" as never, awaitingUserDefinition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-ticket-long-prompt" as never, + title: "Long prompt", + initialLane: "impl" as never, + }); + const waitingDetail = yield* awaitStatus(ticketId as string, "waiting_on_user"); + const body = waitingDetail?.messages[0]?.body ?? ""; + + assert.equal(body.length, 8_000); + assert.isTrue(body.endsWith("...")); + assert.isFalse(body.includes(" tail")); + }).pipe(Effect.provide(promptLayer)); + }), +); + +it.effect("answerTicketStep records image-only replies without resuming the turn", () => + Effect.gen(function* () { + const providerResponses = yield* Ref.make>([]); + const imageOnlyLayer = baseLayer( + makeStubStepExecutor({ + default: { + _tag: "awaiting_user", + waitingReason: "Attach a screenshot.", + providerThreadId: "thread-ticket-image-only" as never, + providerRequestId: "request-ticket-image-only" as never, + providerResponseKind: "user-input", + }, + }), + ).pipe( + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(providerResponses, (calls) => [...calls, input]), + }), + ), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ticket-image-only" as never, awaitingUserDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-ticket-image-only" as never, + title: "Need screenshot", + initialLane: "impl" as never, + }); + const waitingDetail = yield* awaitStatus(ticketId as string, "waiting_on_user"); + const stepRunId = waitingDetail?.steps[0]?.stepRunId; + assert.isString(stepRunId); + + yield* engine.answerTicketStep({ + stepRunId: stepRunId as never, + attachments: [ + { + kind: "image", + id: "image-only", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 1200, + dataUrl: "data:image/png;base64,AAAA", + }, + ], + }); + + const detail = yield* read.getTicketDetail(ticketId); + const calls = yield* Ref.get(providerResponses); + assert.equal(calls.length, 0); + assert.equal(detail?.ticket.status, "waiting_on_user"); + assert.equal(detail?.steps[0]?.status, "awaiting_user"); + assert.equal(detail?.messages.at(-1)?.author, "user"); + assert.equal(detail?.messages.at(-1)?.body, ""); + assert.equal(detail?.messages.at(-1)?.attachments[0]?.kind, "image"); + }).pipe(Effect.provide(imageOnlyLayer)); + }), +); + +it.effect("answerTicketStep rejects non-awaiting steps without posting a user message", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ticket-answer-completed" as never, awaitingUserDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-ticket-answer-completed" as never, + title: "Already answered", + initialLane: "impl" as never, + }); + const doneDetail = yield* awaitLane(ticketId as string, "done"); + const stepRunId = doneDetail?.steps[0]?.stepRunId; + assert.isString(stepRunId); + + const exit = yield* Effect.exit( + engine.answerTicketStep({ + stepRunId: stepRunId as never, + text: "This should not be posted.", + }), + ); + assert.isTrue(Exit.isFailure(exit)); + + const detail = yield* read.getTicketDetail(ticketId); + assert.deepEqual(detail?.messages, []); + }).pipe(Effect.provide(baseLayer(makeStubStepExecutor({ default: { _tag: "completed" } })))), +); + +it.effect( + "answerTicketStep rejects provider approval requests without posting a user message", + () => + Effect.gen(function* () { + const providerResponses = yield* Ref.make>([]); + const requestLayer = baseLayer( + makeStubStepExecutor({ + default: { + _tag: "awaiting_user", + waitingReason: "Approve this command?", + providerThreadId: "thread-ticket-request" as never, + providerRequestId: "request-ticket-request" as never, + providerResponseKind: "request", + }, + }), + ).pipe( + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(providerResponses, (calls) => [...calls, input]), + }), + ), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ticket-answer-request" as never, awaitingUserDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-ticket-answer-request" as never, + title: "Approve me", + initialLane: "impl" as never, + }); + const waitingDetail = yield* awaitStatus(ticketId as string, "waiting_on_user"); + const stepRunId = waitingDetail?.steps[0]?.stepRunId; + assert.isString(stepRunId); + + const exit = yield* Effect.exit( + engine.answerTicketStep({ + stepRunId: stepRunId as never, + text: "This should not be posted.", + }), + ); + assert.isTrue(Exit.isFailure(exit)); + + const detail = yield* read.getTicketDetail(ticketId); + assert.deepEqual(detail?.messages, []); + assert.deepEqual(yield* Ref.get(providerResponses), []); + }).pipe(Effect.provide(requestLayer)); + }), +); + +it.effect("answerTicketStep rejects over-limit reply bodies and attachments", () => + Effect.gen(function* () { + const providerResponses = yield* Ref.make>([]); + const limitLayer = baseLayer( + makeStubStepExecutor({ + default: { + _tag: "awaiting_user", + waitingReason: "Provide details.", + providerThreadId: "thread-ticket-limits" as never, + providerRequestId: "request-ticket-limits" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-ticket-limits", + } as never, + }), + ).pipe( + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(providerResponses, (calls) => [...calls, input]), + }), + ), + ); + + const image = (id: string, dataUrl = "data:image/png;base64,AAAA") => ({ + kind: "image" as const, + id, + name: `${id}.png`, + mimeType: "image/png" as const, + sizeBytes: dataUrl.length, + dataUrl, + }); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ticket-limits" as never, awaitingUserDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const assertRejected = Effect.fn("assertRejected")(function* ( + title: string, + input: { + readonly text?: string; + readonly attachments?: ReadonlyArray>; + }, + ) { + const ticketId = yield* engine.createTicket({ + boardId: "b-ticket-limits" as never, + title, + initialLane: "impl" as never, + }); + const waitingDetail = yield* awaitStatus(ticketId as string, "waiting_on_user"); + const stepRunId = waitingDetail?.steps[0]?.stepRunId; + assert.isString(stepRunId); + + const exit = yield* Effect.exit( + engine.answerTicketStep({ + stepRunId: stepRunId as never, + ...input, + }), + ); + assert.isTrue(Exit.isFailure(exit)); + + const detail = yield* read.getTicketDetail(ticketId); + assert.deepEqual( + detail?.messages.map((message) => [message.author, message.body]), + [["agent", "Provide details."]], + ); + }); + + yield* assertRejected("Too many attachments", { + text: "See attached.", + attachments: Array.from({ length: 7 }, (_, index) => image(`image-${index}`)), + }); + yield* assertRejected("Too much image data", { + text: "See attached.", + attachments: [image("huge", `data:image/png;base64,${"A".repeat(10 * 1024 * 1024)}`)], + }); + yield* assertRejected("Too much text", { + text: "x".repeat(8001), + }); + + assert.deepEqual(yield* Ref.get(providerResponses), []); + }).pipe(Effect.provide(limitLayer)); + }), +); + +it.effect("answerTicketStep rejects non-image attachments before storing messages", () => + Effect.gen(function* () { + const providerResponses = yield* Ref.make>([]); + const attachmentKindLayer = baseLayer( + makeStubStepExecutor({ + default: { + _tag: "awaiting_user", + waitingReason: "Attach an image.", + providerThreadId: "thread-ticket-attachment-kind" as never, + providerRequestId: "request-ticket-attachment-kind" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-ticket-attachment-kind", + } as never, + }), + ).pipe( + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(providerResponses, (calls) => [...calls, input]), + }), + ), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ticket-attachment-kind" as never, awaitingUserDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const assertRejectedAttachment = Effect.fn("assertRejectedAttachment")(function* ( + title: string, + attachment: NonNullable< + Parameters[0]["attachments"] + >[number], + ) { + const ticketId = yield* engine.createTicket({ + boardId: "b-ticket-attachment-kind" as never, + title, + initialLane: "impl" as never, + }); + const waitingDetail = yield* awaitStatus(ticketId as string, "waiting_on_user"); + const stepRunId = waitingDetail?.steps[0]?.stepRunId; + assert.isString(stepRunId); + + const exit = yield* Effect.exit( + engine.answerTicketStep({ + stepRunId: stepRunId as never, + text: "See attached.", + attachments: [attachment], + }), + ); + assert.isTrue(Exit.isFailure(exit)); + + const detail = yield* read.getTicketDetail(ticketId); + assert.deepEqual( + detail?.messages.map((message) => [message.author, message.body]), + [["agent", "Attach an image."]], + ); + }); + + yield* assertRejectedAttachment("Reject video", { + kind: "video", + id: "video-attachment", + name: "clip.mp4", + mimeType: "video/mp4", + sizeBytes: 1200, + ref: "ticket-media/video-attachment", + }); + yield* assertRejectedAttachment("Reject file", { + kind: "file", + id: "file-attachment", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 1200, + ref: "ticket-media/file-attachment", + }); + + assert.deepEqual(yield* Ref.get(providerResponses), []); + }).pipe(Effect.provide(attachmentKindLayer)); + }), +); + +it.effect("answerTicketStep rejects SVG image data URLs before storing messages", () => + Effect.gen(function* () { + const providerResponses = yield* Ref.make>([]); + const svgLayer = baseLayer( + makeStubStepExecutor({ + default: { + _tag: "awaiting_user", + waitingReason: "Attach a raster image.", + providerThreadId: "thread-ticket-svg" as never, + providerRequestId: "request-ticket-svg" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-ticket-svg", + } as never, + }), + ).pipe( + Layer.provideMerge( + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(providerResponses, (calls) => [...calls, input]), + }), + ), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ticket-svg" as never, awaitingUserDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-ticket-svg" as never, + title: "Reject SVG", + initialLane: "impl" as never, + }); + const waitingDetail = yield* awaitStatus(ticketId as string, "waiting_on_user"); + const stepRunId = waitingDetail?.steps[0]?.stepRunId; + assert.isString(stepRunId); + + const exit = yield* Effect.exit( + engine.answerTicketStep({ + stepRunId: stepRunId as never, + text: "See attached.", + attachments: [ + { + kind: "image", + id: "svg-attachment", + name: "payload.svg", + mimeType: "image/svg+xml", + sizeBytes: 1200, + dataUrl: "data:image/svg+xml;base64,PHN2Zy8+", + } as never, + ], + }), + ); + assert.isTrue(Exit.isFailure(exit)); + + const detail = yield* read.getTicketDetail(ticketId); + assert.deepEqual( + detail?.messages.map((message) => [message.author, message.body]), + [["agent", "Attach a raster image."]], + ); + assert.deepEqual(yield* Ref.get(providerResponses), []); + }).pipe(Effect.provide(svgLayer)); + }), +); + +let supersedeStarted: Deferred.Deferred | undefined; +let supersedeInterrupted: Deferred.Deferred | undefined; +let supersedeRelease: Deferred.Deferred | undefined; +let routedAutoStarted: Deferred.Deferred | undefined; +let routedAutoRelease: Deferred.Deferred | undefined; +let routedAutoCompletions = 0; + +const blockingSuccessExecutor = Layer.effect( + StepExecutor, + Effect.gen(function* () { + const started = yield* Deferred.make(); + const interrupted = yield* Deferred.make(); + const release = yield* Deferred.make(); + supersedeStarted = started; + supersedeInterrupted = interrupted; + supersedeRelease = release; + + return StepExecutor.of({ + execute: () => + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + return { _tag: "completed" as const }; + }).pipe( + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined).pipe(Effect.ignore)), + ), + } satisfies StepExecutorShape); + }), +); + +const supersedeLayer = it.layer(baseLayer(blockingSuccessExecutor)); + +supersedeLayer("WorkflowEngine manual move supersede", (it) => { + it.effect("manual move prevents a stale pipeline from routing the ticket", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-supersede" as never, definition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-supersede" as never, + title: "Hold position", + initialLane: "impl" as never, + }); + yield* Effect.yieldNow; + assert.exists(supersedeStarted); + assert.exists(supersedeRelease); + yield* awaitDeferredWithinYields(supersedeStarted, "supersede start"); + yield* engine.moveTicket(ticketId, "needs" as never); + yield* Deferred.succeed(supersedeRelease, undefined); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + }), + ); + + it.effect("manual move interrupts the stale running pipeline", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-hard-supersede" as never, definition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-hard-supersede" as never, + title: "Interrupt stale work", + initialLane: "impl" as never, + }); + yield* Effect.yieldNow; + assert.exists(supersedeStarted); + assert.exists(supersedeInterrupted); + yield* awaitDeferredWithinYields(supersedeStarted, "hard supersede start"); + + yield* engine.moveTicket(ticketId, "needs" as never); + + yield* awaitDeferredWithinYields(supersedeInterrupted, "hard supersede interrupt"); + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + }), + ); +}); + +const routedAutoDefinition = { + name: "routed-auto-wf", + lanes: [ + { + key: "route", + name: "Route", + entry: "auto", + pipeline: [ + { + key: "route-step", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "route", + }, + ], + on: { success: "routed" }, + }, + { + key: "routed", + name: "Routed", + entry: "auto", + pipeline: [ + { + key: "routed-step", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "routed work", + }, + ], + on: { success: "done" }, + }, + { key: "manual", name: "Manual", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const routedAutoBlockingExecutor = Layer.effect( + StepExecutor, + Effect.gen(function* () { + const started = yield* Deferred.make(); + const interrupted = yield* Deferred.make(); + const release = yield* Deferred.make(); + routedAutoStarted = started; + routedAutoRelease = release; + routedAutoCompletions = 0; + + return StepExecutor.of({ + execute: (ctx) => { + if (ctx.step.key !== "routed-step") { + return Effect.succeed({ _tag: "completed" as const }); + } + + return Effect.gen(function* () { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + routedAutoCompletions += 1; + return { _tag: "completed" as const }; + }).pipe( + Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined).pipe(Effect.ignore)), + ); + }, + } satisfies StepExecutorShape); + }), +); + +const routedAutoSupersedeLayer = it.layer(baseLayer(routedAutoBlockingExecutor)); + +routedAutoSupersedeLayer("WorkflowEngine routed auto lane supersede", (it) => { + it.effect("starts the routed auto pipeline and lets a manual move interrupt it", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-routed-auto-supersede" as never, routedAutoDefinition); + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-routed-auto-supersede" as never, + title: "Interrupt routed lane", + initialLane: "route" as never, + }); + assert.exists(routedAutoStarted); + assert.exists(routedAutoRelease); + yield* awaitDeferredWithinYields(routedAutoStarted, "routed auto start"); + + const moveFiber = yield* Effect.forkChild(engine.moveTicket(ticketId, "manual" as never)); + for (let attempt = 0; attempt < 20; attempt += 1) { + const exit = yield* Effect.sync(() => moveFiber.pollUnsafe()); + if (exit !== undefined) { + break; + } + yield* Effect.yieldNow; + } + const moveExitBeforeRelease = yield* Effect.sync(() => moveFiber.pollUnsafe()); + if (moveExitBeforeRelease === undefined) { + yield* Deferred.succeed(routedAutoRelease, undefined); + yield* Effect.yieldNow; + } + + assert.exists( + moveExitBeforeRelease, + "manual move should complete while the routed auto lane is still blocked", + ); + yield* Deferred.succeed(routedAutoRelease, undefined); + for (let attempt = 0; attempt < 20; attempt += 1) { + yield* Effect.yieldNow; + } + + const detail = yield* awaitLane(ticketId as string, "manual"); + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.equal(detail?.ticket.currentLaneKey, "manual"); + assert.isTrue( + events.some( + (event) => + event.type === "TicketMovedToLane" && + event.payload.toLane === "routed" && + event.payload.reason === "routed", + ), + ); + assert.equal(routedAutoCompletions, 0); + assert.isFalse( + events.some( + (event) => event.type === "StepCompleted" && event.payload.stepRunId === "steprun-2", + ), + ); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.retry.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.retry.test.ts new file mode 100644 index 00000000000..495a8548838 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.retry.test.ts @@ -0,0 +1,614 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import type { StepOutcome } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +interface RecordedCall { + readonly stepKey: string; + readonly model: string | null; + readonly instance: string | null; + readonly optionIds: ReadonlyArray; +} + +interface ScriptedExecutor { + readonly calls: Array; + readonly layer: Layer.Layer; +} + +const makeScriptedExecutor = (outcomeForCall: (call: number) => StepOutcome): ScriptedExecutor => { + const calls: Array = []; + const layer = Layer.succeed(StepExecutor, { + execute: (ctx) => + Effect.sync(() => { + const step = ctx.step; + calls.push({ + stepKey: step.key as string, + model: step.type === "agent" ? (step.agent.model as string) : null, + instance: step.type === "agent" ? (step.agent.instance as string) : null, + optionIds: + step.type === "agent" ? (step.agent.options ?? []).map((o) => o.id as string) : [], + }); + return outcomeForCall(calls.length); + }), + } satisfies StepExecutorShape); + return { calls, layer }; +}; + +const baseLayer = (executor: Layer.Layer) => + WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(executor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + +const awaitTicketWhere = (ticketId: string, predicate: (detail: TicketDetail | null) => boolean) => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + for (let attempt = 0; attempt < 100; attempt += 1) { + const detail = yield* read.getTicketDetail(ticketId as never); + if (predicate(detail)) { + return detail; + } + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 10))); + yield* Effect.yieldNow; + } + return yield* read.getTicketDetail(ticketId as never); + }); + +const awaitLane = (ticketId: string, laneKey: string) => + awaitTicketWhere(ticketId, (detail) => detail?.ticket.currentLaneKey === laneKey); + +const retryDefinition = (retry: unknown) => ({ + name: "retry-wf", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + retry, + }, + ], + on: { success: "done", failure: "needs" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}); + +const flakyExecutor = makeScriptedExecutor((call) => + call < 3 ? { _tag: "failed", error: `boom ${call}` } : { _tag: "completed" }, +); + +const flakyLayer = it.layer(baseLayer(flakyExecutor.layer)); + +flakyLayer("retry with escalation succeeds on a later attempt", (it) => { + it.effect("re-runs failed agent steps with the escalated selection", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register( + "b-retry" as never, + retryDefinition({ + maxAttempts: 3, + escalate: { model: "opus", options: [{ id: "effort", value: "high" }] }, + }) as never, + ); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-retry" as never, + title: "Flaky work", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "done"); + assert.equal(detail?.ticket.currentLaneKey, "done"); + + assert.equal(flakyExecutor.calls.length, 3); + assert.deepEqual( + flakyExecutor.calls.map((call) => call.model), + ["sonnet", "opus", "opus"], + ); + assert.deepEqual(flakyExecutor.calls[1]?.optionIds, ["effort"]); + + const codeRuns = (detail?.steps ?? []).filter((step) => step.stepKey === "code"); + assert.equal(codeRuns.length, 3); + assert.deepEqual( + codeRuns.map((step) => step.attempt), + [1, 2, 3], + ); + assert.deepEqual( + codeRuns.map((step) => step.status), + ["failed", "failed", "completed"], + ); + }), + ); +}); + +const alwaysFailExecutor = makeScriptedExecutor((call) => ({ + _tag: "failed", + error: `boom ${call}`, +})); + +const exhaustedLayer = it.layer(baseLayer(alwaysFailExecutor.layer)); + +exhaustedLayer("retry exhaustion routes the final failure", (it) => { + it.effect("stops after maxAttempts and routes to the failure lane", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-exhaust" as never, retryDefinition({ maxAttempts: 2 }) as never); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-exhaust" as never, + title: "Hopeless work", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.equal(alwaysFailExecutor.calls.length, 2); + assert.deepEqual( + (detail?.steps ?? []).map((step) => step.status), + ["failed", "failed"], + ); + }), + ); +}); + +const blockedExecutor = makeScriptedExecutor(() => ({ _tag: "blocked", reason: "no trust" })); + +const blockedLayer = it.layer(baseLayer(blockedExecutor.layer)); + +blockedLayer("blocked outcomes never retry", (it) => { + it.effect("runs the step exactly once", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register( + "b-blocked" as never, + { + ...retryDefinition({ maxAttempts: 3 }), + lanes: retryDefinition({ maxAttempts: 3 }).lanes.map((lane) => + lane.key === "impl" ? { ...lane, on: { ...lane.on, blocked: "needs" } } : lane, + ), + } as never, + ); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-blocked" as never, + title: "Blocked work", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.equal(blockedExecutor.calls.length, 1); + }), + ); +}); + +const awaitingExecutor = makeScriptedExecutor(() => ({ + _tag: "awaiting_user", + waitingReason: "Need a decision", +})); + +const rejectionLayer = it.layer(baseLayer(awaitingExecutor.layer)); + +rejectionLayer("user rejections never retry", (it) => { + it.effect("a rejected awaiting-user step fails without another attempt", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-reject" as never, retryDefinition({ maxAttempts: 3 }) as never); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-reject" as never, + title: "Risky work", + initialLane: "impl" as never, + }); + + const waiting = yield* awaitTicketWhere( + ticketId as string, + (detail) => detail?.ticket.status === "waiting_on_user", + ); + const stepRunId = waiting?.steps[0]?.stepRunId; + assert.ok(stepRunId !== undefined); + + yield* engine.resolveApproval(stepRunId as never, false); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.equal(awaitingExecutor.calls.length, 1); + assert.deepEqual( + (detail?.steps ?? []).map((step) => step.status), + ["failed"], + ); + }), + ); +}); + +const cancelledExecutor = makeScriptedExecutor((call) => ({ + _tag: "failed", + error: `cancelled ${call}`, + retryable: false, +})); + +const cancelledLayer = it.layer(baseLayer(cancelledExecutor.layer)); + +cancelledLayer("non-retryable failures never retry", (it) => { + it.effect("a cancelled step fails without another attempt", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register( + "b-cancelled" as never, + retryDefinition({ maxAttempts: 3 }) as never, + ); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-cancelled" as never, + title: "Cancelled work", + initialLane: "impl" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.equal(cancelledExecutor.calls.length, 1); + }), + ); +}); + +const recoveryExecutor = makeScriptedExecutor(() => ({ _tag: "completed" })); + +const recoveryLayer = it.layer(baseLayer(recoveryExecutor.layer)); + +recoveryLayer("recovered failed attempts resume the retry loop", (it) => { + it.effect("a failed attempt recovered after restart consumes its remaining attempts", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register( + "b-recover" as never, + retryDefinition({ maxAttempts: 2, escalate: { model: "opus" } }) as never, + ); + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + + const seed = (event: Record, eventId: string) => + committer.commit({ + ...event, + eventId, + occurredAt: "1969-12-31T00:00:00.000Z", + } as never); + + yield* seed( + { + type: "TicketCreated", + ticketId: "t-recover", + payload: { boardId: "b-recover", title: "Restarted work", laneKey: "impl" }, + }, + "evt-rec-created", + ); + yield* seed( + { + type: "TicketMovedToLane", + ticketId: "t-recover", + payload: { toLane: "impl", laneEntryToken: "tok-rec", reason: "initial" }, + }, + "evt-rec-moved", + ); + yield* seed( + { + type: "PipelineStarted", + ticketId: "t-recover", + payload: { pipelineRunId: "pipe-rec", laneKey: "impl", laneEntryToken: "tok-rec" }, + }, + "evt-rec-pipe", + ); + yield* seed( + { + type: "StepStarted", + ticketId: "t-recover", + payload: { + pipelineRunId: "pipe-rec", + stepRunId: "step-rec-1", + stepKey: "code", + stepType: "agent", + attempt: 1, + }, + }, + "evt-rec-step", + ); + + yield* engine.completeRecoveredStep("step-rec-1" as never, { + _tag: "failed", + error: "interrupted", + }); + + const detail = yield* awaitLane("t-recover", "done"); + assert.equal(detail?.ticket.currentLaneKey, "done"); + // The recovered failure consumed attempt 1; the engine ran attempt 2 + // with the escalated selection and routed the success. + assert.equal(recoveryExecutor.calls.length, 1); + assert.equal(recoveryExecutor.calls[0]?.model, "opus"); + const codeRuns = (detail?.steps ?? []).filter((step) => step.stepKey === "code"); + assert.deepEqual( + codeRuns.map((step) => [step.attempt, step.status]), + [ + [1, "failed"], + [2, "completed"], + ], + ); + }), + ); +}); + +const recoveredCancelExecutor = makeScriptedExecutor(() => ({ _tag: "completed" })); + +const recoveredCancelLayer = it.layer(baseLayer(recoveredCancelExecutor.layer)); + +recoveredCancelLayer("recovered non-retryable failures never retry", (it) => { + it.effect("a recovered cancellation routes the failure without new attempts", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register( + "b-recover-cancel" as never, + retryDefinition({ maxAttempts: 3 }) as never, + ); + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + + const seed = (event: Record, eventId: string) => + committer.commit({ + ...event, + eventId, + occurredAt: "1969-12-31T00:00:00.000Z", + } as never); + + yield* seed( + { + type: "TicketCreated", + ticketId: "t-recover-cancel", + payload: { boardId: "b-recover-cancel", title: "Cancelled work", laneKey: "impl" }, + }, + "evt-rc-created", + ); + yield* seed( + { + type: "TicketMovedToLane", + ticketId: "t-recover-cancel", + payload: { toLane: "impl", laneEntryToken: "tok-rc", reason: "initial" }, + }, + "evt-rc-moved", + ); + yield* seed( + { + type: "PipelineStarted", + ticketId: "t-recover-cancel", + payload: { pipelineRunId: "pipe-rc", laneKey: "impl", laneEntryToken: "tok-rc" }, + }, + "evt-rc-pipe", + ); + yield* seed( + { + type: "StepStarted", + ticketId: "t-recover-cancel", + payload: { + pipelineRunId: "pipe-rc", + stepRunId: "step-rc-1", + stepKey: "code", + stepType: "script", + attempt: 1, + }, + }, + "evt-rc-step", + ); + + yield* engine.completeRecoveredStep("step-rc-1" as never, { + _tag: "failed", + error: "script cancelled", + retryable: false, + }); + + const detail = yield* awaitLane("t-recover-cancel", "needs"); + assert.equal(detail?.ticket.currentLaneKey, "needs"); + assert.equal(recoveredCancelExecutor.calls.length, 0); + }), + ); +}); + +const loopDefinition = { + name: "loop-wf", + lanes: [ + { + key: "implementation", + name: "Implementation", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "implement", + }, + { + key: "review", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "review", + captureOutput: true, + }, + ], + transitions: [ + { + when: { + and: [ + { "==": [{ var: "steps.review.output.verdict" }, "revise"] }, + { "<": [{ var: "lane.runCount" }, 3] }, + ], + }, + to: "implementation", + }, + { + when: { "==": [{ var: "steps.review.output.verdict" }, "revise"] }, + to: "manual_review", + }, + { + when: { "==": [{ var: "steps.review.output.verdict" }, "approve"] }, + to: "owner_review", + }, + ], + on: { success: "owner_review", failure: "needs", blocked: "needs" }, + }, + { key: "owner_review", name: "Owner Review", entry: "manual" }, + { key: "manual_review", name: "Manual Review", entry: "manual" }, + { key: "needs", name: "Needs", entry: "manual" }, + ], +}; + +const reviewLoopExecutor = makeScriptedExecutor((call) => { + // Calls alternate implement/review per lane run: 1=impl 2=review(revise) + // 3=impl 4=review(approve). + if (call % 2 === 1) { + return { _tag: "completed" }; + } + return { _tag: "completed", output: { verdict: call < 4 ? "revise" : "approve" } }; +}); + +const reviewLoopLayer = it.layer(baseLayer(reviewLoopExecutor.layer)); + +reviewLoopLayer("lane.runCount bounds the review loop", (it) => { + it.effect("revise re-enters the lane and approve routes onward", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-loop" as never, loopDefinition as never); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-loop" as never, + title: "Loop work", + initialLane: "implementation" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "owner_review"); + assert.equal(detail?.ticket.currentLaneKey, "owner_review"); + // Two full lane runs: implement+review, then implement+review again. + assert.equal(reviewLoopExecutor.calls.length, 4); + const reviewRuns = (detail?.steps ?? []).filter((step) => step.stepKey === "review"); + assert.equal(reviewRuns.length, 2); + }), + ); +}); + +const exhaustedLoopExecutor = makeScriptedExecutor((call) => + call % 2 === 1 ? { _tag: "completed" } : { _tag: "completed", output: { verdict: "revise" } }, +); + +const exhaustedLoopLayer = it.layer(baseLayer(exhaustedLoopExecutor.layer)); + +exhaustedLoopLayer("review loop budget exhausts to manual review", (it) => { + it.effect("a persistently revised ticket escalates after three lane runs", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-loop-exhaust" as never, loopDefinition as never); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-loop-exhaust" as never, + title: "Stubborn work", + initialLane: "implementation" as never, + }); + + const detail = yield* awaitLane(ticketId as string, "manual_review"); + assert.equal(detail?.ticket.currentLaneKey, "manual_review"); + // Three lane runs of implement+review before escalation. + assert.equal(exhaustedLoopExecutor.calls.length, 6); + + // A manual move back into the lane is a human intervention: the loop + // budget resets and the ticket gets three fresh passes. + yield* engine.moveTicket(ticketId, "implementation" as never); + const second = yield* awaitTicketWhere( + ticketId as string, + (current) => + current?.ticket.currentLaneKey === "manual_review" && (current.steps?.length ?? 0) >= 12, + ); + assert.equal(second?.ticket.currentLaneKey, "manual_review"); + assert.equal(exhaustedLoopExecutor.calls.length, 12); + }), + ); +}); + +const commentExecutor = makeScriptedExecutor(() => ({ _tag: "completed" })); +const commentLayer = it.layer(baseLayer(commentExecutor.layer)); + +commentLayer("postTicketMessage", (it) => { + it.effect("posts a user comment without an awaiting step", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-comment" as never, retryDefinition({ maxAttempts: 2 }) as never); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-comment" as never, + title: "Comment target", + initialLane: "needs" as never, + }); + + yield* engine.postTicketMessage({ ticketId, text: "Note to self: check auth flow." }); + + const detail = yield* read.getTicketDetail(ticketId); + assert.equal(detail?.messages.length, 1); + assert.equal(detail?.messages[0]?.author, "user"); + assert.equal(detail?.messages[0]?.body, "Note to self: check auth flow."); + assert.equal(detail?.messages[0]?.stepRunId, null); + + const empty = yield* Effect.exit(engine.postTicketMessage({ ticketId, text: " " })); + assert.equal(empty._tag, "Failure"); + + const missing = yield* Effect.exit( + engine.postTicketMessage({ ticketId: "nope" as never, text: "hello" }), + ); + assert.equal(missing._tag, "Failure"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.token-migration.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.token-migration.test.ts new file mode 100644 index 00000000000..06b84387f2c --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.token-migration.test.ts @@ -0,0 +1,21 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("ticket token migration", (it) => { + it.effect("projection_ticket has current_lane_entry_token", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_ticket) + `; + assert.isTrue(columns.some((column) => column.name === "current_lane_entry_token")); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.ts b/apps/server/src/workflow/Layers/WorkflowEngine.ts new file mode 100644 index 00000000000..2c8c9b60aef --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.ts @@ -0,0 +1,2304 @@ +import type { + BoardId, + LaneEntryToken, + LaneKey, + MessageId, + PipelineRunId, + StepOutcome, + StepRunId, + TicketAttachment, + ThreadId, + TicketId, + TurnId, + WorkflowEventId, + WorkflowLane, + WorkflowStep, + WorkflowStepUsage, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Semaphore from "effect/Semaphore"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { ApprovalGate } from "../Services/ApprovalGate.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { CapturedStepOutputReader } from "../Services/CapturedStepOutputReader.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { PredicateEvaluator } from "../Services/PredicateEvaluator.ts"; +import { ProviderDispatchOutbox } from "../Services/ProviderDispatchOutbox.ts"; +import { ProviderResponsePort } from "../Services/ProviderResponsePort.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor } from "../Services/StepExecutor.ts"; +import { StepUsageReader } from "../Services/StepUsageReader.ts"; +import { TurnStateReader, type TurnState } from "../Services/TurnStateReader.ts"; +import { + WorkflowEngine, + type RecoveredStepResult, + type WorkflowEngineShape, +} from "../Services/WorkflowEngine.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { + WorkflowEventStore, + type PersistedWorkflowEvent, + type WorkflowEventInput, +} from "../Services/WorkflowEventStore.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { + WorkflowRoutingContextBuilder, + type WorkflowRoutingContext, +} from "../Services/WorkflowRoutingContextBuilder.ts"; +import { MAX_TICKET_MESSAGE_BODY_LENGTH, truncateTicketMessageBody } from "../ticketMessageBody.ts"; + +type PipelineResult = "success" | "failure" | "blocked"; +type StepResult = "completed" | "failed" | "blocked"; +type RouteSource = "step_on" | "lane_transition" | "lane_on"; +type MoveReason = "manual" | "routed" | "initial" | "external"; + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); +const formatError = (error: unknown) => (error instanceof Error ? error.message : String(error)); +const toEngineSqlError = (cause: unknown) => + new WorkflowEventStoreError({ message: "workflow engine sql failed", cause }); +const wrapSql = (effect: Effect.Effect) => + effect.pipe(Effect.mapError(toEngineSqlError)); + +const alreadyStoppedProviderErrorTags = new Set([ + "ProviderSessionNotFoundError", + "ProviderAdapterSessionNotFoundError", + "ProviderAdapterSessionClosedError", +]); + +const providerErrorTag = (cause: unknown) => { + if (typeof cause !== "object" || cause === null || !("_tag" in cause)) { + return null; + } + const tag = (cause as { readonly _tag?: unknown })._tag; + return typeof tag === "string" ? tag : null; +}; + +const isAlreadyStoppedProviderError = (cause: unknown) => { + const tag = providerErrorTag(cause); + if (tag !== null && alreadyStoppedProviderErrorTags.has(tag)) { + return true; + } + if (!(cause instanceof Error)) { + return false; + } + return /(?:no active (?:provider )?(?:session|turn)|unknown provider thread|unknown .* adapter thread|adapter thread is closed)/i.test( + cause.message, + ); +}; + +const providerCleanupAttempt = ( + effect: Effect.Effect, + message: string, +): Effect.Effect => + effect.pipe( + Effect.as(null), + Effect.catch((cause) => + isAlreadyStoppedProviderError(cause) + ? Effect.succeed(null) + : Effect.succeed(new WorkflowEventStoreError({ message, cause })), + ), + ); + +const stepCompletedPayload = ( + stepRunId: StepRunId, + output?: unknown, + usage?: WorkflowStepUsage, +) => ({ + stepRunId, + ...(output === undefined ? {} : { output }), + ...(usage === undefined ? {} : { usage }), +}); + +const stepFailedPayload = ( + stepRunId: StepRunId, + error: string, + usage?: WorkflowStepUsage, + retryable?: boolean, +) => ({ + stepRunId, + error, + ...(retryable === undefined ? {} : { retryable }), + ...(usage === undefined ? {} : { usage }), +}); + +const MAX_TICKET_ANSWER_BODY_LENGTH = MAX_TICKET_MESSAGE_BODY_LENGTH; +const MAX_TICKET_ANSWER_ATTACHMENT_COUNT = 6; +const MAX_TICKET_ANSWER_ATTACHMENT_BYTES = 10 * 1024 * 1024; +const SAFE_TICKET_IMAGE_MIME_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", +]); +const SAFE_TICKET_IMAGE_DATA_URL = /^data:image\/(?:png|jpeg|gif|webp);base64,/i; + +type PendingWait = Extract; +type StepStarted = Extract; +type PipelineStarted = Extract; +type TicketCreated = Extract; +type UnstampedWorkflowEventInput = WorkflowEventInput extends infer Event + ? Event extends WorkflowEventInput + ? Omit + : never + : never; + +interface ActivePipeline { + readonly fiber: Fiber.Fiber; + readonly laneEntryToken: LaneEntryToken; +} + +interface StepTicketRow { + readonly ticketId: TicketId; +} + +interface StepBoardRow { + readonly boardId: BoardId; +} + +interface StepAwaitingStateRow { + readonly status: string; + readonly providerResponseKind: "request" | "user-input" | null; +} + +interface PipelineRunForTokenRow { + readonly pipelineRunId: PipelineRunId; +} + +interface ActiveProviderTurnRow { + readonly threadId: ThreadId; + readonly turnId: TurnId | null; +} + +interface RouteDecision { + readonly toLane: LaneKey; + readonly source: RouteSource; + readonly matchedTransitionIndex?: number; +} + +interface CaptureTurn { + readonly threadId: ThreadId; + readonly turnId: TurnId; +} + +interface PipelineStartAction { + readonly ticketId: TicketId; + readonly boardId: BoardId; + readonly lane: WorkflowLane; + readonly laneEntryToken: LaneEntryToken; +} + +interface RoutedEnterLaneOptions { + readonly routeDecision: RouteDecision; + readonly contextSnapshot: WorkflowRoutingContext; + readonly expectedToken: LaneEntryToken; + readonly pipelineRunId: PipelineRunId; + readonly fromLane: WorkflowLane; +} + +interface ExternalEnterLaneOptions { + // The lane the matcher was evaluated against — a concurrent move makes the + // decision stale and the external move becomes a no-op. + readonly expectedFromLane: LaneKey; + readonly routeEvent: UnstampedWorkflowEventInput; + // Re-runs matcher resolution under the admission lock: a board save between + // evaluation and commit may have removed the matcher or the target lane. + readonly revalidate: Effect.Effect; +} + +const pipelineResultForStep = (result: StepResult): PipelineResult => { + if (result === "completed") { + return "success"; + } + return result === "blocked" ? "blocked" : "failure"; +}; + +const routingKeyForResult = (result: PipelineResult): "success" | "failure" | "blocked" => + result === "failure" ? "failure" : result; + +const stepRouteDecision = (step: WorkflowStep, result: PipelineResult): RouteDecision | null => { + const target = step.on?.[routingKeyForResult(result)]; + return target ? { toLane: target, source: "step_on" } : null; +}; + +interface StepRunOutcome { + readonly result: StepResult; + // User rejections (approval reject / awaiting-user reject) and explicit + // cancellations must never be retried — the user already said no. + readonly noRetry: boolean; +} + +// Defensive clamp so a hand-edited workflow file cannot retry unboundedly; +// the linter enforces 2..5 at save time. +const MAX_RETRY_ATTEMPTS = 5; + +const retryAttemptsForStep = (step: WorkflowStep): number => { + if (step.type === "approval" || step.type === "merge" || step.retry === undefined) { + return 1; + } + return Math.min(Math.max(1, step.retry.maxAttempts), MAX_RETRY_ATTEMPTS); +}; + +const stepForAttempt = (step: WorkflowStep, attempt: number): WorkflowStep => { + if (attempt === 1 || step.type !== "agent" || step.retry?.escalate === undefined) { + return step; + } + const escalate = step.retry.escalate; + return { + ...step, + agent: { + ...step.agent, + ...(escalate.instance === undefined ? {} : { instance: escalate.instance }), + ...(escalate.model === undefined ? {} : { model: escalate.model }), + ...(escalate.options === undefined ? {} : { options: escalate.options }), + }, + }; +}; + +const make = Effect.gen(function* () { + const approvals = yield* ApprovalGate; + const scriptCancels = yield* ScriptCancelRegistry; + const committer = yield* WorkflowEventCommitter; + const executor = yield* StepExecutor; + const ids = yield* WorkflowIds; + const predicates = yield* PredicateEvaluator; + const read = yield* WorkflowReadModel; + const registry = yield* BoardRegistry; + const routingContextBuilder = yield* WorkflowRoutingContextBuilder; + const sql = yield* SqlClient.SqlClient; + const boardSemaphores = yield* SynchronizedRef.make>(new Map()); + const admissionSemaphores = yield* SynchronizedRef.make>( + new Map(), + ); + const runningPipelines = yield* SynchronizedRef.make>(new Map()); + // One recovery continuation per step run per process: the dispatch monitors + // and the stranded-pipeline sweep can race to recover the same step. + const recoveredStepClaims = yield* SynchronizedRef.make>(new Set()); + + const getOptionalServices = Effect.context().pipe( + Effect.map((context) => ({ + providerResponses: Context.getOption( + context as Context.Context, + ProviderResponsePort, + ), + providerDispatches: Context.getOption( + context as Context.Context, + ProviderDispatchOutbox, + ), + providerService: Context.getOption( + context as Context.Context, + ProviderService, + ), + turnStateReader: Context.getOption( + context as Context.Context, + TurnStateReader, + ), + capturedOutputs: Context.getOption( + context as Context.Context, + CapturedStepOutputReader, + ), + usageReader: Context.getOption(context as Context.Context, StepUsageReader), + store: Context.getOption(context as Context.Context, WorkflowEventStore), + })), + ); + + const ticketIdForStepRun = (stepRunId: StepRunId) => + wrapSql(sql` + SELECT ticket_id AS "ticketId" + FROM projection_step_run + WHERE step_run_id = ${stepRunId} + UNION ALL + SELECT ticket_id AS "ticketId" + FROM workflow_events + WHERE event_type = 'StepAwaitingUser' + AND json_extract(payload_json, '$.stepRunId') = ${stepRunId} + LIMIT 1 + `).pipe(Effect.map((rows) => rows[0]?.ticketId ?? null)); + + const boardIdForStepRun = (stepRunId: StepRunId) => + wrapSql(sql` + SELECT ticket.board_id AS "boardId" + FROM projection_step_run AS step + INNER JOIN projection_ticket AS ticket + ON ticket.ticket_id = step.ticket_id + WHERE step.step_run_id = ${stepRunId} + UNION ALL + SELECT json_extract(created.payload_json, '$.boardId') AS "boardId" + FROM workflow_events AS awaiting + INNER JOIN workflow_events AS created + ON created.ticket_id = awaiting.ticket_id + AND created.event_type = 'TicketCreated' + WHERE awaiting.event_type = 'StepAwaitingUser' + AND json_extract(awaiting.payload_json, '$.stepRunId') = ${stepRunId} + LIMIT 1 + `).pipe(Effect.map((rows) => rows[0]?.boardId ?? null)); + + const awaitingStateForStepRun = (stepRunId: StepRunId) => + wrapSql(sql` + SELECT + status, + provider_response_kind AS "providerResponseKind" + FROM projection_step_run + WHERE step_run_id = ${stepRunId} + LIMIT 1 + `).pipe(Effect.map((rows) => rows[0] ?? null)); + + const readStoredEventsForStep = (stepRunId: StepRunId) => + Effect.gen(function* () { + const { store } = yield* getOptionalServices; + if (Option.isNone(store)) { + return null; + } + + const ticketId = yield* ticketIdForStepRun(stepRunId); + if (ticketId === null) { + return null; + } + + return yield* Stream.runCollect(store.value.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + }); + + const pendingWaitInEvents = ( + events: ReadonlyArray, + stepRunId: StepRunId, + ) => { + let pending: PendingWait | null = null; + for (const event of events) { + if (event.type === "StepAwaitingUser" && event.payload.stepRunId === stepRunId) { + pending = event; + continue; + } + if (event.type === "StepUserResolved" && event.payload.stepRunId === stepRunId) { + pending = null; + } + } + return pending; + }; + + const isLiveProviderUserInputWait = (pending: PendingWait, state: TurnState) => { + if ( + state._tag !== "awaiting_user" || + state.providerResponseKind !== "user-input" || + pending.payload.providerResponseKind !== "user-input" || + pending.payload.providerThreadId === undefined || + pending.payload.providerRequestId === undefined + ) { + return false; + } + + return ( + String(state.providerThreadId) === String(pending.payload.providerThreadId) && + String(state.providerRequestId) === String(pending.payload.providerRequestId) && + (state.providerQuestionId ?? null) === (pending.payload.providerQuestionId ?? null) + ); + }; + + const ensureLiveProviderUserInputWait = (pending: PendingWait | null) => + Effect.gen(function* () { + if ( + pending?.payload.providerResponseKind !== "user-input" || + pending.payload.providerThreadId === undefined || + pending.payload.providerRequestId === undefined + ) { + return; + } + + const { providerDispatches, turnStateReader } = yield* getOptionalServices; + if (Option.isNone(turnStateReader)) { + if (Option.isSome(providerDispatches)) { + return yield* new WorkflowEventStoreError({ + message: + "provider user-input request is not live yet; retry after recovery refreshes it", + }); + } + return; + } + + const state = yield* turnStateReader.value.read(pending.payload.providerThreadId); + if (isLiveProviderUserInputWait(pending, state)) { + return; + } + + return yield* new WorkflowEventStoreError({ + message: "provider user-input request is not live yet; retry after recovery refreshes it", + }); + }); + + const hasTerminalStepEvent = ( + events: ReadonlyArray, + stepRunId: StepRunId, + ) => + events.some( + (event) => + (event.type === "StepCompleted" || + event.type === "StepFailed" || + event.type === "StepBlocked") && + event.payload.stepRunId === stepRunId, + ); + + const hasPipelineCompletedEvent = ( + events: ReadonlyArray, + pipelineRunId: PipelineRunId, + ) => + events.some( + (event) => + event.type === "PipelineCompleted" && event.payload.pipelineRunId === pipelineRunId, + ); + + const pendingWaitFor = (stepRunId: StepRunId) => + Effect.gen(function* () { + const events = yield* readStoredEventsForStep(stepRunId); + if (events === null) { + return null; + } + return pendingWaitInEvents(events, stepRunId); + }); + + const ticketAnswerAttachmentBytes = (attachments: ReadonlyArray) => + attachments.reduce((total, attachment) => { + if (attachment.kind !== "image") { + return total; + } + return total + new TextEncoder().encode(attachment.dataUrl).byteLength; + }, 0); + + const semaphoreFor = (boardId: BoardId, permits: number) => + SynchronizedRef.modifyEffect(boardSemaphores, (current) => { + const key = boardId as string; + const existing = current.get(key); + if (existing) { + return Effect.succeed([existing, current] as const); + } + + return Semaphore.make(permits).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(key, semaphore); + return [semaphore, next] as const; + }), + ); + }); + + const admissionSemaphoreFor = (boardId: BoardId) => + SynchronizedRef.modifyEffect(admissionSemaphores, (current) => { + const key = boardId as string; + const existing = current.get(key); + if (existing) { + return Effect.succeed([existing, current] as const); + } + + return Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(key, semaphore); + return [semaphore, next] as const; + }), + ); + }); + + const withAdmissionLock = ( + boardId: BoardId, + body: Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + const semaphore = yield* admissionSemaphoreFor(boardId); + return yield* semaphore.withPermits(1)(body); + }); + + const commit = ( + event: UnstampedWorkflowEventInput, + ): Effect.Effect => + Effect.gen(function* () { + const eventId = yield* ids.eventId(); + yield* committer.commit({ + ...event, + eventId: eventId as WorkflowEventId, + occurredAt: (yield* nowIso) as never, + } as WorkflowEventInput); + }); + + const commitMany = ( + events: ReadonlyArray, + ): Effect.Effect => + Effect.gen(function* () { + const stamped: Array = []; + for (const event of events) { + const eventId = yield* ids.eventId(); + stamped.push({ + ...event, + eventId: eventId as WorkflowEventId, + occurredAt: (yield* nowIso) as never, + } as WorkflowEventInput); + } + yield* committer.commitMany(stamped); + }); + + const userInputPromptMessageEvent = ( + ticketId: TicketId, + stepRunId: StepRunId, + body: string, + ): Effect.Effect => + Effect.gen(function* () { + const messageId = yield* ids.messageId(); + const createdAt = yield* nowIso; + return { + type: "TicketMessagePosted", + ticketId, + payload: { + messageId: messageId as MessageId, + stepRunId, + author: "agent", + body: truncateTicketMessageBody(body), + attachments: [], + createdAt: createdAt as never, + }, + } satisfies UnstampedWorkflowEventInput; + }); + + const awaitingUserEvents = ( + ticketId: TicketId, + event: Extract, + ): Effect.Effect, never> => + Effect.gen(function* () { + if (event.payload.providerResponseKind !== "user-input") { + return [event]; + } + const message = yield* userInputPromptMessageEvent( + ticketId, + event.payload.stepRunId, + event.payload.waitingReason, + ); + return [event, message]; + }); + + const currentToken = (ticketId: TicketId) => + read + .getTicketDetail(ticketId) + .pipe(Effect.map((detail) => detail?.ticket.currentLaneEntryToken ?? null)); + + const evaluateTransition = (rule: unknown, context: WorkflowRoutingContext) => + predicates.evaluate(rule, context).pipe( + Effect.mapError( + (cause) => + new WorkflowEventStoreError({ + message: "workflow route predicate evaluation failed", + cause, + }), + ), + ); + + const laneTransitionDecision = ( + lane: WorkflowLane, + context: WorkflowRoutingContext, + ): Effect.Effect => + Effect.gen(function* () { + const transitions = lane.transitions ?? []; + for (const [index, transition] of transitions.entries()) { + const evaluation = yield* evaluateTransition(transition.when, context); + if (evaluation.result) { + return { + toLane: transition.to, + source: "lane_transition", + matchedTransitionIndex: index, + } satisfies RouteDecision; + } + } + return null; + }); + + const laneOnDecision = (lane: WorkflowLane, result: PipelineResult): RouteDecision | null => { + const target = lane.on?.[routingKeyForResult(result)]; + return target ? { toLane: target, source: "lane_on" } : null; + }; + + const routeDecisionEvent = ( + ticketId: TicketId, + pipelineRunId: PipelineRunId, + lane: WorkflowLane, + decision: RouteDecision, + contextSnapshot: WorkflowRoutingContext, + ): UnstampedWorkflowEventInput => + ({ + type: "TicketRouteDecided", + ticketId, + payload: { + pipelineRunId, + fromLane: lane.key, + toLane: decision.toLane, + source: decision.source, + ...(decision.matchedTransitionIndex === undefined + ? {} + : { matchedTransitionIndex: decision.matchedTransitionIndex }), + contextSnapshot, + }, + }) as UnstampedWorkflowEventInput; + + const clearRunningPipeline = (ticketId: TicketId, laneEntryToken: LaneEntryToken) => + SynchronizedRef.update(runningPipelines, (current) => { + const key = ticketId as string; + const active = current.get(key); + if (!active || active.laneEntryToken !== laneEntryToken) { + return current; + } + + const next = new Map(current); + next.delete(key); + return next; + }); + + const interruptRunningPipeline = (ticketId: TicketId) => + Effect.gen(function* () { + const active = yield* SynchronizedRef.modify(runningPipelines, (current) => { + const key = ticketId as string; + const existing = current.get(key) ?? null; + if (!existing) { + return [null, current] as const; + } + + const next = new Map(current); + next.delete(key); + return [existing, next] as const; + }); + if (active) { + yield* Fiber.interrupt(active.fiber).pipe(Effect.ignore); + } + }); + + const readStepUsage = ( + threadId: ThreadId | undefined, + ): Effect.Effect => + Effect.gen(function* () { + if (threadId === undefined) { + return undefined; + } + const { usageReader } = yield* getOptionalServices; + if (Option.isNone(usageReader)) { + return undefined; + } + return yield* usageReader.value.read(threadId); + }); + + const awaitProviderTerminalForStep = ( + stepRunId: StepRunId, + threadId: ThreadId, + step?: WorkflowStep, + ): Effect.Effect => + Effect.gen(function* () { + const { providerDispatches } = yield* getOptionalServices; + if (Option.isNone(providerDispatches)) { + return { _tag: "completed" } satisfies RecoveredStepResult; + } + + const result = yield* providerDispatches.value.awaitStepTerminal(stepRunId, threadId); + const usage = yield* readStepUsage(threadId); + if (result.ok) { + const completed = yield* completedResultForStep(stepRunId, step); + return usage === undefined || completed._tag === "blocked" + ? completed + : { ...completed, usage }; + } + if ("awaitingUser" in result) { + return { + _tag: "failed", + error: "provider requested additional user input", + ...(usage === undefined ? {} : { usage }), + } satisfies RecoveredStepResult; + } + return { + _tag: "failed", + error: result.error ?? "turn failed", + ...(usage === undefined ? {} : { usage }), + } satisfies RecoveredStepResult; + }); + + const completedResultForStep = ( + stepRunId: StepRunId, + step: WorkflowStep | undefined, + output?: unknown, + captureTurn?: CaptureTurn, + ): Effect.Effect => + Effect.gen(function* () { + if (output !== undefined) { + return { _tag: "completed", output } satisfies RecoveredStepResult; + } + if (step?.type !== "agent" || step.captureOutput !== true) { + return { _tag: "completed" } satisfies RecoveredStepResult; + } + + const { capturedOutputs } = yield* getOptionalServices; + if (Option.isNone(capturedOutputs)) { + return { + _tag: "failed", + error: "missing or invalid structured output", + } satisfies RecoveredStepResult; + } + let turn = captureTurn; + if (turn === undefined) { + const { providerDispatches } = yield* getOptionalServices; + if (Option.isSome(providerDispatches)) { + turn = (yield* providerDispatches.value.getDispatchForStep(stepRunId)) ?? undefined; + } + } + if (turn === undefined) { + return { + _tag: "failed", + error: "missing or invalid structured output", + } satisfies RecoveredStepResult; + } + + return yield* capturedOutputs.value.read({ stepRunId, ...turn }).pipe( + Effect.map((captured) => { + if (captured === undefined) { + return { + _tag: "failed", + error: "missing or invalid structured output", + } satisfies RecoveredStepResult; + } + return { _tag: "completed", output: captured } satisfies RecoveredStepResult; + }), + Effect.orElseSucceed( + () => + ({ + _tag: "failed", + error: "structured output lookup failed", + }) satisfies RecoveredStepResult, + ), + ); + }); + + const runStep = ( + ticketId: TicketId, + boardId: BoardId, + pipelineRunId: PipelineRunId, + step: WorkflowStep, + laneEntryToken: LaneEntryToken, + attempt: number, + ): Effect.Effect => + Effect.gen(function* () { + const stepRunId = yield* ids.stepRunId(); + yield* commit({ + type: "StepStarted", + ticketId, + payload: { pipelineRunId, stepRunId, stepKey: step.key, stepType: step.type, attempt }, + }); + + if (step.type === "approval") { + yield* commit({ + type: "StepAwaitingUser", + ticketId, + payload: { stepRunId, waitingReason: step.prompt ?? "Approval required" }, + }); + const approved = yield* approvals.await(stepRunId); + yield* commit({ type: "StepUserResolved", ticketId, payload: { stepRunId } }); + if (!approved) { + yield* commit({ + type: "StepFailed", + ticketId, + payload: stepFailedPayload(stepRunId, "rejected", undefined, false), + }); + return { result: "failed", noRetry: true }; + } + yield* commit({ + type: "StepCompleted", + ticketId, + payload: stepCompletedPayload(stepRunId), + }); + return { result: "completed", noRetry: false }; + } + + const outcome = yield* ( + executor.execute({ + ticketId, + boardId, + pipelineRunId, + stepRunId, + laneEntryToken, + step, + }) as Effect.Effect + ).pipe( + Effect.catch((error) => + Effect.succeed({ _tag: "failed", error: formatError(error) }), + ), + ); + if (outcome._tag === "awaiting_user") { + const awaitingEvent = { + type: "StepAwaitingUser", + ticketId, + payload: { + stepRunId, + waitingReason: outcome.waitingReason, + ...(outcome.providerThreadId === undefined + ? {} + : { providerThreadId: outcome.providerThreadId }), + ...(outcome.providerRequestId === undefined + ? {} + : { providerRequestId: outcome.providerRequestId }), + ...(outcome.providerResponseKind === undefined + ? {} + : { providerResponseKind: outcome.providerResponseKind }), + ...(outcome.providerQuestionId === undefined + ? {} + : { providerQuestionId: outcome.providerQuestionId }), + }, + } satisfies UnstampedWorkflowEventInput; + yield* commitMany(yield* awaitingUserEvents(ticketId, awaitingEvent)); + const approved = yield* approvals.await(stepRunId); + yield* commit({ type: "StepUserResolved", ticketId, payload: { stepRunId } }); + if (!approved) { + yield* commit({ + type: "StepFailed", + ticketId, + payload: stepFailedPayload(stepRunId, "rejected", undefined, false), + }); + return { result: "failed", noRetry: true }; + } + if (outcome.providerThreadId !== undefined) { + const terminalResult = yield* awaitProviderTerminalForStep( + stepRunId, + outcome.providerThreadId, + step, + ); + if (terminalResult._tag === "failed") { + yield* commit({ + type: "StepFailed", + ticketId, + payload: stepFailedPayload(stepRunId, terminalResult.error, terminalResult.usage), + }); + return { result: "failed", noRetry: false }; + } + if (terminalResult._tag === "blocked") { + yield* commit({ + type: "StepBlocked", + ticketId, + payload: { stepRunId, reason: terminalResult.reason }, + }); + return { result: "blocked", noRetry: false }; + } + yield* commit({ + type: "StepCompleted", + ticketId, + payload: stepCompletedPayload(stepRunId, terminalResult.output, terminalResult.usage), + }); + return { result: "completed", noRetry: false }; + } + yield* commit({ + type: "StepCompleted", + ticketId, + payload: stepCompletedPayload(stepRunId), + }); + return { result: "completed", noRetry: false }; + } + if (outcome._tag === "failed") { + yield* commit({ + type: "StepFailed", + ticketId, + payload: stepFailedPayload( + stepRunId, + outcome.error, + outcome.usage, + outcome.retryable === false ? false : undefined, + ), + }); + return { result: "failed", noRetry: outcome.retryable === false }; + } + if (outcome._tag === "blocked") { + yield* commit({ + type: "StepBlocked", + ticketId, + payload: { stepRunId, reason: outcome.reason }, + }); + return { result: "blocked", noRetry: false }; + } + + yield* commit({ + type: "StepCompleted", + ticketId, + payload: stepCompletedPayload(stepRunId, outcome.output, outcome.usage), + }); + return { result: "completed", noRetry: false }; + }); + + const runPipeline = ( + ticketId: TicketId, + boardId: BoardId, + lane: WorkflowLane, + laneEntryToken: LaneEntryToken, + ): Effect.Effect => + Effect.gen(function* () { + const definition = yield* registry.getDefinition(boardId); + const permits = Math.max(1, definition?.settings?.maxConcurrentTickets ?? 3); + const semaphore = yield* semaphoreFor(boardId, permits); + yield* semaphore.withPermits(1)(runPipelineBody(ticketId, boardId, lane, laneEntryToken)); + }).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const reason = `pipeline error: ${Cause.pretty(cause)}`; + return Effect.logWarning("workflow pipeline orchestration failed", { + boardId, + laneEntryToken, + laneKey: lane.key, + reason, + ticketId, + }).pipe( + Effect.flatMap(() => + commit({ + type: "TicketBlocked", + ticketId, + payload: { reason }, + }), + ), + Effect.catch(() => Effect.void), + ); + }), + ); + + const completePipelineFrom = ( + ticketId: TicketId, + boardId: BoardId, + lane: WorkflowLane, + laneEntryToken: LaneEntryToken, + pipelineRunId: PipelineRunId, + steps: ReadonlyArray, + startIndex: number, + initialResult: PipelineResult, + initialRouteDecision?: RouteDecision, + ): Effect.Effect => + Effect.gen(function* () { + let result: PipelineResult = initialResult; + let routeDecision: RouteDecision | null = initialRouteDecision ?? null; + + if (routeDecision === null) { + for (const step of steps.slice(startIndex)) { + if (result !== "success") { + break; + } + const maxAttempts = retryAttemptsForStep(step); + let attempt = 1; + let stepOutcome = yield* runStep( + ticketId, + boardId, + pipelineRunId, + step, + laneEntryToken, + attempt, + ); + while (stepOutcome.result === "failed" && !stepOutcome.noRetry && attempt < maxAttempts) { + attempt += 1; + stepOutcome = yield* runStep( + ticketId, + boardId, + pipelineRunId, + stepForAttempt(step, attempt), + laneEntryToken, + attempt, + ); + } + result = pipelineResultForStep(stepOutcome.result); + routeDecision = stepRouteDecision(step, result); + if (routeDecision !== null || result !== "success") { + break; + } + } + } + + const contextSnapshot = yield* routingContextBuilder.build({ + ticketId, + pipelineRunId, + result, + }); + if (routeDecision === null) { + routeDecision = + (yield* laneTransitionDecision(lane, contextSnapshot)) ?? laneOnDecision(lane, result); + } + + yield* commit({ + type: "PipelineCompleted", + ticketId, + payload: { pipelineRunId, result }, + }); + + if (routeDecision !== null) { + yield* enterLane(ticketId, boardId, routeDecision.toLane, "routed", { + routeDecision, + contextSnapshot, + expectedToken: laneEntryToken, + pipelineRunId, + fromLane: lane, + }); + return; + } + + if (result !== "success") { + yield* Effect.uninterruptible( + Effect.gen(function* () { + const token = yield* currentToken(ticketId); + if (token !== laneEntryToken) { + return; + } + yield* commit({ + type: "TicketBlocked", + ticketId, + payload: { reason: `pipeline ${result} with no route` }, + }); + }), + ); + } + }); + + const runPipelineBody = ( + ticketId: TicketId, + boardId: BoardId, + lane: WorkflowLane, + laneEntryToken: LaneEntryToken, + ): Effect.Effect => + Effect.gen(function* () { + const steps = lane.pipeline ?? []; + if (steps.length === 0) { + return; + } + + const pipelineRunId = yield* ids.pipelineRunId(); + yield* commit({ + type: "PipelineStarted", + ticketId, + payload: { pipelineRunId, laneKey: lane.key, laneEntryToken }, + }); + + yield* completePipelineFrom( + ticketId, + boardId, + lane, + laneEntryToken, + pipelineRunId, + steps, + 0, + "success", + ); + }); + + const startPipeline = ( + ticketId: TicketId, + boardId: BoardId, + lane: WorkflowLane, + laneEntryToken: LaneEntryToken, + ) => + Effect.gen(function* () { + const fiber = yield* SynchronizedRef.modifyEffect(runningPipelines, (current) => { + const key = ticketId as string; + const active = current.get(key); + if (active?.laneEntryToken === laneEntryToken) { + return Effect.succeed([null, current] as const); + } + + return runPipeline(ticketId, boardId, lane, laneEntryToken).pipe( + Effect.ensuring(clearRunningPipeline(ticketId, laneEntryToken)), + Effect.forkDetach({ startImmediately: false, uninterruptible: false }), + Effect.map((fiber) => { + const next = new Map(current); + next.set(key, { fiber, laneEntryToken }); + return [fiber, next] as const; + }), + ); + }); + if (fiber !== null) { + yield* Effect.yieldNow; + } + }); + + const runPipelineStarts = (starts: ReadonlyArray) => + Effect.forEach( + starts, + (start) => startPipeline(start.ticketId, start.boardId, start.lane, start.laneEntryToken), + { discard: true }, + ); + + const collectStartAction = ( + starts: Array, + ticketId: TicketId, + boardId: BoardId, + lane: WorkflowLane | null, + laneEntryToken: LaneEntryToken, + ) => { + if (lane?.entry === "auto") { + starts.push({ ticketId, boardId, lane, laneEntryToken }); + } + }; + + const admitNextLocked = ( + boardId: BoardId, + laneKey: LaneKey, + ): Effect.Effect, WorkflowEventStoreError> => + Effect.gen(function* () { + const lane = yield* registry.getLane(boardId, laneKey); + const limit = lane?.wipLimit; + if (lane === null || limit === undefined) { + return []; + } + + const starts: Array = []; + while ((yield* read.countAdmittedInLane(boardId, laneKey)) < limit) { + const queued = yield* read.oldestQueuedForLane(boardId, laneKey); + if (queued === null) { + break; + } + + const laneEntryToken = yield* ids.token(); + const queuedTicketId = queued.ticketId as TicketId; + yield* commit({ + type: "TicketAdmitted", + ticketId: queuedTicketId, + payload: { lane: laneKey, laneEntryToken }, + }); + collectStartAction(starts, queuedTicketId, boardId, lane, laneEntryToken); + } + + return starts; + }); + + const enterLane = ( + ticketId: TicketId, + boardId: BoardId, + toLane: LaneKey, + reason: MoveReason, + routedOptions?: RoutedEnterLaneOptions, + externalOptions?: ExternalEnterLaneOptions, + ): Effect.Effect<"moved" | "queued" | "none", WorkflowEventStoreError> => + Effect.gen(function* () { + // A manual move supersedes whatever the ticket was doing: stop live + // provider turns so a stale agent cannot keep mutating the worktree + // underneath the next lane's steps (e.g. a merge), and tombstone the + // outbox rows so restart recovery never re-dispatches the stale work. + // External events do the same, but only inside the admission lock once + // the stale-lane guard has confirmed the event still applies. + const supersedeRunningWork = Effect.gen(function* () { + yield* interruptRunningPipeline(ticketId); + yield* cancelActiveProviderTurnsForTicket(ticketId).pipe(Effect.catch(() => Effect.void)); + yield* abandonTicketDispatches(ticketId).pipe(Effect.catch(() => Effect.void)); + }); + if (reason === "manual") { + yield* supersedeRunningWork; + } + + const lockResult = yield* withAdmissionLock( + boardId, + Effect.uninterruptible( + Effect.gen(function* () { + const none = { + starts: [] as Array, + acted: "none" as "moved" | "queued" | "none", + }; + const detail = yield* read.getTicketDetail(ticketId); + const priorLane = detail?.ticket.currentLaneKey as LaneKey | undefined; + const priorWasAdmitted = + detail !== null && detail.ticket.currentLaneEntryToken !== null; + if (reason === "routed") { + if ( + routedOptions === undefined || + detail?.ticket.currentLaneEntryToken !== routedOptions.expectedToken + ) { + return none; + } + } + if (reason === "external") { + if ( + externalOptions === undefined || + detail?.ticket.currentLaneKey !== (externalOptions.expectedFromLane as string) + ) { + return none; + } + // A board save may have removed the matcher or its target lane + // between evaluation and this commit — re-resolve before acting. + if (!(yield* externalOptions.revalidate)) { + return none; + } + // Only a confirmed-fresh event may kill the ticket's running + // work; stale events must no-op without side effects. + yield* supersedeRunningWork; + } + const routeEvent = + reason === "routed" && routedOptions !== undefined + ? routeDecisionEvent( + ticketId, + routedOptions.pipelineRunId, + routedOptions.fromLane, + routedOptions.routeDecision, + routedOptions.contextSnapshot, + ) + : reason === "external" && externalOptions !== undefined + ? externalOptions.routeEvent + : null; + const targetLane = yield* registry.getLane(boardId, toLane); + const limit = targetLane?.wipLimit; + const admittedCount = + limit === undefined ? 0 : yield* read.countAdmittedInLane(boardId, toLane); + const selfInTarget = priorWasAdmitted && priorLane === toLane ? 1 : 0; + const starts: Array = []; + + // A ticket waiting on dependencies never starts an auto lane's + // pipeline — queue it; resolution of the last dependency + // releases it through the admission sweep. + const unresolvedDeps = detail?.ticket.unresolvedDependencyCount ?? 0; + const dependencyGated = targetLane?.entry === "auto" && unresolvedDeps > 0; + + let acted: "moved" | "queued" = "moved"; + if ((limit !== undefined && admittedCount - selfInTarget >= limit) || dependencyGated) { + acted = "queued"; + const queueEvent = { + type: "TicketQueued", + ticketId, + payload: { lane: toLane }, + } as UnstampedWorkflowEventInput; + if (routeEvent === null) { + yield* commit(queueEvent); + } else { + yield* commitMany([routeEvent, queueEvent]); + } + } else { + const laneEntryToken = yield* ids.token(); + const moveEvent = { + type: "TicketMovedToLane", + ticketId, + payload: { toLane, laneEntryToken, reason }, + } as UnstampedWorkflowEventInput; + if (routeEvent === null) { + yield* commit(moveEvent); + } else { + yield* commitMany([routeEvent, moveEvent]); + } + collectStartAction(starts, ticketId, boardId, targetLane, laneEntryToken); + } + + if (priorWasAdmitted && priorLane !== undefined && priorLane !== toLane) { + starts.push(...(yield* admitNextLocked(boardId, priorLane))); + } + + return { starts, acted }; + }), + ), + ); + + yield* runPipelineStarts(lockResult.starts); + + const movedLane = yield* registry.getLane(boardId, toLane); + if (movedLane?.terminal === true) { + // Resolution releases queued dependents; failure here must never undo + // the move itself. + yield* releaseDependents(ticketId).pipe(Effect.catch(() => Effect.void)); + } + + return lockResult.acted; + }); + + const moveToLane = ( + ticketId: TicketId, + boardId: BoardId, + toLane: LaneKey, + reason: MoveReason, + ): Effect.Effect => + enterLane(ticketId, boardId, toLane, reason).pipe(Effect.asVoid); + + // Budgets are advisory caps — clamp junk client input instead of failing. + const normalizeTokenBudget = (value: number | null | undefined): number | null | undefined => { + if (value === undefined || value === null) { + return value; + } + if (!Number.isFinite(value) || value <= 0) { + return null; + } + return Math.floor(value); + }; + + const validateDependsOn = ( + boardId: BoardId, + ticketId: TicketId | null, + dependsOn: ReadonlyArray, + ): Effect.Effect, WorkflowEventStoreError> => + Effect.gen(function* () { + const unique = [...new Set(dependsOn)]; + if (ticketId !== null && unique.some((dep) => dep === ticketId)) { + return yield* new WorkflowEventStoreError({ + message: "a ticket cannot depend on itself", + }); + } + for (const dep of unique) { + const depDetail = yield* read.getTicketDetail(dep); + if (depDetail === null) { + return yield* new WorkflowEventStoreError({ + message: `dependency ticket ${dep} was not found`, + }); + } + if (depDetail.ticket.boardId !== (boardId as string)) { + return yield* new WorkflowEventStoreError({ + message: "dependencies must be tickets on the same board", + }); + } + } + if (ticketId !== null) { + // Walk the existing edges from each new dependency; reaching the + // ticket itself would close a cycle and deadlock both tickets. The + // budget exists only to bound pathological graphs — exhausting it + // with work remaining fails closed rather than letting a deep cycle + // slip through. + const seen = new Set(); + const stack: string[] = [...unique]; + while (stack.length > 0) { + if (seen.size > 500) { + return yield* new WorkflowEventStoreError({ + message: "dependency graph is too deep to validate", + }); + } + const current = stack.pop(); + if (current === undefined) { + break; + } + if (current === (ticketId as string)) { + return yield* new WorkflowEventStoreError({ + message: "circular ticket dependencies are not allowed", + }); + } + if (seen.has(current)) { + continue; + } + seen.add(current); + const currentDetail = yield* read.getTicketDetail(current as TicketId); + stack.push(...(currentDetail?.ticket.dependsOn ?? [])); + } + } + return unique; + }); + + const releaseDependents = ( + resolvedTicketId: TicketId, + ): Effect.Effect => + Effect.gen(function* () { + const dependents = yield* read.listReleasableDependents(resolvedTicketId); + for (const dependent of dependents) { + yield* releaseTicketIfEligible(dependent.ticketId as TicketId); + } + }); + + // Admit a queued ticket whose dependencies are all resolved. Used when a + // dependency edit removes the last blocker and by restart recovery — + // unlimited lanes are never swept by admitNextLocked, so they need a + // direct admit. + const releaseTicketIfEligible = ( + ticketId: TicketId, + ): Effect.Effect => + Effect.gen(function* () { + const detail = yield* read.getTicketDetail(ticketId); + if ( + detail === null || + detail.ticket.queuedAt === null || + (detail.ticket.unresolvedDependencyCount ?? 0) > 0 + ) { + return; + } + const boardId = detail.ticket.boardId as BoardId; + const laneKey = detail.ticket.currentLaneKey as LaneKey; + const lane = yield* registry.getLane(boardId, laneKey); + if (lane === null) { + return; + } + const starts = yield* withAdmissionLock( + boardId, + Effect.uninterruptible( + Effect.gen(function* () { + if (lane.wipLimit !== undefined) { + return yield* admitNextLocked(boardId, laneKey); + } + const lockedDetail = yield* read.getTicketDetail(ticketId); + if ( + lockedDetail === null || + lockedDetail.ticket.queuedAt === null || + (lockedDetail.ticket.unresolvedDependencyCount ?? 0) > 0 + ) { + return []; + } + const laneEntryToken = yield* ids.token(); + yield* commit({ + type: "TicketAdmitted", + ticketId, + payload: { lane: laneKey, laneEntryToken }, + }); + const released: Array = []; + collectStartAction(released, ticketId, boardId, lane, laneEntryToken); + return released; + }), + ), + ); + yield* runPipelineStarts(starts); + }); + + const createTicket: WorkflowEngineShape["createTicket"] = (input) => + Effect.gen(function* () { + const dependsOn = + input.dependsOn === undefined || input.dependsOn.length === 0 + ? [] + : yield* validateDependsOn(input.boardId, null, input.dependsOn); + const ticketId = yield* ids.ticketId(); + const tokenBudget = normalizeTokenBudget(input.tokenBudget); + yield* commit({ + type: "TicketCreated", + ticketId, + payload: { + boardId: input.boardId, + title: input.title, + laneKey: input.initialLane, + description: input.description, + ...(tokenBudget === undefined || tokenBudget === null ? {} : { tokenBudget }), + }, + } as UnstampedWorkflowEventInput); + if (dependsOn.length > 0) { + yield* commit({ + type: "TicketDependenciesSet", + ticketId, + payload: { dependsOn }, + }); + } + yield* moveToLane(ticketId, input.boardId, input.initialLane, "initial"); + return ticketId; + }); + + const editTicket: WorkflowEngineShape["editTicket"] = (input) => + Effect.gen(function* () { + const title = input.title === undefined ? undefined : input.title.trim(); + if (title !== undefined && title.length === 0) { + return yield* new WorkflowEventStoreError({ message: "ticket title cannot be empty" }); + } + if (input.dependsOn !== undefined) { + const detail = yield* read.getTicketDetail(input.ticketId); + if (detail === null) { + return yield* new WorkflowEventStoreError({ message: "ticket not found" }); + } + const boardId = detail.ticket.boardId as BoardId; + // Validate and commit under the board's admission lock so two + // concurrent edits cannot both validate against the old graph and + // commit edges that only together form a cycle. + yield* withAdmissionLock( + boardId, + Effect.gen(function* () { + const dependsOn = yield* validateDependsOn( + boardId, + input.ticketId, + input.dependsOn ?? [], + ); + yield* commit({ + type: "TicketDependenciesSet", + ticketId: input.ticketId, + payload: { dependsOn }, + }); + }), + ); + // Removing the last blocker must release the ticket right away — + // there is no terminal move to trigger it otherwise. + yield* releaseTicketIfEligible(input.ticketId).pipe(Effect.catch(() => Effect.void)); + } + const tokenBudget = normalizeTokenBudget(input.tokenBudget); + if (title === undefined && input.description === undefined && tokenBudget === undefined) { + return; + } + yield* commit({ + type: "TicketEdited", + ticketId: input.ticketId, + payload: { + ...(title === undefined ? {} : { title: title as never }), + ...(input.description === undefined ? {} : { description: input.description }), + ...(tokenBudget === undefined ? {} : { tokenBudget }), + }, + }); + }); + + const validateTicketMessageInput = (input: { + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray | undefined; + }): Effect.Effect< + { readonly text: string; readonly attachments: ReadonlyArray }, + WorkflowEventStoreError + > => + Effect.gen(function* () { + const text = input.text?.trim() ?? ""; + const attachments: ReadonlyArray = input.attachments ?? []; + if (text.length === 0 && attachments.length === 0) { + return yield* new WorkflowEventStoreError({ + message: "ticket message requires text or an attachment", + }); + } + if (text.length > MAX_TICKET_ANSWER_BODY_LENGTH) { + return yield* new WorkflowEventStoreError({ + message: `ticket message body exceeds ${MAX_TICKET_ANSWER_BODY_LENGTH} characters`, + }); + } + if (attachments.length > MAX_TICKET_ANSWER_ATTACHMENT_COUNT) { + return yield* new WorkflowEventStoreError({ + message: `ticket message supports at most ${MAX_TICKET_ANSWER_ATTACHMENT_COUNT} attachments`, + }); + } + if (attachments.some((attachment) => attachment.kind !== "image")) { + return yield* new WorkflowEventStoreError({ + message: "ticket message attachments must be images", + }); + } + if ( + attachments.some( + (attachment) => + attachment.kind === "image" && + (!SAFE_TICKET_IMAGE_MIME_TYPES.has(attachment.mimeType) || + !SAFE_TICKET_IMAGE_DATA_URL.test(attachment.dataUrl)), + ) + ) { + return yield* new WorkflowEventStoreError({ + message: "ticket message image attachments must use png, jpeg, gif, or webp data URLs", + }); + } + if (ticketAnswerAttachmentBytes(attachments) > MAX_TICKET_ANSWER_ATTACHMENT_BYTES) { + return yield* new WorkflowEventStoreError({ + message: "ticket message attachments exceed the 10 MiB encoded limit", + }); + } + return { text, attachments }; + }); + + const postTicketMessage: WorkflowEngineShape["postTicketMessage"] = (input) => + Effect.gen(function* () { + const { text, attachments } = yield* validateTicketMessageInput(input); + const detail = yield* read.getTicketDetail(input.ticketId); + if (!detail) { + return yield* new WorkflowEventStoreError({ message: "ticket not found" }); + } + const messageId = yield* ids.messageId(); + yield* commit({ + type: "TicketMessagePosted", + ticketId: input.ticketId, + payload: { + messageId, + author: "user", + body: text, + attachments, + createdAt: (yield* nowIso) as never, + }, + }); + }); + + const answerTicketStep: WorkflowEngineShape["answerTicketStep"] = (input) => + Effect.gen(function* () { + const text = input.text?.trim() ?? ""; + const attachments: ReadonlyArray = input.attachments ?? []; + if (text.length === 0 && attachments.length === 0) { + return yield* new WorkflowEventStoreError({ + message: "ticket answer requires text or an attachment", + }); + } + if (text.length > MAX_TICKET_ANSWER_BODY_LENGTH) { + return yield* new WorkflowEventStoreError({ + message: `ticket answer body exceeds ${MAX_TICKET_ANSWER_BODY_LENGTH} characters`, + }); + } + if (attachments.length > MAX_TICKET_ANSWER_ATTACHMENT_COUNT) { + return yield* new WorkflowEventStoreError({ + message: `ticket answer supports at most ${MAX_TICKET_ANSWER_ATTACHMENT_COUNT} attachments`, + }); + } + if (attachments.some((attachment) => attachment.kind !== "image")) { + return yield* new WorkflowEventStoreError({ + message: "ticket answer attachments must be images", + }); + } + if ( + attachments.some( + (attachment) => + attachment.kind === "image" && + (!SAFE_TICKET_IMAGE_MIME_TYPES.has(attachment.mimeType) || + !SAFE_TICKET_IMAGE_DATA_URL.test(attachment.dataUrl)), + ) + ) { + return yield* new WorkflowEventStoreError({ + message: "ticket answer image attachments must use png, jpeg, gif, or webp data URLs", + }); + } + if (ticketAnswerAttachmentBytes(attachments) > MAX_TICKET_ANSWER_ATTACHMENT_BYTES) { + return yield* new WorkflowEventStoreError({ + message: "ticket answer attachments exceed the 10 MiB encoded limit", + }); + } + + const ticketId = yield* ticketIdForStepRun(input.stepRunId); + if (ticketId === null) { + return; + } + const awaitingState = yield* awaitingStateForStepRun(input.stepRunId); + const pending = yield* pendingWaitFor(input.stepRunId); + const responseKind = + awaitingState === null + ? pending?.payload.providerResponseKind + : awaitingState.status === "awaiting_user" + ? awaitingState.providerResponseKind + : null; + if (responseKind !== "user-input") { + return yield* new WorkflowEventStoreError({ + message: "ticket answer requires an awaiting user-input step", + }); + } + yield* ensureLiveProviderUserInputWait(pending); + + const messageId = yield* ids.messageId(); + yield* commit({ + type: "TicketMessagePosted", + ticketId, + payload: { + messageId, + stepRunId: input.stepRunId, + author: "user", + body: text, + attachments, + createdAt: (yield* nowIso) as never, + }, + }); + + if (text.length === 0) { + return; + } + + const { providerResponses } = yield* getOptionalServices; + if ( + pending?.payload.providerThreadId && + pending.payload.providerRequestId && + pending.payload.providerResponseKind === "user-input" && + Option.isSome(providerResponses) + ) { + yield* providerResponses.value.respond({ + threadId: pending.payload.providerThreadId, + requestId: pending.payload.providerRequestId, + responseKind: pending.payload.providerResponseKind, + approved: true, + ...(pending.payload.providerQuestionId === undefined + ? {} + : { questionId: pending.payload.providerQuestionId }), + text, + }); + } + + if (pending?.payload.providerResponseKind !== "user-input") { + return; + } + const resumedLiveWaiter = yield* approvals.resolve(input.stepRunId, true); + if (!resumedLiveWaiter) { + yield* continueRecoveredApproval(pending, true); + } + }); + + const moveTicket: WorkflowEngineShape["moveTicket"] = (ticketId, toLane) => + Effect.gen(function* () { + const detail = yield* read.getTicketDetail(ticketId); + if (!detail) { + return; + } + const currentDetail = yield* read.getTicketDetail(ticketId); + if (!currentDetail) { + return; + } + yield* moveToLane(ticketId, currentDetail.ticket.boardId as BoardId, toLane, "manual"); + }); + + const hasPipelineStartedForToken = (ticketId: TicketId, laneEntryToken: LaneEntryToken) => + wrapSql(sql` + SELECT pipeline_run_id AS "pipelineRunId" + FROM projection_pipeline_run + WHERE ticket_id = ${ticketId} + AND lane_entry_token = ${laneEntryToken} + LIMIT 1 + `).pipe(Effect.map((rows) => rows.length > 0)); + + const cancellableProviderDispatchesForBoard = (boardId: BoardId) => + wrapSql(sql` + SELECT DISTINCT + outbox.thread_id AS "threadId", + outbox.turn_id AS "turnId" + FROM workflow_dispatch_outbox AS outbox + INNER JOIN projection_ticket AS ticket + ON ticket.ticket_id = outbox.ticket_id + WHERE ticket.board_id = ${boardId} + AND outbox.status IN ('pending', 'started') + ORDER BY outbox.thread_id ASC, outbox.turn_id ASC + `); + + const cancellableProviderDispatchesForTicket = (ticketId: TicketId) => + wrapSql(sql` + SELECT DISTINCT + thread_id AS "threadId", + turn_id AS "turnId" + FROM workflow_dispatch_outbox + WHERE ticket_id = ${ticketId} + AND status IN ('pending', 'started') + ORDER BY thread_id ASC, turn_id ASC + `); + + const cancelProviderTurns = (turns: ReadonlyArray) => + Effect.gen(function* () { + const { providerService } = yield* getOptionalServices; + if (Option.isNone(providerService)) { + return; + } + yield* Effect.forEach( + turns, + (turn) => + Effect.gen(function* () { + const interruptError = + turn.turnId === null + ? null + : yield* providerCleanupAttempt( + providerService.value.interruptTurn({ + threadId: turn.threadId, + turnId: turn.turnId, + }), + "workflow provider turn interrupt failed", + ); + + const stopError = yield* providerCleanupAttempt( + providerService.value.stopSession({ threadId: turn.threadId }), + "workflow provider session stop failed", + ); + + const cleanupError = interruptError ?? stopError; + if (cleanupError !== null) { + return yield* cleanupError; + } + }), + { discard: true }, + ); + }); + + const abandonTicketDispatches = (ticketId: TicketId) => + Effect.gen(function* () { + const confirmedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'confirmed', + confirmed_at = ${confirmedAt} + WHERE ticket_id = ${ticketId} + AND status IN ('pending', 'started') + `); + }); + + const cancelActiveProviderTurns = (boardId: BoardId) => + Effect.gen(function* () { + const turns = yield* cancellableProviderDispatchesForBoard(boardId); + yield* cancelProviderTurns(turns); + }); + + const cancelActiveProviderTurnsForTicket = (ticketId: TicketId) => + Effect.gen(function* () { + const turns = yield* cancellableProviderDispatchesForTicket(ticketId); + yield* cancelProviderTurns(turns); + }); + + const recoverBoardWip: WorkflowEngineShape["recoverBoardWip"] = (boardId) => + Effect.gen(function* () { + const definition = yield* registry.getDefinition(boardId); + if (definition === null) { + return; + } + + for (const lane of definition.lanes) { + yield* withAdmissionLock( + boardId, + Effect.uninterruptible(admitNextLocked(boardId, lane.key)), + ); + } + + const tickets = yield* read.listTickets(boardId); + // admitNextLocked only sweeps WIP-limited lanes; a crash between a + // dependency landing and its dependents being released would otherwise + // strand queued tickets in unlimited auto lanes forever. + for (const ticket of tickets) { + if (ticket.queuedAt === null || (ticket.unresolvedDependencyCount ?? 0) > 0) { + continue; + } + const lane = yield* registry.getLane(boardId, ticket.currentLaneKey as LaneKey); + if (lane?.entry !== "auto" || lane.wipLimit !== undefined) { + continue; + } + yield* releaseTicketIfEligible(ticket.ticketId as TicketId).pipe( + Effect.catch(() => Effect.void), + ); + } + for (const ticket of tickets) { + if (ticket.currentLaneEntryToken === null) { + continue; + } + const lane = yield* registry.getLane(boardId, ticket.currentLaneKey as LaneKey); + if (lane?.entry !== "auto") { + continue; + } + const laneEntryToken = ticket.currentLaneEntryToken as LaneEntryToken; + const hasStarted = yield* hasPipelineStartedForToken( + ticket.ticketId as TicketId, + laneEntryToken, + ); + if (!hasStarted) { + yield* startPipeline(ticket.ticketId as TicketId, boardId, lane, laneEntryToken); + } + } + }); + + const ingestExternalEvent: WorkflowEngineShape["ingestExternalEvent"] = (input) => + Effect.gen(function* () { + const detail = yield* read.getTicketDetail(input.ticketId); + if (detail === null || detail.ticket.boardId !== (input.boardId as string)) { + return yield* new WorkflowEventStoreError({ + message: "ticket not found on this board", + }); + } + const fromLaneKey = detail.ticket.currentLaneKey as LaneKey; + const eventContext = { event: { name: input.name, payload: input.payload ?? null } }; + const resolveTarget = Effect.gen(function* () { + const lane = yield* registry.getLane(input.boardId, fromLaneKey); + for (const matcher of lane?.onEvent ?? []) { + if ((matcher.name as string) !== input.name) { + continue; + } + if (matcher.when !== undefined) { + const evaluation = yield* predicates.evaluate(matcher.when, eventContext).pipe( + Effect.mapError( + (cause) => + new WorkflowEventStoreError({ + message: "external event predicate evaluation failed", + cause, + }), + ), + ); + if (!evaluation.result) { + continue; + } + } + return matcher.to; + } + return null; + }); + const target = yield* resolveTarget; + if (target === null) { + return { outcome: "noop" as const }; + } + + const routeEvent = { + type: "TicketRouteDecided", + ticketId: input.ticketId, + payload: { + fromLane: fromLaneKey, + toLane: target, + source: "external_event", + contextSnapshot: eventContext, + }, + } as UnstampedWorkflowEventInput; + const acted = yield* enterLane(input.ticketId, input.boardId, target, "external", undefined, { + expectedFromLane: fromLaneKey, + routeEvent, + revalidate: Effect.gen(function* () { + if ((yield* resolveTarget) !== target) { + return false; + } + return (yield* registry.getLane(input.boardId, target)) !== null; + }), + }); + if (acted === "none") { + return { outcome: "noop" as const }; + } + return { outcome: acted, toLane: target as string }; + }); + + const runLane: WorkflowEngineShape["runLane"] = (ticketId) => + Effect.gen(function* () { + const detail = yield* read.getTicketDetail(ticketId); + if (!detail) { + return; + } + + const currentDetail = yield* read.getTicketDetail(ticketId); + if (!currentDetail) { + return; + } + + const unresolvedDeps = currentDetail.ticket.unresolvedDependencyCount ?? 0; + if (unresolvedDeps > 0) { + return yield* new WorkflowEventStoreError({ + message: `ticket is waiting on ${unresolvedDeps} unresolved dependenc${ + unresolvedDeps === 1 ? "y" : "ies" + }`, + }); + } + const lane = yield* registry.getLane( + currentDetail.ticket.boardId as BoardId, + currentDetail.ticket.currentLaneKey as LaneKey, + ); + const token = yield* currentToken(ticketId); + if (lane && token) { + yield* startPipeline( + ticketId, + currentDetail.ticket.boardId as BoardId, + lane, + token as LaneEntryToken, + ); + } + }); + + const recoveredStepContext = ( + events: ReadonlyArray, + stepRunId: StepRunId, + ) => { + let stepStarted: StepStarted | null = null; + let pipelineStarted: PipelineStarted | null = null; + let ticketCreated: TicketCreated | null = null; + + for (const event of events) { + if (event.type === "StepStarted" && event.payload.stepRunId === stepRunId) { + stepStarted = event; + } + } + if (!stepStarted) { + return null; + } + + for (const event of events) { + if (event.type === "TicketCreated" && event.ticketId === stepStarted.ticketId) { + ticketCreated = event; + } + if ( + event.type === "PipelineStarted" && + event.payload.pipelineRunId === stepStarted.payload.pipelineRunId + ) { + pipelineStarted = event; + } + } + if (!pipelineStarted || !ticketCreated) { + return null; + } + + return { stepStarted, pipelineStarted, ticketCreated }; + }; + + const completeRecoveredStepUnlocked = ( + stepRunId: StepRunId, + result: RecoveredStepResult, + captureTurn: { readonly threadId: ThreadId; readonly turnId: TurnId } | undefined, + options?: { readonly allowRetry?: boolean }, + ): Effect.Effect => + Effect.gen(function* () { + const events = yield* readStoredEventsForStep(stepRunId); + if (events === null) { + return; + } + + const recovered = recoveredStepContext(events, stepRunId); + if ( + !recovered || + hasPipelineCompletedEvent(events, recovered.pipelineStarted.payload.pipelineRunId) + ) { + return; + } + + const boardId = recovered.ticketCreated.payload.boardId; + const laneEntryToken = recovered.pipelineStarted.payload.laneEntryToken; + const pipelineRunId = recovered.pipelineStarted.payload.pipelineRunId; + // The board definition may have changed across the restart: a missing + // lane or step must still resolve the pipeline run, or it pins the + // ticket's WIP slot forever. + const supersedePipeline = commitMany([ + { + type: "PipelineCompleted", + ticketId: recovered.stepStarted.ticketId, + payload: { pipelineRunId, result: "superseded" }, + }, + // Surface the dead end instead of leaving the ticket "running"; the + // user re-routes it manually once the board matches reality again. + { + type: "TicketBlocked", + ticketId: recovered.stepStarted.ticketId, + payload: { reason: "board definition changed while this step was recovering" }, + }, + ] as ReadonlyArray); + const lane = yield* registry.getLane(boardId, recovered.pipelineStarted.payload.laneKey); + if (!lane) { + yield* supersedePipeline; + return; + } + + const steps = lane.pipeline ?? []; + const currentStepIndex = steps.findIndex( + (step) => step.key === recovered.stepStarted.payload.stepKey, + ); + if (currentStepIndex < 0) { + yield* supersedePipeline; + return; + } + + const recoveredStep = steps[currentStepIndex]; + let terminalResult = + result._tag === "completed" + ? yield* completedResultForStep(stepRunId, recoveredStep, result.output, captureTurn) + : result; + if ( + terminalResult._tag !== "blocked" && + terminalResult.usage === undefined && + captureTurn !== undefined + ) { + const usage = yield* readStepUsage(captureTurn.threadId); + if (usage !== undefined) { + terminalResult = { ...terminalResult, usage }; + } + } + + if (!hasTerminalStepEvent(events, stepRunId)) { + if (terminalResult._tag === "completed") { + yield* commit({ + type: "StepCompleted", + ticketId: recovered.stepStarted.ticketId, + payload: stepCompletedPayload(stepRunId, terminalResult.output, terminalResult.usage), + }); + } else if (terminalResult._tag === "failed") { + yield* commit({ + type: "StepFailed", + ticketId: recovered.stepStarted.ticketId, + payload: stepFailedPayload( + stepRunId, + terminalResult.error, + terminalResult.usage, + terminalResult.retryable === false ? false : undefined, + ), + }); + } else { + yield* commit({ + type: "StepBlocked", + ticketId: recovered.stepStarted.ticketId, + payload: { stepRunId, reason: terminalResult.reason }, + }); + } + } + + // Never continue a pipeline the ticket has already left: a manual move + // or re-route invalidated this lane entry token, so running more steps + // or routing from here would act on stale state. The terminal step + // event above is still recorded; the pipeline run closes superseded. + if ((yield* currentToken(recovered.stepStarted.ticketId)) !== laneEntryToken) { + yield* commit({ + type: "PipelineCompleted", + ticketId: recovered.stepStarted.ticketId, + payload: { pipelineRunId, result: "superseded" }, + }); + return; + } + + let finalResult: StepResult = + terminalResult._tag === "completed" + ? "completed" + : terminalResult._tag === "blocked" + ? "blocked" + : "failed"; + + // Resume the retry loop across restarts: a failed attempt recovered + // mid-policy keeps consuming its remaining attempts (with escalation), + // unless the failure was a user rejection/cancellation. + if ( + finalResult === "failed" && + (terminalResult._tag !== "failed" || terminalResult.retryable !== false) && + recoveredStep !== undefined && + options?.allowRetry !== false + ) { + const maxAttempts = retryAttemptsForStep(recoveredStep); + let attempt = recovered.stepStarted.payload.attempt ?? 1; + let outcome: StepRunOutcome = { result: "failed", noRetry: false }; + while (outcome.result === "failed" && !outcome.noRetry && attempt < maxAttempts) { + attempt += 1; + outcome = yield* runStep( + recovered.stepStarted.ticketId, + boardId, + recovered.pipelineStarted.payload.pipelineRunId, + stepForAttempt(recoveredStep, attempt), + laneEntryToken, + attempt, + ); + } + if (attempt > (recovered.stepStarted.payload.attempt ?? 1)) { + finalResult = outcome.result; + } + } + + const recoveredResult: PipelineResult = pipelineResultForStep(finalResult); + const initialRouteDecision = recoveredStep + ? stepRouteDecision(recoveredStep, recoveredResult) + : null; + + yield* completePipelineFrom( + recovered.stepStarted.ticketId, + boardId, + lane, + laneEntryToken, + recovered.pipelineStarted.payload.pipelineRunId, + steps, + initialRouteDecision === null && finalResult === "completed" + ? currentStepIndex + 1 + : steps.length, + recoveredResult, + initialRouteDecision ?? undefined, + ); + }); + + const completeRecoveredStep: WorkflowEngineShape["completeRecoveredStep"] = ( + stepRunId, + result, + captureTurn, + ) => + Effect.gen(function* () { + const claimed = yield* SynchronizedRef.modify(recoveredStepClaims, (current) => { + const key = stepRunId as string; + if (current.has(key)) { + return [false, current] as const; + } + const next = new Set(current); + next.add(key); + return [true, next] as const; + }); + if (!claimed) { + return; + } + yield* completeRecoveredStepUnlocked(stepRunId, result, captureTurn).pipe( + // Release the claim on failure so a later monitor/sweep can finish + // what this continuation could not. + Effect.onError(() => + SynchronizedRef.update(recoveredStepClaims, (current) => { + const next = new Set(current); + next.delete(stepRunId as string); + return next; + }), + ), + ); + }); + + const continueRecoveredApproval = (pending: PendingWait, approved: boolean) => + Effect.gen(function* () { + const events = yield* readStoredEventsForStep(pending.payload.stepRunId); + if (events === null || !pendingWaitInEvents(events, pending.payload.stepRunId)) { + return; + } + + const recovered = recoveredStepContext(events, pending.payload.stepRunId); + if (!recovered) { + return; + } + + yield* commit({ + type: "StepUserResolved", + ticketId: pending.ticketId, + payload: { stepRunId: pending.payload.stepRunId }, + }); + if (!approved) { + yield* completeRecoveredStepUnlocked( + pending.payload.stepRunId, + { + _tag: "failed", + error: "rejected", + }, + undefined, + { allowRetry: false }, + ); + return; + } + + const terminalResult = + pending.payload.providerThreadId === undefined + ? ({ _tag: "completed" } satisfies RecoveredStepResult) + : yield* awaitProviderTerminalForStep( + pending.payload.stepRunId, + pending.payload.providerThreadId, + ); + yield* completeRecoveredStepUnlocked(pending.payload.stepRunId, terminalResult, undefined); + }); + + const cancelStep: WorkflowEngineShape["cancelStep"] = (stepRunId) => + scriptCancels.cancel(stepRunId); + + const cancelBoardPipelines: WorkflowEngineShape["cancelBoardPipelines"] = (boardId) => + Effect.gen(function* () { + const tickets = yield* read.listTickets(boardId); + yield* Effect.forEach( + tickets, + (ticket) => interruptRunningPipeline(ticket.ticketId as TicketId), + { discard: true }, + ); + yield* cancelActiveProviderTurns(boardId); + }); + + const cancelTicketPipelines: WorkflowEngineShape["cancelTicketPipelines"] = (ticketId) => + Effect.gen(function* () { + yield* interruptRunningPipeline(ticketId); + yield* cancelActiveProviderTurnsForTicket(ticketId); + }); + + const resolveApproval: WorkflowEngineShape["resolveApproval"] = (stepRunId, approved) => + Effect.gen(function* () { + const resolve = Effect.gen(function* () { + const pending = yield* pendingWaitFor(stepRunId); + const { providerResponses } = yield* getOptionalServices; + if (pending?.payload.providerResponseKind === "user-input") { + return yield* new WorkflowEventStoreError({ + message: "provider user-input waits must be answered with answerTicketStep", + }); + } + if ( + pending?.payload.providerThreadId && + pending.payload.providerRequestId && + pending.payload.providerResponseKind && + Option.isSome(providerResponses) + ) { + yield* providerResponses.value.respond({ + threadId: pending.payload.providerThreadId, + requestId: pending.payload.providerRequestId, + responseKind: pending.payload.providerResponseKind, + approved, + }); + } + + const resumedLiveWaiter = yield* approvals.resolve(stepRunId, approved); + if (!resumedLiveWaiter && pending) { + yield* continueRecoveredApproval(pending, approved); + } + }); + const boardId = yield* boardIdForStepRun(stepRunId); + if (boardId === null) { + yield* resolve; + return; + } + yield* resolve; + }); + + return { + createTicket, + editTicket, + moveTicket, + runLane, + ingestExternalEvent, + resolveApproval, + answerTicketStep, + postTicketMessage, + cancelStep, + cancelBoardPipelines, + cancelTicketPipelines, + recoverBoardWip, + completeRecoveredStep, + } satisfies WorkflowEngineShape; +}); + +export const WorkflowEngineLayer = Layer.effect(WorkflowEngine, make); diff --git a/apps/server/src/workflow/Layers/WorkflowEngine.wip.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.wip.test.ts new file mode 100644 index 00000000000..9204f918620 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.wip.test.ts @@ -0,0 +1,557 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { + WorkflowEventCommitter, + type WorkflowEventCommitterShape, +} from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +const failedExecutor = Layer.succeed(StepExecutor, { + execute: () => Effect.succeed({ _tag: "failed" as const, error: "hold slot" }), +} satisfies StepExecutorShape); + +let selfRouteExecutionCount = 0; +const selfRouteExecutor = Layer.succeed(StepExecutor, { + execute: () => + Effect.sync(() => { + selfRouteExecutionCount += 1; + if (selfRouteExecutionCount === 1) { + return { _tag: "failed" as const, error: "retry in same lane" }; + } + return { _tag: "blocked" as const, reason: "stop after retry" }; + }), +} satisfies StepExecutorShape); + +const workflowLayer = (executor: Layer.Layer) => + WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(executor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + +const layer = it.layer(workflowLayer(failedExecutor)); + +const selfRouteLayer = it.layer(workflowLayer(selfRouteExecutor)); + +const wipDefinition = { + name: "wip", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + wipLimit: 1, + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const selfRouteDefinition = { + name: "self route", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "auto", + wipLimit: 1, + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { failure: "impl" }, + }, + ], +}; + +const manualCapacityDefinition = { + name: "manual capacity", + lanes: [{ key: "impl", name: "Impl", entry: "manual", wipLimit: 2 }], +}; + +const routedQueueDefinition = { + name: "routed queue", + lanes: [ + { + key: "source", + name: "Source", + entry: "manual", + wipLimit: 1, + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + on: { success: "target" }, + }, + { key: "target", name: "Target", entry: "manual", wipLimit: 1 }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const concurrentExitDefinition = { + name: "concurrent exit", + lanes: [ + { key: "source", name: "Source", entry: "manual", wipLimit: 1 }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const awaitTicketWhere = (ticketId: string, predicate: (detail: TicketDetail | null) => boolean) => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + for (let attempt = 0; attempt < 50; attempt += 1) { + const detail = yield* read.getTicketDetail(ticketId as never); + if (predicate(detail)) { + return detail; + } + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 10))); + yield* Effect.yieldNow; + } + return yield* read.getTicketDetail(ticketId as never); + }); + +const seedAdmittedTicket = ( + committer: WorkflowEventCommitterShape, + boardId: string, + ticketId: string, + token: string, + offset: number, +) => + Effect.gen(function* () { + yield* committer.commit({ + type: "TicketCreated", + eventId: `evt-${ticketId}-created` as never, + ticketId: ticketId as never, + occurredAt: `2026-06-07T00:10:${offset.toString().padStart(2, "0")}.000Z` as never, + payload: { + boardId: boardId as never, + title: ticketId, + laneKey: "impl" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: `evt-${ticketId}-admitted` as never, + ticketId: ticketId as never, + occurredAt: `2026-06-07T00:11:${offset.toString().padStart(2, "0")}.000Z` as never, + payload: { + toLane: "impl" as never, + laneEntryToken: token as never, + reason: "initial", + }, + } as never); + }); + +selfRouteLayer("WorkflowEngine same-lane WIP enforcement", (it) => { + it.effect("re-admits an admitted auto ticket routed back into its own full lane", () => + Effect.gen(function* () { + selfRouteExecutionCount = 0; + const registry = yield* BoardRegistry; + yield* registry.register("b-self-route" as never, selfRouteDefinition); + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + + const ticketId = yield* engine.createTicket({ + boardId: "b-self-route" as never, + title: "Retry", + initialLane: "impl" as never, + }); + const detail = yield* awaitTicketWhere( + ticketId as string, + (detail) => detail?.ticket.status === "blocked" && selfRouteExecutionCount === 2, + ); + + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const starts = events.filter((event) => event.type === "PipelineStarted"); + const moves = events.filter((event) => event.type === "TicketMovedToLane"); + assert.equal(selfRouteExecutionCount, 2); + assert.equal(starts.length, 2); + assert.equal(moves.length, 2); + assert.equal(moves[1]?.type, "TicketMovedToLane"); + if (moves[0]?.type !== "TicketMovedToLane" || moves[1]?.type !== "TicketMovedToLane") { + assert.fail("expected initial and routed lane moves"); + } + assert.equal(moves[1].payload.reason, "routed"); + assert.notEqual(moves[1].payload.laneEntryToken, moves[0].payload.laneEntryToken); + assert.equal(detail?.ticket.currentLaneKey, "impl"); + assert.equal(detail?.ticket.currentLaneEntryToken, moves[1].payload.laneEntryToken); + assert.isFalse(events.some((event) => event.type === "TicketQueued")); + }), + ); +}); + +layer("WorkflowEngine WIP enforcement", (it) => { + it.effect("discounts only the moving ticket for same-lane capacity checks", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const committer = yield* WorkflowEventCommitter; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + yield* registry.register("b-same-capacity-open" as never, manualCapacityDefinition); + yield* registry.register("b-same-capacity-full" as never, manualCapacityDefinition); + + yield* seedAdmittedTicket( + committer, + "b-same-capacity-open", + "ticket-open-self", + "tok-open-self", + 1, + ); + yield* seedAdmittedTicket( + committer, + "b-same-capacity-open", + "ticket-open-other", + "tok-open-other", + 2, + ); + + yield* engine.moveTicket("ticket-open-self" as never, "impl" as never); + + const openDetail = yield* read.getTicketDetail("ticket-open-self" as never); + const openEvents = yield* Stream.runCollect( + store.readByTicket("ticket-open-self" as never), + ).pipe(Effect.map((chunk) => Array.from(chunk))); + const openMoves = openEvents.filter((event) => event.type === "TicketMovedToLane"); + assert.equal(openDetail?.ticket.status, "idle"); + assert.equal(openDetail?.ticket.currentLaneKey, "impl"); + assert.isNotNull(openDetail?.ticket.currentLaneEntryToken ?? null); + assert.notEqual(openDetail?.ticket.currentLaneEntryToken, "tok-open-self"); + assert.equal(openMoves.length, 2); + assert.isFalse(openEvents.some((event) => event.type === "TicketQueued")); + + yield* seedAdmittedTicket( + committer, + "b-same-capacity-full", + "ticket-full-self", + "tok-full-self", + 3, + ); + yield* seedAdmittedTicket( + committer, + "b-same-capacity-full", + "ticket-full-other-a", + "tok-full-other-a", + 4, + ); + yield* seedAdmittedTicket( + committer, + "b-same-capacity-full", + "ticket-full-other-b", + "tok-full-other-b", + 5, + ); + + yield* engine.moveTicket("ticket-full-self" as never, "impl" as never); + + const fullDetail = yield* read.getTicketDetail("ticket-full-self" as never); + const fullEvents = yield* Stream.runCollect( + store.readByTicket("ticket-full-self" as never), + ).pipe(Effect.map((chunk) => Array.from(chunk))); + assert.equal(fullDetail?.ticket.status, "queued"); + assert.equal(fullDetail?.ticket.currentLaneKey, "impl"); + assert.equal(fullDetail?.ticket.currentLaneEntryToken, null); + assert.isTrue(fullEvents.some((event) => event.type === "TicketQueued")); + }), + ); + + it.effect("queues a second initial entry into a full auto lane without starting a pipeline", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-wip" as never, wipDefinition); + const engine = yield* WorkflowEngine; + + const firstTicketId = yield* engine.createTicket({ + boardId: "b-wip" as never, + title: "First", + initialLane: "impl" as never, + }); + const firstDetail = yield* awaitTicketWhere( + firstTicketId as string, + (detail) => + detail?.ticket.status === "blocked" && + detail.ticket.currentLaneEntryToken !== null && + detail.steps.length === 1, + ); + assert.equal(firstDetail?.ticket.currentLaneKey, "impl"); + assert.isNotNull(firstDetail?.ticket.currentLaneEntryToken ?? null); + + const secondTicketId = yield* engine.createTicket({ + boardId: "b-wip" as never, + title: "Second", + initialLane: "impl" as never, + }); + const secondDetail = yield* awaitTicketWhere( + secondTicketId as string, + (detail) => detail?.ticket.status === "queued", + ); + + assert.equal(secondDetail?.ticket.currentLaneKey, "impl"); + assert.equal(secondDetail?.ticket.status, "queued"); + assert.equal(secondDetail?.ticket.currentLaneEntryToken, null); + assert.isNotNull(secondDetail?.ticket.queuedAt ?? null); + assert.equal(secondDetail?.steps.length, 0); + }), + ); + + it.effect("queues a routed ticket into a full lane and admits the source lane FIFO", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const committer = yield* WorkflowEventCommitter; + const engine = yield* WorkflowEngine; + const store = yield* WorkflowEventStore; + const read = yield* WorkflowReadModel; + yield* registry.register("b-routed-wip" as never, routedQueueDefinition); + + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-target-created" as never, + ticketId: "ticket-target-full" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-routed-wip" as never, + title: "Target full", + laneKey: "target" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-target-admitted" as never, + ticketId: "ticket-target-full" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "target" as never, + laneEntryToken: "tok-target-full" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-source-created" as never, + ticketId: "ticket-source-routing" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + boardId: "b-routed-wip" as never, + title: "Source routing", + laneKey: "source" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-source-admitted" as never, + ticketId: "ticket-source-routing" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + toLane: "source" as never, + laneEntryToken: "tok-source-routing" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-source-pipeline" as never, + ticketId: "ticket-source-routing" as never, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + pipelineRunId: "pipeline-source-routing" as never, + laneKey: "source" as never, + laneEntryToken: "tok-source-routing" as never, + }, + } as never); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-source-step" as never, + ticketId: "ticket-source-routing" as never, + occurredAt: "2026-06-07T00:00:05.000Z" as never, + payload: { + pipelineRunId: "pipeline-source-routing" as never, + stepRunId: "step-source-routing" as never, + stepKey: "code" as never, + stepType: "agent", + }, + } as never); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-source-queued-created" as never, + ticketId: "ticket-source-queued" as never, + occurredAt: "2026-06-07T00:00:06.000Z" as never, + payload: { + boardId: "b-routed-wip" as never, + title: "Source queued", + laneKey: "source" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketQueued", + eventId: "evt-source-queued" as never, + ticketId: "ticket-source-queued" as never, + occurredAt: "2026-06-07T00:00:07.000Z" as never, + payload: { lane: "source" as never }, + } as never); + + yield* engine.completeRecoveredStep("step-source-routing" as never, { _tag: "completed" }); + + const routedDetail = yield* read.getTicketDetail("ticket-source-routing" as never); + const admittedDetail = yield* read.getTicketDetail("ticket-source-queued" as never); + assert.equal(routedDetail?.ticket.currentLaneKey, "target"); + assert.equal(routedDetail?.ticket.status, "queued"); + assert.equal(routedDetail?.ticket.currentLaneEntryToken, null); + assert.isNotNull(routedDetail?.ticket.queuedAt ?? null); + assert.equal(admittedDetail?.ticket.currentLaneKey, "source"); + assert.equal(admittedDetail?.ticket.status, "idle"); + assert.isNotNull(admittedDetail?.ticket.currentLaneEntryToken ?? null); + assert.equal(admittedDetail?.ticket.queuedAt, null); + + const events = yield* Stream.runCollect( + store.readByTicket("ticket-source-routing" as never), + ).pipe(Effect.map((chunk) => Array.from(chunk))); + const routeIndex = events.findIndex((event) => event.type === "TicketRouteDecided"); + const queueIndex = events.findIndex((event) => event.type === "TicketQueued"); + assert.isTrue(routeIndex >= 0 && queueIndex > routeIndex); + assert.isFalse( + events.some( + (event) => event.type === "TicketMovedToLane" && event.payload.reason === "routed", + ), + ); + }), + ); + + it.effect("admits only one queued ticket after two concurrent exits from an overfull lane", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const committer = yield* WorkflowEventCommitter; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + yield* registry.register("b-concurrent-exit" as never, concurrentExitDefinition); + + const seedAdmitted = (ticketId: string, token: string, offset: number) => + Effect.gen(function* () { + yield* committer.commit({ + type: "TicketCreated", + eventId: `evt-${ticketId}-created` as never, + ticketId: ticketId as never, + occurredAt: `2026-06-07T00:01:0${offset}.000Z` as never, + payload: { + boardId: "b-concurrent-exit" as never, + title: ticketId, + laneKey: "source" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: `evt-${ticketId}-admitted` as never, + ticketId: ticketId as never, + occurredAt: `2026-06-07T00:01:1${offset}.000Z` as never, + payload: { + toLane: "source" as never, + laneEntryToken: token as never, + reason: "initial", + }, + } as never); + }); + const seedQueued = (ticketId: string, offset: number) => + Effect.gen(function* () { + yield* committer.commit({ + type: "TicketCreated", + eventId: `evt-${ticketId}-created` as never, + ticketId: ticketId as never, + occurredAt: `2026-06-07T00:02:0${offset}.000Z` as never, + payload: { + boardId: "b-concurrent-exit" as never, + title: ticketId, + laneKey: "source" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketQueued", + eventId: `evt-${ticketId}-queued` as never, + ticketId: ticketId as never, + occurredAt: `2026-06-07T00:02:1${offset}.000Z` as never, + payload: { lane: "source" as never }, + } as never); + }); + + yield* seedAdmitted("ticket-exit-a", "tok-exit-a", 1); + yield* seedAdmitted("ticket-exit-b", "tok-exit-b", 2); + yield* seedQueued("ticket-queued-a", 1); + yield* seedQueued("ticket-queued-b", 2); + + yield* Effect.all( + [ + engine.moveTicket("ticket-exit-a" as never, "done" as never), + engine.moveTicket("ticket-exit-b" as never, "done" as never), + ], + { concurrency: "unbounded" }, + ); + + const queuedA = yield* read.getTicketDetail("ticket-queued-a" as never); + const queuedB = yield* read.getTicketDetail("ticket-queued-b" as never); + const admittedCount = yield* read.countAdmittedInLane( + "b-concurrent-exit" as never, + "source" as never, + ); + const admittedQueuedTickets = [queuedA, queuedB].filter( + (detail) => detail !== null && detail.ticket.currentLaneEntryToken !== null, + ); + + assert.equal(admittedCount, 1); + assert.equal(admittedQueuedTickets.length, 1); + assert.equal(queuedA?.ticket.status, "idle"); + assert.equal(queuedA?.ticket.queuedAt, null); + assert.equal(queuedB?.ticket.status, "queued"); + assert.equal(queuedB?.ticket.currentLaneEntryToken, null); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEventCommitter.test.ts b/apps/server/src/workflow/Layers/WorkflowEventCommitter.test.ts new file mode 100644 index 00000000000..8bae315e3a7 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEventCommitter.test.ts @@ -0,0 +1,596 @@ +import { assert, it } from "@effect/vitest"; +import type { BoardId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowEventStore, type PersistedWorkflowEvent } from "../Services/WorkflowEventStore.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; + +const layer = it.layer( + WorkflowEventCommitterLive.pipe( + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const registerBoard = (boardId: string) => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + yield* registry.register(boardId as never, { + name: boardId, + lanes: [{ key: "impl", name: "Impl", entry: "manual" }], + }); + yield* read.registerBoard({ + boardId: boardId as never, + projectId: "project-committer" as never, + name: boardId, + workflowFilePath: `.t3/boards/${boardId}.json`, + workflowVersionHash: `hash-${boardId}`, + maxConcurrentTickets: 3, + }); + }); + +const insertProjectedTicket = (input: { + readonly ticketId: string; + readonly boardId: string; + readonly title: string; + readonly lane?: string; + readonly status?: string; +}) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + ${input.ticketId}, + ${input.boardId}, + ${input.title}, + ${input.lane ?? "impl"}, + ${input.status ?? "running"}, + ${now}, + ${now} + ) + `; + }); + +const workflowEventCount = (ticketId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_events + WHERE ticket_id = ${ticketId} + `; + return rows[0]?.count ?? 0; + }); + +const commitManyLayerWithSaveLockInterposition = ( + expectedBoardId: BoardId, + beforeLockedEffect: (sql: SqlClient.SqlClient) => Effect.Effect, +) => { + const saveLocksLayer = Layer.effect( + WorkflowBoardSaveLocks, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return { + withSaveLock: (lockBoardId, effect) => + Effect.gen(function* () { + if (lockBoardId !== expectedBoardId) { + return yield* Effect.die(`unexpected board lock ${lockBoardId as string}`); + } + yield* beforeLockedEffect(sql).pipe(Effect.orDie); + return yield* effect; + }), + } satisfies WorkflowBoardSaveLocks["Service"]; + }), + ); + + return WorkflowEventCommitterLive.pipe( + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(saveLocksLayer), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); +}; + +it.effect( + "WorkflowEventCommitter.commitMany acquires the board save lock before its transaction without re-entering it", + () => + Effect.gen(function* () { + const boardId = "b-commit-many-delete-lock-order" as BoardId; + const persistedEvents: PersistedWorkflowEvent[] = []; + const projectedEvents: PersistedWorkflowEvent[] = []; + let inTransaction = false; + let boardLockHeld = false; + let saveLockAcquisitions = 0; + + const unsupportedEffect = () => Effect.die("unsupported fake committer dependency") as never; + const unsupportedStream = () => Stream.die("unsupported fake committer dependency") as never; + const fakeSql = Object.assign( + (() => Effect.die("unexpected sql statement")) as unknown as SqlClient.SqlClient, + { + withTransaction: (effect: Effect.Effect) => + Effect.gen(function* () { + if (inTransaction) { + return yield* Effect.die("commitMany opened a nested transaction"); + } + inTransaction = true; + return yield* effect.pipe( + Effect.ensuring( + Effect.sync(() => { + inTransaction = false; + }), + ), + ); + }), + } satisfies Partial, + ) as SqlClient.SqlClient; + const fakeSaveLocks = Layer.succeed(WorkflowBoardSaveLocks, { + withSaveLock: (lockBoardId, effect) => + Effect.gen(function* () { + if (lockBoardId !== boardId) { + return yield* Effect.die(`unexpected board lock ${lockBoardId as string}`); + } + if (inTransaction) { + return yield* Effect.die("commitMany acquired the save lock inside a transaction"); + } + if (boardLockHeld) { + return yield* Effect.die("commitMany re-entered the non-reentrant save lock"); + } + saveLockAcquisitions += 1; + boardLockHeld = true; + return yield* effect.pipe( + Effect.ensuring( + Effect.sync(() => { + boardLockHeld = false; + }), + ), + ); + }), + } satisfies WorkflowBoardSaveLocks["Service"]); + const fakeStore = Layer.succeed(WorkflowEventStore, { + append: (event) => + Effect.sync(() => { + const persisted = { + ...event, + streamVersion: persistedEvents.length, + sequence: persistedEvents.length + 1, + } as PersistedWorkflowEvent; + persistedEvents.push(persisted); + return persisted; + }), + readByTicket: unsupportedStream, + readFromSequence: unsupportedStream, + readAll: unsupportedStream, + deleteForBoard: unsupportedEffect, + deleteForTicket: unsupportedEffect, + } satisfies WorkflowEventStore["Service"]); + const fakeProjectionPipeline = Layer.succeed(WorkflowProjectionPipeline, { + projectEvent: (event) => + Effect.sync(() => { + projectedEvents.push(event as PersistedWorkflowEvent); + }), + } satisfies WorkflowProjectionPipeline["Service"]); + const fakeReadModel = Layer.succeed(WorkflowReadModel, { + registerBoard: unsupportedEffect, + getBoard: unsupportedEffect, + deleteBoard: unsupportedEffect, + deleteBoardTicketState: unsupportedEffect, + deleteTicketState: unsupportedEffect, + listBoardsForProject: unsupportedEffect, + listTickets: unsupportedEffect, + countAdmittedInLane: unsupportedEffect, + oldestQueuedForLane: unsupportedEffect, + getTicketDetail: () => Effect.succeed(null), + listTicketMessages: unsupportedEffect, + listStepRunsForPipeline: unsupportedEffect, + countLanePipelineRuns: unsupportedEffect, + listTicketDiscussion: unsupportedEffect, + listReleasableDependents: unsupportedEffect, + getBoardDigest: unsupportedEffect, + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: unsupportedEffect, + } satisfies WorkflowReadModel["Service"]); + const fakeRegistry = Layer.succeed(BoardRegistry, { + register: unsupportedEffect, + unregister: unsupportedEffect, + getDefinition: (requestedBoardId) => + Effect.succeed( + requestedBoardId === boardId + ? ({ + name: "Fake", + lanes: [{ key: "backlog", name: "Backlog", entry: "manual" }], + } as never) + : null, + ), + listDefinitions: unsupportedEffect, + getLane: unsupportedEffect, + } satisfies BoardRegistry["Service"]); + + yield* Effect.gen(function* () { + const committer = yield* WorkflowEventCommitter; + + yield* committer.commitMany([ + { + type: "TicketCreated", + eventId: "e-commit-many-delete-lock-order-1" as never, + ticketId: "t-commit-many-delete-lock-order" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId, + title: "Lock order" as never, + laneKey: "backlog" as never, + }, + }, + { + type: "TicketMovedToLane", + eventId: "e-commit-many-delete-lock-order-2" as never, + ticketId: "t-commit-many-delete-lock-order" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "backlog" as never, + laneEntryToken: "tok-lock-order" as never, + reason: "routed", + }, + }, + ]); + + assert.equal(saveLockAcquisitions, 1); + assert.deepEqual( + persistedEvents.map((event) => event.type), + ["TicketCreated", "TicketMovedToLane"], + ); + assert.deepEqual( + projectedEvents.map((event) => event.type), + ["TicketCreated", "TicketMovedToLane"], + ); + }).pipe( + Effect.provide( + WorkflowEventCommitterLive.pipe( + Layer.provideMerge(fakeRegistry), + Layer.provideMerge(fakeSaveLocks), + Layer.provideMerge(fakeStore), + Layer.provideMerge(fakeProjectionPipeline), + Layer.provideMerge(fakeReadModel), + Layer.provideMerge(Layer.succeed(SqlClient.SqlClient, fakeSql)), + ), + ), + ); + }), +); + +it.effect( + "commitMany skips stale events when an existing ticket was deleted under the save lock", + () => + Effect.gen(function* () { + const boardId = "b-commit-many-retention-delete" as BoardId; + const ticketId = "t-commit-many-retention-delete"; + const committer = yield* WorkflowEventCommitter; + yield* registerBoard(boardId); + yield* insertProjectedTicket({ + ticketId, + boardId, + title: "Retention deleted", + }); + + yield* committer.commitMany([ + { + type: "TicketBlocked", + eventId: "e-commit-many-retention-delete" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { reason: "stale" }, + }, + ]); + + assert.equal(yield* workflowEventCount(ticketId), 0); + }).pipe( + Effect.provide( + commitManyLayerWithSaveLockInterposition( + "b-commit-many-retention-delete" as BoardId, + (sql) => + sql` + DELETE FROM projection_ticket + WHERE ticket_id = ${"t-commit-many-retention-delete"} + `.pipe(Effect.asVoid), + ), + ), + ), +); + +it.effect( + "commitMany skips stale events when an existing ticket moved to another board under the save lock", + () => + Effect.gen(function* () { + const originalBoardId = "b-commit-many-move-original" as BoardId; + const movedBoardId = "b-commit-many-move-target" as BoardId; + const ticketId = "t-commit-many-move"; + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + yield* registerBoard(originalBoardId); + yield* registerBoard(movedBoardId); + yield* insertProjectedTicket({ + ticketId, + boardId: originalBoardId, + title: "Moved", + }); + + yield* committer.commitMany([ + { + type: "TicketBlocked", + eventId: "e-commit-many-move" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { reason: "wrong-board" }, + }, + ]); + + const tickets = yield* sql<{ readonly boardId: string; readonly status: string }>` + SELECT board_id AS "boardId", status + FROM projection_ticket + WHERE ticket_id = ${ticketId} + `; + assert.equal(yield* workflowEventCount(ticketId), 0); + assert.deepEqual(tickets, [{ boardId: movedBoardId, status: "running" }]); + }).pipe( + Effect.provide( + commitManyLayerWithSaveLockInterposition("b-commit-many-move-original" as BoardId, (sql) => + sql` + UPDATE projection_ticket + SET board_id = ${"b-commit-many-move-target"} + WHERE ticket_id = ${"t-commit-many-move"} + `.pipe(Effect.asVoid), + ), + ), + ), +); + +layer("WorkflowEventCommitter", (it) => { + it.effect("appends and projects in one call", () => + Effect.gen(function* () { + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + yield* registerBoard("b-1"); + + yield* committer.commit({ + type: "TicketCreated", + eventId: "e1" as never, + ticketId: "t-1" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { boardId: "b-1" as never, title: "X" as never, laneKey: "backlog" as never }, + }); + + const rows = yield* sql<{ readonly title: string }>` + SELECT title FROM projection_ticket WHERE ticket_id = 't-1' + `; + assert.equal(rows[0]?.title, "X"); + }), + ); + + it.effect("commitMany appends and projects all events in one transaction", () => + Effect.gen(function* () { + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + yield* registerBoard("b-1"); + + yield* committer.commitMany([ + { + type: "TicketCreated", + eventId: "e-many-1" as never, + ticketId: "t-many" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { boardId: "b-1" as never, title: "Many" as never, laneKey: "backlog" as never }, + }, + { + type: "TicketMovedToLane", + eventId: "e-many-2" as never, + ticketId: "t-many" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "impl" as never, + laneEntryToken: "tok-many" as never, + reason: "routed", + }, + }, + ]); + + const events = yield* sql<{ readonly eventType: string; readonly streamVersion: number }>` + SELECT event_type AS "eventType", stream_version AS "streamVersion" + FROM workflow_events + WHERE ticket_id = 't-many' + ORDER BY stream_version ASC + `; + const tickets = yield* sql<{ readonly lane: string; readonly token: string | null }>` + SELECT current_lane_key AS lane, current_lane_entry_token AS token + FROM projection_ticket + WHERE ticket_id = 't-many' + `; + assert.deepEqual(events, [ + { eventType: "TicketCreated", streamVersion: 0 }, + { eventType: "TicketMovedToLane", streamVersion: 1 }, + ]); + assert.deepEqual(tickets, [{ lane: "impl", token: "tok-many" }]); + }), + ); + + it.effect("commitMany rolls back earlier appends and projections when a later append fails", () => + Effect.gen(function* () { + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + yield* registerBoard("b-rollback"); + + const exit = yield* Effect.exit( + committer.commitMany([ + { + type: "TicketCreated", + eventId: "e-rollback-shared" as never, + ticketId: "t-rollback-a" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-rollback" as never, + title: "Rollback A" as never, + laneKey: "backlog" as never, + }, + }, + { + type: "TicketCreated", + eventId: "e-rollback-shared" as never, + ticketId: "t-rollback-b" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + boardId: "b-rollback" as never, + title: "Rollback B" as never, + laneKey: "backlog" as never, + }, + }, + ]), + ); + assert.isTrue(Exit.isFailure(exit)); + + const eventRows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_events + WHERE ticket_id IN ('t-rollback-a', 't-rollback-b') + `; + const projectionRows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM projection_ticket + WHERE ticket_id IN ('t-rollback-a', 't-rollback-b') + `; + assert.equal(eventRows[0]?.count, 0); + assert.equal(projectionRows[0]?.count, 0); + }), + ); + + it.effect( + "commitMany appends and projects an event for an existing ticket that still matches the board", + () => + Effect.gen(function* () { + const boardId = "b-commit-many-existing" as BoardId; + const ticketId = "t-commit-many-existing"; + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + yield* registerBoard(boardId); + yield* insertProjectedTicket({ + ticketId, + boardId, + title: "Existing", + }); + + yield* committer.commitMany([ + { + type: "TicketBlocked", + eventId: "e-commit-many-existing" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { reason: "normal" }, + }, + ]); + + const tickets = yield* sql<{ readonly status: string }>` + SELECT status + FROM projection_ticket + WHERE ticket_id = ${ticketId} + `; + assert.equal(yield* workflowEventCount(ticketId), 1); + assert.deepEqual(tickets, [{ status: "blocked" }]); + }), + ); + + it.effect("does not append a step event when board deletion wins the save lock", () => + Effect.gen(function* () { + const boardId = "b-committer-delete-race" as never; + const ticketId = "t-committer-delete-race" as never; + const now = "2026-06-07T00:00:00.000Z"; + const committer = yield* WorkflowEventCommitter; + const eventStore = yield* WorkflowEventStore; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const saveLocks = yield* WorkflowBoardSaveLocks; + const sql = yield* SqlClient.SqlClient; + const deleteReady = yield* Deferred.make(); + const releaseDelete = yield* Deferred.make(); + + yield* registerBoard(boardId); + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES (${ticketId}, ${boardId}, 'Delete race', 'impl', 'running', ${now}, ${now}) + `; + + const deleteFiber = yield* saveLocks + .withSaveLock( + boardId, + Effect.gen(function* () { + yield* eventStore.deleteForBoard(boardId); + yield* read.deleteBoardTicketState(boardId); + yield* registry.unregister(boardId); + yield* read.deleteBoard(boardId); + yield* Deferred.succeed(deleteReady, undefined); + yield* Deferred.await(releaseDelete); + }), + ) + .pipe(Effect.forkChild); + + yield* Deferred.await(deleteReady).pipe(Effect.timeout("1 second")); + const commitFiber = yield* committer + .commit({ + type: "StepCompleted", + eventId: "evt-delete-race-step-completed" as never, + ticketId, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { stepRunId: "step-delete-race" as never }, + }) + .pipe(Effect.exit, Effect.forkChild); + + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseDelete, undefined); + yield* Fiber.join(deleteFiber).pipe(Effect.timeout("1 second")); + yield* Fiber.join(commitFiber).pipe(Effect.timeout("1 second")); + + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_events + WHERE ticket_id = ${ticketId} + `; + assert.equal(rows[0]?.count, 0); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEventCommitter.ts b/apps/server/src/workflow/Layers/WorkflowEventCommitter.ts new file mode 100644 index 00000000000..fae8234dc81 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEventCommitter.ts @@ -0,0 +1,299 @@ +import type { BoardId, BoardTicketView, LaneKey, TicketId, TicketStatus } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { WorkflowBoardEvents } from "../Services/WorkflowBoardEvents.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { + WorkflowEventCommitter, + type WorkflowEventCommitterShape, +} from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowEventStore, type PersistedWorkflowEvent } from "../Services/WorkflowEventStore.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; + +const isWorkflowEventStoreError = Schema.is(WorkflowEventStoreError); +const toCommitterError = (cause: unknown) => + isWorkflowEventStoreError(cause) + ? cause + : new WorkflowEventStoreError({ message: "workflow commit transaction failed", cause }); + +const boardNotRegistered = (boardId: BoardId) => + new WorkflowEventStoreError({ message: `Workflow board ${boardId} is no longer registered` }); + +const make = Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const pipeline = yield* WorkflowProjectionPipeline; + const readModel = yield* WorkflowReadModel; + const registry = yield* BoardRegistry; + const saveLocks = yield* WorkflowBoardSaveLocks; + const sql = yield* SqlClient.SqlClient; + type CommitEvent = Parameters[0]; + interface ResolvedCommitEvent { + readonly event: CommitEvent; + readonly boardId: BoardId | undefined; + } + interface RecheckedCommitEvent extends ResolvedCommitEvent { + readonly shouldCommit: boolean; + } + + const getOptionalServices = Effect.context().pipe( + Effect.map((context) => ({ + boardEvents: Context.getOption( + context as Context.Context, + WorkflowBoardEvents, + ), + })), + ); + + const resolveBoardId = (event: CommitEvent) => + Effect.gen(function* () { + if (event.type === "TicketCreated") { + return event.payload.boardId; + } + const detail = yield* readModel.getTicketDetail(event.ticketId); + return detail?.ticket.boardId as BoardId | undefined; + }); + + const recheckRegisteredBoard = (boardId: BoardId, event: CommitEvent) => + Effect.gen(function* () { + const definitionExit = yield* Effect.exit(registry.getDefinition(boardId)); + if (Exit.isSuccess(definitionExit) && definitionExit.value === null) { + if (event.type === "TicketCreated") { + return yield* boardNotRegistered(boardId); + } + return false; + } + if (event.type === "TicketCreated") { + return true; + } + const detail = yield* readModel.getTicketDetail(event.ticketId); + return detail?.ticket.boardId === boardId; + }); + + const appendAndProjectUnlocked = (event: CommitEvent) => + Effect.gen(function* () { + const persisted = yield* store.append(event); + yield* pipeline.projectEvent(persisted); + return persisted; + }); + + const appendAndProject = ( + event: CommitEvent, + ): Effect.Effect => + Effect.gen(function* () { + const boardId = yield* resolveBoardId(event); + if (boardId === undefined) { + return null; + } + return yield* saveLocks.withSaveLock( + boardId, + Effect.gen(function* () { + const isRegistered = yield* recheckRegisteredBoard(boardId, event); + if (!isRegistered) { + return null; + } + return yield* appendAndProjectUnlocked(event); + }), + ); + }); + + const resolveBatchBoardIds = (events: ReadonlyArray) => + Effect.gen(function* () { + const ticketBoardIds = new Map(); + const resolved: Array = []; + + for (const event of events) { + let boardId: BoardId | undefined; + if (event.type === "TicketCreated") { + boardId = event.payload.boardId; + } else { + boardId = ticketBoardIds.get(event.ticketId as string); + if (boardId === undefined) { + boardId = yield* resolveBoardId(event); + } + } + + if (boardId !== undefined) { + ticketBoardIds.set(event.ticketId as string, boardId); + } + resolved.push({ event, boardId }); + } + + return resolved; + }); + + const distinctSortedBoardIds = (events: ReadonlyArray) => + Array.from( + new Set(events.flatMap(({ boardId }) => (boardId === undefined ? [] : [boardId]))), + ).sort((left, right) => (left as string).localeCompare(right as string)); + + const withBoardSaveLocks = ( + boardIds: ReadonlyArray, + effect: Effect.Effect, + ) => + boardIds.reduceRight( + (lockedEffect, boardId) => saveLocks.withSaveLock(boardId, lockedEffect), + effect, + ); + + const recheckRegisteredBoards = ( + resolved: ReadonlyArray, + boardIds: ReadonlyArray, + ) => + Effect.gen(function* () { + const registeredBoards = new Map(); + + for (const boardId of boardIds) { + const definitionExit = yield* Effect.exit(registry.getDefinition(boardId)); + const isRegistered = !(Exit.isSuccess(definitionExit) && definitionExit.value === null); + if ( + !isRegistered && + resolved.some( + ({ boardId: eventBoardId, event }) => + eventBoardId === boardId && event.type === "TicketCreated", + ) + ) { + return yield* boardNotRegistered(boardId); + } + registeredBoards.set(boardId as string, isRegistered); + } + + return registeredBoards; + }); + + const recheckBatchTickets = ( + resolved: ReadonlyArray, + registeredBoards: ReadonlyMap, + ) => + Effect.gen(function* () { + const createdTicketIds = new Set(); + const rechecked: Array = []; + + for (const resolvedEvent of resolved) { + const { event, boardId } = resolvedEvent; + const ticketId = event.ticketId as string; + + if (boardId === undefined || registeredBoards.get(boardId as string) !== true) { + rechecked.push({ ...resolvedEvent, shouldCommit: false }); + continue; + } + + if (event.type === "TicketCreated") { + createdTicketIds.add(ticketId); + rechecked.push({ ...resolvedEvent, shouldCommit: true }); + continue; + } + + if (createdTicketIds.has(ticketId)) { + rechecked.push({ ...resolvedEvent, shouldCommit: true }); + continue; + } + + const detail = yield* readModel.getTicketDetail(event.ticketId); + rechecked.push({ + ...resolvedEvent, + shouldCommit: detail?.ticket.boardId === boardId, + }); + } + + return rechecked; + }); + + const publishTicketView = (ticketId: PersistedWorkflowEvent["ticketId"]) => + Effect.gen(function* () { + const detail = yield* readModel.getTicketDetail(ticketId); + const { boardEvents } = yield* getOptionalServices; + if (detail && Option.isSome(boardEvents)) { + const ticket = detail.ticket; + yield* boardEvents.value.publish({ + ticketId: ticket.ticketId as TicketId, + boardId: ticket.boardId as BoardId, + title: ticket.title, + ...(ticket.description === null ? {} : { description: ticket.description }), + currentLaneKey: ticket.currentLaneKey as LaneKey, + status: ticket.status as TicketStatus, + ...(ticket.queuedAt === null ? {} : { queuedAt: ticket.queuedAt }), + ...(ticket.dependsOn === undefined || ticket.dependsOn.length === 0 + ? {} + : { dependsOn: ticket.dependsOn as ReadonlyArray }), + ...(ticket.unresolvedDependencyCount === undefined || + ticket.unresolvedDependencyCount === 0 + ? {} + : { unresolvedDependencyCount: ticket.unresolvedDependencyCount }), + ...(typeof ticket.tokenBudget === "number" ? { tokenBudget: ticket.tokenBudget } : {}), + ...(ticket.updatedAt === undefined ? {} : { updatedAt: ticket.updatedAt }), + ...(typeof ticket.totalTokens === "number" && ticket.totalTokens > 0 + ? { totalTokens: ticket.totalTokens } + : {}), + ...(typeof ticket.totalDurationMs === "number" && ticket.totalDurationMs > 0 + ? { totalDurationMs: ticket.totalDurationMs } + : {}), + } satisfies BoardTicketView); + } + }); + + const publishTicket = (persisted: PersistedWorkflowEvent) => + Effect.gen(function* () { + yield* publishTicketView(persisted.ticketId); + // Lane moves can change dependents' unresolved counts (terminal entry + // resolves them, leaving a terminal lane un-resolves them) — republish + // every dependent so waiting badges stay live. + if (persisted.type === "TicketMovedToLane" || persisted.type === "TicketDependenciesSet") { + const dependents = yield* readModel + .listDependentTicketIds(persisted.ticketId) + .pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach(dependents, (dependent) => publishTicketView(dependent as never), { + discard: true, + }); + } + }); + + const commit: WorkflowEventCommitterShape["commit"] = (event) => + appendAndProject(event).pipe( + Effect.flatMap((persisted) => (persisted === null ? Effect.void : publishTicket(persisted))), + ); + + const commitMany: WorkflowEventCommitterShape["commitMany"] = (events) => + Effect.gen(function* () { + const resolved = yield* resolveBatchBoardIds(events); + const boardIds = distinctSortedBoardIds(resolved); + if (boardIds.length === 0) { + return; + } + + const persisted = yield* withBoardSaveLocks( + boardIds, + Effect.gen(function* () { + const registeredBoards = yield* recheckRegisteredBoards(resolved, boardIds); + const rechecked = yield* recheckBatchTickets(resolved, registeredBoards); + return yield* sql + .withTransaction( + Effect.forEach( + rechecked, + ({ event, shouldCommit }) => + !shouldCommit ? Effect.succeed(null) : appendAndProjectUnlocked(event), + { concurrency: 1 }, + ), + ) + .pipe(Effect.mapError(toCommitterError)); + }), + ); + yield* Effect.forEach( + persisted, + (event) => (event === null ? Effect.void : publishTicket(event)), + { discard: true }, + ); + }); + + return { commit, commitMany } satisfies WorkflowEventCommitterShape; +}); + +export const WorkflowEventCommitterLive = Layer.effect(WorkflowEventCommitter, make); diff --git a/apps/server/src/workflow/Layers/WorkflowEventStore.test.ts b/apps/server/src/workflow/Layers/WorkflowEventStore.test.ts new file mode 100644 index 00000000000..f13114a26f7 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEventStore.test.ts @@ -0,0 +1,179 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowEventStoreLive } from "./WorkflowEventStore.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("workflow migration", (it) => { + it.effect("creates workflow_events and projection tables", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const tables = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' + AND name IN ( + 'workflow_events', + 'projection_board', + 'projection_ticket', + 'projection_pipeline_run', + 'projection_step_run' + ) + `; + assert.equal(tables.length, 5); + }), + ); +}); + +const storeLayer = it.layer( + WorkflowEventStoreLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +storeLayer("WorkflowEventStore", (it) => { + it.effect("appends and replays a decoded event with assigned version", () => + Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const appended = yield* store.append({ + type: "TicketCreated", + eventId: "evt-a" as never, + ticketId: "t-1" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { boardId: "b-1" as never, title: "X" as never, laneKey: "backlog" as never }, + }); + assert.equal(appended.streamVersion, 0); + + const events = yield* Stream.runCollect(store.readByTicket("t-1" as never)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.equal(events.length, 1); + assert.equal(events[0]?.type, "TicketCreated"); + }), + ); + + it.effect("assigns incrementing stream versions per ticket", () => + Effect.gen(function* () { + const store = yield* WorkflowEventStore; + yield* store.append({ + type: "TicketCreated", + eventId: "evt-b" as never, + ticketId: "t-2" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { boardId: "b-1" as never, title: "Y" as never, laneKey: "backlog" as never }, + }); + const second = yield* store.append({ + type: "TicketBlocked", + eventId: "evt-c" as never, + ticketId: "t-2" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { reason: "scope unclear" }, + }); + assert.equal(second.streamVersion, 1); + }), + ); + + it.effect("deletes events for tickets that belong to a board", () => + Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES + ('ticket-events-delete', 'board-events-delete', 'Delete', 'backlog', 'idle', ${now}, ${now}), + ('ticket-events-keep', 'board-events-keep', 'Keep', 'backlog', 'idle', ${now}, ${now}) + `; + yield* store.append({ + type: "TicketCreated", + eventId: "evt-delete" as never, + ticketId: "ticket-events-delete" as never, + occurredAt: now as never, + payload: { + boardId: "board-events-delete" as never, + title: "Delete" as never, + laneKey: "backlog" as never, + }, + }); + yield* store.append({ + type: "TicketCreated", + eventId: "evt-keep" as never, + ticketId: "ticket-events-keep" as never, + occurredAt: now as never, + payload: { + boardId: "board-events-keep" as never, + title: "Keep" as never, + laneKey: "backlog" as never, + }, + }); + + yield* store.deleteForBoard("board-events-delete" as never); + + const rows = yield* sql<{ readonly ticketId: string; readonly count: number }>` + SELECT ticket_id AS "ticketId", COUNT(*) AS count + FROM workflow_events + WHERE ticket_id IN ('ticket-events-delete', 'ticket-events-keep') + GROUP BY ticket_id + ORDER BY ticket_id ASC + `; + assert.deepEqual(rows, [{ ticketId: "ticket-events-keep", count: 1 }]); + }), + ); + + it.effect("deletes events for exactly one ticket", () => + Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + + yield* store.append({ + type: "TicketCreated", + eventId: "evt-ticket-delete" as never, + ticketId: "ticket-events-delete-one" as never, + occurredAt: now as never, + payload: { + boardId: "board-events-delete-one" as never, + title: "Delete" as never, + laneKey: "backlog" as never, + }, + }); + yield* store.append({ + type: "TicketCreated", + eventId: "evt-ticket-keep" as never, + ticketId: "ticket-events-keep-one" as never, + occurredAt: now as never, + payload: { + boardId: "board-events-delete-one" as never, + title: "Keep" as never, + laneKey: "backlog" as never, + }, + }); + + yield* store.deleteForTicket("ticket-events-delete-one" as never); + + const rows = yield* sql<{ readonly ticketId: string; readonly count: number }>` + SELECT ticket_id AS "ticketId", COUNT(*) AS count + FROM workflow_events + WHERE ticket_id IN ('ticket-events-delete-one', 'ticket-events-keep-one') + GROUP BY ticket_id + ORDER BY ticket_id ASC + `; + assert.deepEqual(rows, [{ ticketId: "ticket-events-keep-one", count: 1 }]); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowEventStore.ts b/apps/server/src/workflow/Layers/WorkflowEventStore.ts new file mode 100644 index 00000000000..0024522d116 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEventStore.ts @@ -0,0 +1,165 @@ +import { WorkflowEvent } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorkflowEventStore, + type PersistedWorkflowEvent, + type WorkflowEventStoreShape, +} from "../Services/WorkflowEventStore.ts"; + +interface Row { + readonly sequence: number; + readonly eventId: string; + readonly ticketId: string; + readonly streamVersion: number; + readonly type: string; + readonly occurredAt: string; + readonly payloadJson: string; +} + +const decodePayloadJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeWorkflowEvent = Schema.decodeUnknownEffect(WorkflowEvent); +const encodePayloadJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); + +const toStoreError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const decodeEvent = (row: Row): Effect.Effect => + Effect.gen(function* () { + const payload = yield* decodePayloadJson(row.payloadJson); + const event = yield* decodeWorkflowEvent({ + type: row.type, + eventId: row.eventId, + ticketId: row.ticketId, + streamVersion: row.streamVersion, + occurredAt: row.occurredAt, + payload, + }); + return { ...event, sequence: row.sequence } as PersistedWorkflowEvent; + }).pipe(Effect.mapError(toStoreError("Failed to decode workflow event"))); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const append: WorkflowEventStoreShape["append"] = (event) => + Effect.gen(function* () { + const payloadJson = yield* encodePayloadJson(event.payload); + const rows = yield* sql` + INSERT INTO workflow_events + (event_id, ticket_id, stream_version, event_type, occurred_at, payload_json) + VALUES ( + ${event.eventId}, + ${event.ticketId}, + COALESCE( + ( + SELECT stream_version + 1 + FROM workflow_events + WHERE ticket_id = ${event.ticketId} + ORDER BY stream_version DESC + LIMIT 1 + ), + 0 + ), + ${event.type}, + ${event.occurredAt}, + ${payloadJson} + ) + RETURNING + sequence, + event_id AS "eventId", + ticket_id AS "ticketId", + stream_version AS "streamVersion", + event_type AS "type", + occurred_at AS "occurredAt", + payload_json AS "payloadJson" + `; + const row = rows[0]; + if (!row) { + return yield* new WorkflowEventStoreError({ message: "append returned no row" }); + } + return yield* decodeEvent(row); + }).pipe(Effect.mapError(toStoreError("append failed"))); + + const streamRows = ( + query: Effect.Effect, SqlError>, + ): Stream.Stream => + Stream.fromEffect(query.pipe(Effect.mapError(toStoreError("read failed")))).pipe( + Stream.flatMap((rows) => Stream.fromIterable(rows)), + Stream.mapEffect(decodeEvent), + ); + + const readByTicket: WorkflowEventStoreShape["readByTicket"] = (ticketId) => + streamRows(sql` + SELECT + sequence, + event_id AS "eventId", + ticket_id AS "ticketId", + stream_version AS "streamVersion", + event_type AS "type", + occurred_at AS "occurredAt", + payload_json AS "payloadJson" + FROM workflow_events + WHERE ticket_id = ${ticketId} + ORDER BY stream_version ASC + `); + + const readFromSequence: WorkflowEventStoreShape["readFromSequence"] = ( + sequenceExclusive, + limit = 1_000, + ) => { + const normalizedLimit = Math.max(0, Math.floor(limit)); + if (normalizedLimit === 0) { + return Stream.empty; + } + return streamRows(sql` + SELECT + sequence, + event_id AS "eventId", + ticket_id AS "ticketId", + stream_version AS "streamVersion", + event_type AS "type", + occurred_at AS "occurredAt", + payload_json AS "payloadJson" + FROM workflow_events + WHERE sequence > ${sequenceExclusive} + ORDER BY sequence ASC + LIMIT ${normalizedLimit} + `); + }; + + const readAll: WorkflowEventStoreShape["readAll"] = () => + readFromSequence(0, Number.MAX_SAFE_INTEGER); + + const deleteForBoard: WorkflowEventStoreShape["deleteForBoard"] = (boardId) => + sql` + DELETE FROM workflow_events + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `.pipe(Effect.mapError(toStoreError("delete failed")), Effect.asVoid); + + const deleteForTicket: WorkflowEventStoreShape["deleteForTicket"] = (ticketId) => + sql` + DELETE FROM workflow_events + WHERE ticket_id = ${ticketId} + `.pipe(Effect.mapError(toStoreError("delete failed")), Effect.asVoid); + + return { + append, + readByTicket, + readFromSequence, + readAll, + deleteForBoard, + deleteForTicket, + } satisfies WorkflowEventStoreShape; +}); + +export const WorkflowEventStoreLive = Layer.effect(WorkflowEventStore, make); diff --git a/apps/server/src/workflow/Layers/WorkflowFileLoader.test.ts b/apps/server/src/workflow/Layers/WorkflowFileLoader.test.ts new file mode 100644 index 00000000000..d89131f78b2 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowFileLoader.test.ts @@ -0,0 +1,469 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +import { assert, it } from "@effect/vitest"; +import { + WorkflowDefinition, + WorkflowRpcError, + type BoardId, + type ProjectId, +} from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { + WorkflowFileLoader, + WorkflowFilePort, + WorkflowProviderInstancePort, +} from "../Services/WorkflowFileLoader.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { WorkflowFileLoaderLive } from "./WorkflowFileLoader.ts"; +import { WorkflowReadModelLive } from "./WorkflowReadModel.ts"; + +const workflowJson = (providerInstance = "codex_main") => + JSON.stringify({ + name: "Delivery Board", + settings: { maxConcurrentTickets: 2 }, + lanes: [ + { + key: "code", + name: "Code", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent: { instance: providerInstance, model: "gpt-5.5" }, + instruction: { file: "prompts/implement.md" }, + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + +const scriptTimeoutWorkflowJson = () => + JSON.stringify({ + name: "Script Timeout Board", + lanes: [ + { + key: "run", + name: "Run", + entry: "auto", + pipeline: [{ key: "smoke", type: "script", run: "echo hi", timeout: "1 minute" }], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + +const invalidWipWorkflowJson = () => + JSON.stringify({ + name: "Invalid WIP Board", + lanes: [ + { key: "queue", name: "Queue", entry: "manual", wipLimit: 0 }, + { key: "done", name: "Done", entry: "manual", terminal: true, wipLimit: 1 }, + ], + }); + +const decodeWorkflowDefinitionJson = Schema.decodeEffect(Schema.fromJsonString(WorkflowDefinition)); +const decodeWorkflowDefinition = Schema.decodeUnknownEffect(WorkflowDefinition); + +const mk = (providerInstanceExists: (instanceId: string) => boolean) => + it.layer( + WorkflowFileLoaderLive.pipe( + Layer.provideMerge( + Layer.succeed(WorkflowFilePort, { + readFileString: () => Effect.succeed(workflowJson()), + instructionFileExists: ({ repoRelativePath }) => + Effect.succeed(repoRelativePath === "prompts/implement.md"), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: (instanceId) => + Effect.succeed(providerInstanceExists(instanceId)), + }), + ), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ); + +mk((instanceId) => instanceId === "codex_main")("WorkflowFileLoader", (it) => { + it.effect("loads, lints, registers, and persists a workflow board", () => + Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const boardId = "board-loader" as BoardId; + + const loadedBoardId = yield* loader.loadAndRegister({ + boardId, + projectId: "project-loader" as ProjectId, + workspaceRoot: "/repo", + relativePath: ".t3/boards/delivery.json", + }); + + const definition = yield* registry.getDefinition(boardId); + const board = yield* read.getBoard(boardId); + + assert.equal(loadedBoardId, boardId); + assert.equal(definition?.name, "Delivery Board"); + assert.equal(board?.name, "Delivery Board"); + assert.equal(board?.workflowFilePath, ".t3/boards/delivery.json"); + assert.equal(board?.maxConcurrentTickets, 2); + assert.isTrue((board?.workflowVersionHash.length ?? 0) > 0); + }), + ); +}); + +it.effect("WorkflowFileLoader lintDefinition reuses provider and instruction-file context", () => { + const providerChecks: string[] = []; + const instructionChecks: Array<{ readonly repoRoot: string; readonly repoRelativePath: string }> = + []; + const layer = WorkflowFileLoaderLive.pipe( + Layer.provideMerge( + Layer.succeed(WorkflowFilePort, { + readFileString: (filePath) => + String(filePath).endsWith("prompts/implement.md") + ? Effect.succeed("Implement {{ticket.title}}.") + : Effect.die("lintDefinition must not read a workflow file"), + instructionFileExists: (input) => { + instructionChecks.push(input); + return Effect.succeed( + input.repoRoot === "/repo" && input.repoRelativePath === "prompts/implement.md", + ); + }, + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: (instanceId) => { + providerChecks.push(instanceId); + return Effect.succeed(instanceId === "codex_main"); + }, + }), + ), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + return Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + const definition = yield* decodeWorkflowDefinitionJson(workflowJson()); + const errors = yield* loader.lintDefinition({ + definition, + projectId: "project-loader" as ProjectId, + workspaceRoot: "/repo", + }); + + assert.deepEqual(errors, []); + assert.deepEqual(providerChecks, ["codex_main"]); + assert.deepEqual(instructionChecks, [ + { repoRoot: "/repo", repoRelativePath: "prompts/implement.md" }, + ]); + }).pipe(Effect.provide(layer)); +}); + +it.effect("WorkflowFileLoader lintDefinition returns lint errors without registering", () => { + const layer = WorkflowFileLoaderLive.pipe( + Layer.provideMerge( + Layer.succeed(WorkflowFilePort, { + readFileString: () => Effect.die("lintDefinition must not read a workflow file"), + instructionFileExists: () => Effect.succeed(false), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: () => Effect.succeed(false), + }), + ), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + return Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + const registry = yield* BoardRegistry; + const boardId = "board-lint-only" as BoardId; + const definition = yield* decodeWorkflowDefinitionJson(workflowJson()); + const errors = yield* loader.lintDefinition({ + definition, + projectId: "project-loader" as ProjectId, + workspaceRoot: "/repo", + }); + + assert.deepEqual( + errors.map((error) => ({ + code: error.code, + laneKey: error.laneKey, + stepKey: error.stepKey, + })), + [ + { code: "unknown_provider_instance", laneKey: "code", stepKey: "implement" }, + { code: "missing_instruction_file", laneKey: "code", stepKey: "implement" }, + ], + ); + assert.isNull(yield* registry.getDefinition(boardId)); + }).pipe(Effect.provide(layer)); +}); + +it.effect( + "WorkflowFileLoader lintDefinition rejects unsafe instruction paths before file checks", + () => { + const instructionChecks: Array<{ + readonly repoRoot: string; + readonly repoRelativePath: string; + }> = []; + const layer = WorkflowFileLoaderLive.pipe( + Layer.provideMerge( + Layer.succeed(WorkflowFilePort, { + readFileString: () => Effect.die("lintDefinition must not read a workflow file"), + instructionFileExists: (input) => { + instructionChecks.push(input); + return Effect.succeed(true); + }, + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: (instanceId) => Effect.succeed(instanceId === "codex_main"), + }), + ), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + return Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + const definition = yield* decodeWorkflowDefinition({ + name: "Unsafe Instruction Board", + lanes: [ + { + key: "code", + name: "Code", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent: { instance: "codex_main", model: "gpt-5.5" }, + instruction: { file: "../escape.md" }, + }, + ], + }, + ], + }); + const errors = yield* loader.lintDefinition({ + definition, + projectId: "project-loader" as ProjectId, + workspaceRoot: "/repo", + }); + + assert.deepEqual( + errors.map((error) => ({ + code: error.code, + laneKey: error.laneKey, + stepKey: error.stepKey, + })), + [{ code: "unsafe_instruction_path", laneKey: "code", stepKey: "implement" }], + ); + assert.deepEqual(instructionChecks, []); + }).pipe(Effect.provide(layer)); + }, +); + +it.effect("WorkflowFileLoader registers a workflow board whose script step has a timeout", () => { + const layer = WorkflowFileLoaderLive.pipe( + Layer.provideMerge( + Layer.succeed(WorkflowFilePort, { + readFileString: () => Effect.succeed(scriptTimeoutWorkflowJson()), + instructionFileExists: () => Effect.succeed(false), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: () => Effect.succeed(false), + }), + ), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + return Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const boardId = "board-script-timeout" as BoardId; + + const loadedBoardId = yield* loader.loadAndRegister({ + boardId, + projectId: "project-loader" as ProjectId, + workspaceRoot: "/repo", + relativePath: ".t3/boards/script-timeout.json", + }); + + const definition = yield* registry.getDefinition(boardId); + const board = yield* read.getBoard(boardId); + const step = definition?.lanes[0]?.pipeline?.[0]; + + assert.equal(loadedBoardId, boardId); + assert.equal(definition?.name, "Script Timeout Board"); + assert.equal(step?.type, "script"); + if (step?.type === "script") { + const timeout = step.timeout; + assert.isDefined(timeout); + if (timeout !== undefined) { + assert.equal(Duration.toMillis(timeout), 60_000); + } + } + assert.equal(board?.name, "Script Timeout Board"); + assert.equal(board?.workflowFilePath, ".t3/boards/script-timeout.json"); + }).pipe(Effect.provide(layer)); +}); + +it.effect( + "WorkflowFileLoader reads from the workspace-root path and persists the relative path", + () => { + let workspaceRoot = ""; + return Effect.gen(function* () { + workspaceRoot = mkdtempSync(join(tmpdir(), "t3-workflow-loader-")); + const relativePath = ".t3/boards/split.json"; + const absolutePath = resolve(workspaceRoot, relativePath); + mkdirSync(dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, workflowJson(), "utf8"); + + const readPath = yield* Ref.make(null); + const layer = WorkflowFileLoaderLive.pipe( + Layer.provideMerge( + Layer.succeed(WorkflowFilePort, { + readFileString: (filePath) => + Effect.gen(function* () { + if (String(filePath).endsWith(".json")) { + yield* Ref.set(readPath, filePath); + } + return yield* Effect.try({ + try: () => readFileSync(filePath, "utf8"), + catch: (cause) => + new WorkflowRpcError({ message: "test workflow file read failed", cause }), + }); + }), + instructionFileExists: ({ repoRelativePath }) => + Effect.succeed(repoRelativePath === "prompts/implement.md"), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: (instanceId) => Effect.succeed(instanceId === "codex_main"), + }), + ), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + const read = yield* WorkflowReadModel; + const boardId = "board-split-path" as BoardId; + + yield* loader.loadAndRegister({ + boardId, + projectId: "project-loader" as ProjectId, + workspaceRoot, + relativePath, + }); + + assert.equal(yield* Ref.get(readPath), absolutePath); + const board = yield* read.getBoard(boardId); + assert.equal(board?.workflowFilePath, relativePath); + }).pipe(Effect.provide(layer)); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (workspaceRoot !== "") { + rmSync(workspaceRoot, { recursive: true, force: true }); + } + }), + ), + ); + }, +); + +it.effect("WorkflowFileLoader blocks activation for invalid WIP limits", () => { + const layer = WorkflowFileLoaderLive.pipe( + Layer.provideMerge( + Layer.succeed(WorkflowFilePort, { + readFileString: () => Effect.succeed(invalidWipWorkflowJson()), + instructionFileExists: () => Effect.succeed(false), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowProviderInstancePort, { + providerInstanceExists: () => Effect.succeed(false), + }), + ), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + return Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + const result = yield* Effect.exit( + loader.loadAndRegister({ + boardId: "board-invalid-wip" as BoardId, + projectId: "project-loader" as ProjectId, + workspaceRoot: "/repo", + relativePath: ".t3/boards/invalid-wip.json", + }), + ); + + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.isTrue(result.cause.toString().includes("invalid_wip_limit")); + } + }).pipe(Effect.provide(layer)); +}); + +mk(() => false)("WorkflowFileLoader lint failure", (it) => { + it.effect("fails when the workflow references an unknown provider instance", () => + Effect.gen(function* () { + const loader = yield* WorkflowFileLoader; + + const result = yield* Effect.exit( + loader.loadAndRegister({ + boardId: "board-loader-fail" as BoardId, + projectId: "project-loader" as ProjectId, + workspaceRoot: "/repo", + relativePath: ".t3/boards/delivery.json", + }), + ); + + assert.strictEqual(result._tag, "Failure"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowFileLoader.ts b/apps/server/src/workflow/Layers/WorkflowFileLoader.ts new file mode 100644 index 00000000000..da28d693443 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowFileLoader.ts @@ -0,0 +1,196 @@ +// @effect-diagnostics nodeBuiltinImport:off +import path from "node:path"; + +import { ProviderInstanceId, WorkflowDefinition, WorkflowRpcError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { ProviderInstanceRegistry } from "../../provider/Services/ProviderInstanceRegistry.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { + WorkflowFileLoader, + WorkflowFilePort, + WorkflowProviderInstancePort, + type WorkflowFileLoaderShape, + type WorkflowFilePortShape, + type WorkflowProviderInstancePortShape, +} from "../Services/WorkflowFileLoader.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { + isSafeWorkflowInstructionPath, + resolveWorkflowInstructionPath, +} from "../instructionPath.ts"; +import { sha256Hex } from "../workflowVersionHash.ts"; +import { lintWorkflowDefinition, type LintContext } from "../workflowFile.ts"; + +const decodeWorkflowDefinition = Schema.decodeUnknownEffect(WorkflowDefinition); +const decodeUnknownJsonString = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); +const decodeProviderInstanceId = Schema.decodeUnknownEffect(ProviderInstanceId); + +const toWorkflowRpcError = (message: string) => (cause: unknown) => + new WorkflowRpcError({ message, cause }); + +const unique = (values: ReadonlyArray) => Array.from(new Set(values)); + +const make = Effect.gen(function* () { + const files = yield* WorkflowFilePort; + const providers = yield* WorkflowProviderInstancePort; + const boardRegistry = yield* BoardRegistry; + const readModel = yield* WorkflowReadModel; + + const lintContextForDefinition = ( + definition: WorkflowDefinition, + workspaceRoot: string, + ): Effect.Effect => + Effect.gen(function* () { + const agentSteps = definition.lanes.flatMap((lane) => + (lane.pipeline ?? []).flatMap((step) => (step.type === "agent" ? [step] : [])), + ); + const providerEntries = yield* Effect.forEach( + unique( + agentSteps.flatMap((step) => [ + step.agent.instance as string, + ...(step.retry?.escalate?.instance === undefined + ? [] + : [step.retry.escalate.instance as string]), + ]), + ), + (instanceId) => + providers + .providerInstanceExists(instanceId) + .pipe(Effect.map((exists) => [instanceId, exists] as const)), + { concurrency: "unbounded" }, + ); + const instructionEntries = yield* Effect.forEach( + unique( + agentSteps.flatMap((step) => + typeof step.instruction === "object" && + isSafeWorkflowInstructionPath(step.instruction.file as string) + ? [step.instruction.file as string] + : [], + ), + ), + (repoRelativePath) => + files + .instructionFileExists({ repoRoot: workspaceRoot, repoRelativePath }) + .pipe(Effect.map((exists) => [repoRelativePath, exists] as const)), + { concurrency: "unbounded" }, + ); + const providerExists = new Map(providerEntries); + const instructionExists = new Map(instructionEntries); + const instructionContentEntries = yield* Effect.forEach( + instructionEntries.flatMap(([repoRelativePath, exists]) => + exists ? [repoRelativePath] : [], + ), + (repoRelativePath) => { + const instructionPath = resolveWorkflowInstructionPath(workspaceRoot, repoRelativePath); + return instructionPath === null + ? Effect.succeed([repoRelativePath, null] as const) + : files.readFileString(instructionPath).pipe( + Effect.map((content) => [repoRelativePath, content] as const), + Effect.orElseSucceed(() => [repoRelativePath, null] as const), + ); + }, + { concurrency: "unbounded" }, + ); + const instructionContents = new Map(instructionContentEntries); + + return { + providerInstanceExists: (instanceId) => providerExists.get(instanceId) ?? false, + instructionFileExists: (repoRelativePath) => + instructionExists.get(repoRelativePath) ?? false, + readInstructionFile: (repoRelativePath) => + instructionContents.get(repoRelativePath) ?? null, + }; + }); + + const lintDefinition: WorkflowFileLoaderShape["lintDefinition"] = (input) => + Effect.gen(function* () { + const lintContext = yield* lintContextForDefinition(input.definition, input.workspaceRoot); + return lintWorkflowDefinition(input.definition, lintContext); + }); + + const loadAndRegister: WorkflowFileLoaderShape["loadAndRegister"] = (input) => + Effect.gen(function* () { + const raw = yield* files.readFileString( + path.resolve(input.workspaceRoot, input.relativePath), + ); + const encodedDefinition = yield* decodeUnknownJsonString(raw).pipe( + Effect.mapError(toWorkflowRpcError("workflow file decode failed")), + ); + const definition = yield* decodeWorkflowDefinition(encodedDefinition).pipe( + Effect.mapError(toWorkflowRpcError("workflow file decode failed")), + ); + + const lintErrors = yield* lintDefinition({ + definition, + projectId: input.projectId, + workspaceRoot: input.workspaceRoot, + }); + if (lintErrors.length > 0) { + return yield* new WorkflowRpcError({ + message: `Workflow lint failed: ${lintErrors.map((error) => error.code).join(", ")}`, + }); + } + + yield* boardRegistry + .register(input.boardId, encodedDefinition) + .pipe(Effect.mapError(toWorkflowRpcError("workflow board registration failed"))); + yield* readModel + .registerBoard({ + boardId: input.boardId, + projectId: input.projectId, + name: definition.name, + workflowFilePath: input.relativePath, + workflowVersionHash: sha256Hex(raw), + maxConcurrentTickets: definition.settings?.maxConcurrentTickets ?? 3, + }) + .pipe(Effect.mapError(toWorkflowRpcError("workflow board projection registration failed"))); + return input.boardId; + }); + + return { lintDefinition, loadAndRegister } satisfies WorkflowFileLoaderShape; +}); + +export const WorkflowFileLoaderLive = Layer.effect(WorkflowFileLoader, make); + +export const WorkflowFilePortLive = Layer.effect( + WorkflowFilePort, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return { + readFileString: (filePath) => + fileSystem + .readFileString(filePath) + .pipe(Effect.mapError(toWorkflowRpcError("workflow file read failed"))), + instructionFileExists: ({ repoRoot, repoRelativePath }) => + Effect.gen(function* () { + const instructionPath = resolveWorkflowInstructionPath(repoRoot, repoRelativePath); + if (instructionPath === null) { + return false; + } + return yield* fileSystem.exists(instructionPath).pipe( + Effect.map((exists): boolean => exists), + Effect.orElseSucceed(() => false), + ); + }), + } satisfies WorkflowFilePortShape; + }), +); + +export const WorkflowProviderInstancePortLive = Layer.effect( + WorkflowProviderInstancePort, + Effect.gen(function* () { + const registry = yield* ProviderInstanceRegistry; + return { + providerInstanceExists: (instanceId) => + decodeProviderInstanceId(instanceId).pipe( + Effect.flatMap((decoded) => registry.getInstance(decoded)), + Effect.map((instance) => instance !== undefined), + Effect.orElseSucceed(() => false), + ), + } satisfies WorkflowProviderInstancePortShape; + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowIds.test.ts b/apps/server/src/workflow/Layers/WorkflowIds.test.ts new file mode 100644 index 00000000000..9a6df6acb29 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowIds.test.ts @@ -0,0 +1,19 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; + +const layer = it.layer(DeterministicWorkflowIds); + +layer("DeterministicWorkflowIds", (it) => { + it.effect("produces stable, prefixed, incrementing ids", () => + Effect.gen(function* () { + const ids = yield* WorkflowIds; + assert.equal(yield* ids.ticketId(), "ticket-1"); + assert.equal(yield* ids.ticketId(), "ticket-2"); + assert.equal(yield* ids.token(), "token-1"); + assert.equal(yield* ids.stepRunId(), "steprun-1"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowIds.ts b/apps/server/src/workflow/Layers/WorkflowIds.ts new file mode 100644 index 00000000000..5490c366873 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowIds.ts @@ -0,0 +1,59 @@ +import { + LaneEntryToken, + MessageId, + PipelineRunId, + ScriptRunId, + StepRunId, + TicketId, + WorkflowEventId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { WorkflowIds, type WorkflowIdsShape } from "../Services/WorkflowIds.ts"; + +export const DeterministicWorkflowIds = Layer.effect( + WorkflowIds, + Effect.gen(function* () { + const counters = yield* Ref.make>({}); + const next = (prefix: string) => + Ref.modify(counters, (counters) => { + const value = (counters[prefix] ?? 0) + 1; + return [`${prefix}-${value}`, { ...counters, [prefix]: value }] as const; + }); + + return { + ticketId: () => next("ticket").pipe(Effect.map(TicketId.make)), + pipelineRunId: () => next("pipelinerun").pipe(Effect.map(PipelineRunId.make)), + scriptRunId: () => next("scriptrun").pipe(Effect.map(ScriptRunId.make)), + stepRunId: () => next("steprun").pipe(Effect.map(StepRunId.make)), + messageId: () => next("message").pipe(Effect.map(MessageId.make)), + eventId: () => next("evt").pipe(Effect.map(WorkflowEventId.make)), + token: () => next("token").pipe(Effect.map(LaneEntryToken.make)), + } satisfies WorkflowIdsShape; + }), +); + +export const WorkflowIdsLive = Layer.effect( + WorkflowIds, + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const next = (prefix: string) => + crypto.randomUUIDv4.pipe( + Effect.orDie, + Effect.map((uuid) => `${prefix}-${uuid}`), + ); + + return { + ticketId: () => next("ticket").pipe(Effect.map(TicketId.make)), + pipelineRunId: () => next("pipelinerun").pipe(Effect.map(PipelineRunId.make)), + scriptRunId: () => next("scriptrun").pipe(Effect.map(ScriptRunId.make)), + stepRunId: () => next("steprun").pipe(Effect.map(StepRunId.make)), + messageId: () => next("message").pipe(Effect.map(MessageId.make)), + eventId: () => next("evt").pipe(Effect.map(WorkflowEventId.make)), + token: () => next("token").pipe(Effect.map(LaneEntryToken.make)), + } satisfies WorkflowIdsShape; + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowIntake.test.ts b/apps/server/src/workflow/Layers/WorkflowIntake.test.ts new file mode 100644 index 00000000000..8c1189db9d1 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowIntake.test.ts @@ -0,0 +1,188 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { CapturedStepOutputReader } from "../Services/CapturedStepOutputReader.ts"; +import { ProjectWorkspaceResolver } from "../Services/ProjectWorkspaceResolver.ts"; +import { ProviderTurnPort, type DispatchRequest } from "../Services/ProviderDispatchOutbox.ts"; +import { TurnStateReader, type TurnState } from "../Services/TurnStateReader.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowIntakeService } from "../Services/WorkflowIntake.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { parseIntakeProposals, WorkflowIntakeLive } from "./WorkflowIntake.ts"; + +describe("parseIntakeProposals", () => { + it("keeps valid proposals, drops junk, and caps the list", () => { + const proposals = parseIntakeProposals({ + tickets: [ + { title: "Fix login", description: "Users get logged out" }, + { title: " " }, + "not an object", + { title: "No description" }, + ...Array.from({ length: 30 }, (_, index) => ({ title: `extra ${index}` })), + ], + }); + + assert.equal(proposals.length, 20); + assert.deepEqual(proposals[0], { title: "Fix login", description: "Users get logged out" }); + assert.deepEqual(proposals[1], { title: "No description" }); + }); + + it("truncates overlong fields instead of failing", () => { + const proposals = parseIntakeProposals({ + tickets: [{ title: "t".repeat(500), description: "d".repeat(9000) }], + }); + assert.equal(proposals[0]?.title.length, 200); + assert.equal(proposals[0]?.description?.length, 4000); + }); + + it("keeps backward dependency indices and drops self/forward/junk", () => { + const proposals = parseIntakeProposals({ + tickets: [ + { title: "API" }, + { title: "UI", dependsOn: [0] }, + { title: "Docs", dependsOn: [0, 1, 2, 7, -1, "0", 1] }, + { title: "Free", dependsOn: "nope" }, + ], + }); + + assert.equal(proposals[0]?.dependsOn, undefined); + assert.deepEqual(proposals[1]?.dependsOn, [0]); + assert.deepEqual(proposals[2]?.dependsOn, [0, 1]); + assert.equal(proposals[3]?.dependsOn, undefined); + }); + + it("returns nothing for unusable shapes", () => { + assert.deepEqual(parseIntakeProposals(null), []); + assert.deepEqual(parseIntakeProposals({ tickets: "nope" }), []); + assert.deepEqual(parseIntakeProposals([]), []); + }); +}); + +const baseInput = { + boardId: "board-intake" as never, + braindump: "Fix the login flow and add rate limiting", + agent: { instance: "codex" as never, model: "gpt-5.5" as never }, +}; + +const makeLayer = (options: { + readonly turnState: TurnState; + readonly capturedOutput?: unknown; + readonly onStart?: (req: DispatchRequest) => void; +}) => + WorkflowIntakeLive.pipe( + Layer.provide( + Layer.succeed(WorkflowReadModel, { + getBoard: () => + Effect.succeed({ + boardId: "board-intake", + projectId: "project-intake", + name: "Intake board", + workflowFilePath: ".t3/boards/intake.json", + workflowVersionHash: "hash", + maxConcurrentTickets: 1, + }), + } as never), + ), + Layer.provide( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed("/tmp/project-intake"), + }), + ), + Layer.provide( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: (req) => + Effect.sync(() => { + options.onStart?.(req); + return { turnId: "turn-intake" as never }; + }), + }), + ), + Layer.provide( + Layer.succeed(TurnStateReader, { read: () => Effect.succeed(options.turnState) }), + ), + Layer.provide( + Layer.succeed(CapturedStepOutputReader, { + read: () => Effect.succeed(options.capturedOutput), + }), + ), + Layer.provide( + Layer.succeed(WorkflowIds, { + eventId: () => Effect.succeed("evt-intake-1" as never), + ticketId: () => Effect.succeed("ticket-x" as never), + pipelineRunId: () => Effect.succeed("pipeline-x" as never), + stepRunId: () => Effect.succeed("step-x" as never), + laneEntryToken: () => Effect.succeed("token-x" as never), + } as never), + ), + ); + +describe("WorkflowIntakeService", () => { + it.effect("dispatches a one-shot turn and returns parsed proposals", () => { + const starts: DispatchRequest[] = []; + return Effect.gen(function* () { + const intake = yield* WorkflowIntakeService; + const proposals = yield* intake.proposeTickets(baseInput); + + assert.deepEqual(proposals, [ + { title: "Fix login", description: "Restore session persistence" }, + ]); + assert.equal(starts.length, 1); + const request = starts[0]; + assert.equal(request?.worktreePath, "/tmp/project-intake"); + assert.include(request?.instruction, "Fix the login flow and add rate limiting"); + assert.include(request?.instruction, '"tickets"'); + assert.match(String(request?.ticketId), /^intake-/); + }).pipe( + Effect.provide( + makeLayer({ + turnState: { _tag: "completed" }, + capturedOutput: { + tickets: [{ title: "Fix login", description: "Restore session persistence" }], + }, + onStart: (req) => starts.push(req), + }), + ), + ); + }); + + it.effect("fails when the agent asks a question", () => + Effect.gen(function* () { + const intake = yield* WorkflowIntakeService; + const result = yield* intake.proposeTickets(baseInput).pipe(Effect.flip); + assert.include(result.message, "asked a question"); + }).pipe( + Effect.provide( + makeLayer({ + turnState: { + _tag: "awaiting_user", + waitingReason: "Which auth provider?", + providerThreadId: "thread-1" as never, + providerRequestId: "request-1" as never, + providerResponseKind: "user-input", + }, + }), + ), + ), + ); + + it.effect("fails when the turn fails", () => + Effect.gen(function* () { + const intake = yield* WorkflowIntakeService; + const result = yield* intake.proposeTickets(baseInput).pipe(Effect.flip); + assert.include(result.message, "boom"); + }).pipe(Effect.provide(makeLayer({ turnState: { _tag: "failed", error: "boom" } }))), + ); + + it.effect("fails when no usable proposals come back", () => + Effect.gen(function* () { + const intake = yield* WorkflowIntakeService; + const result = yield* intake.proposeTickets(baseInput).pipe(Effect.flip); + assert.include(result.message, "usable ticket proposals"); + }).pipe( + Effect.provide( + makeLayer({ turnState: { _tag: "completed" }, capturedOutput: { tickets: [] } }), + ), + ), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowIntake.ts b/apps/server/src/workflow/Layers/WorkflowIntake.ts new file mode 100644 index 00000000000..33da350d0df --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowIntake.ts @@ -0,0 +1,228 @@ +import type { ProjectId, WorkflowTicketProposal } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { CapturedStepOutputReader } from "../Services/CapturedStepOutputReader.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { ProjectWorkspaceResolver } from "../Services/ProjectWorkspaceResolver.ts"; +import { ProviderTurnPort } from "../Services/ProviderDispatchOutbox.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowIntakeService, type WorkflowIntakeShape } from "../Services/WorkflowIntake.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; + +const INTAKE_TIMEOUT = "3 minutes"; +const MAX_PROPOSALS = 20; +const TITLE_MAX_LENGTH = 200; +const DESCRIPTION_MAX_LENGTH = 4000; + +// Intake runs in approval-required mode, where any tool use (even a read) +// would stall on an approval nobody is there to grant — so the prompt forbids +// tools entirely and works from the braindump text alone. +const intakeInstruction = (braindump: string): string => + [ + "You are an intake assistant for a kanban board on this repository.", + "Break the braindump below into independent, actionable tickets. Each", + "ticket gets a short imperative title and a description with enough", + "context for another engineer (or agent) to pick it up cold. Skip vague", + "asides that are not actionable; merge duplicates.", + "", + "Work ONLY from the braindump text. Do not run commands, read files, or", + "modify anything — answer directly.", + "", + "When the braindump implies ordering (build X, then Y on top of it), add", + '"dependsOn" with the zero-based indices of EARLIER tickets in your list', + "that must land first. Only reference earlier tickets.", + "", + "Braindump:", + "---", + braindump, + "---", + "", + "End your final message with a single fenced ```json block of the form", + '{"tickets": [{"title": "...", "description": "...", "dependsOn": [0]}]}.', + ].join("\n"); + +/** + * Validate the agent's parsed output into bounded proposals. Invalid entries + * are dropped rather than failing the whole intake; overlong fields are + * truncated. Returns an empty array when the shape is unusable. + */ +export const parseIntakeProposals = (output: unknown): ReadonlyArray => { + if (typeof output !== "object" || output === null || Array.isArray(output)) { + return []; + } + const tickets = (output as Record)["tickets"]; + if (!Array.isArray(tickets)) { + return []; + } + const proposals: WorkflowTicketProposal[] = []; + for (const raw of tickets) { + if (proposals.length >= MAX_PROPOSALS) { + break; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + continue; + } + const entry = raw as Record; + const title = typeof entry["title"] === "string" ? entry["title"].trim() : ""; + if (title === "") { + continue; + } + const description = typeof entry["description"] === "string" ? entry["description"].trim() : ""; + // Backward-only index references: anything else (forward, self, junk) is + // dropped rather than failing the proposal. + const index = proposals.length; + const rawDependsOn = entry["dependsOn"]; + const dependsOn = Array.isArray(rawDependsOn) + ? [ + ...new Set( + rawDependsOn.filter( + (value): value is number => + typeof value === "number" && Number.isInteger(value) && value >= 0 && value < index, + ), + ), + ] + : []; + proposals.push({ + title: title.slice(0, TITLE_MAX_LENGTH) as never, + ...(description === "" ? {} : { description: description.slice(0, DESCRIPTION_MAX_LENGTH) }), + ...(dependsOn.length === 0 ? {} : { dependsOn }), + }); + } + return proposals; +}; + +const intakeError = (message: string) => new WorkflowEventStoreError({ message }); + +const make = Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const workspaces = yield* ProjectWorkspaceResolver; + const turnPort = yield* ProviderTurnPort; + const turnState = yield* TurnStateReader; + const capturedOutputs = yield* CapturedStepOutputReader; + const ids = yield* WorkflowIds; + const providerService = yield* Effect.serviceOption(ProviderService); + const orchestration = yield* Effect.serviceOption(OrchestrationEngineService); + + const cleanupSession = (threadId: string, turnId: unknown) => + Option.match(providerService, { + onNone: () => Effect.void, + onSome: (provider) => + provider.interruptTurn({ threadId: threadId as never, turnId: turnId as never }).pipe( + Effect.catch(() => Effect.void), + Effect.andThen( + provider + .stopSession({ threadId: threadId as never }) + .pipe(Effect.catch(() => Effect.void)), + ), + ), + }).pipe( + // Intake threads are one-shot scratch space — delete them once the + // proposals (or the failure) have been extracted so they never + // accumulate as orphaned hidden threads. + Effect.andThen( + Option.match(orchestration, { + onNone: () => Effect.void, + onSome: (engine) => + engine + .dispatch({ + type: "thread.delete", + commandId: `workflow-intake-delete-${threadId}` as never, + threadId: threadId as never, + }) + .pipe( + Effect.catch(() => Effect.void), + Effect.asVoid, + ), + }), + ), + ); + + const proposeTickets: WorkflowIntakeShape["proposeTickets"] = (input) => + Effect.gen(function* () { + const board = yield* read.getBoard(input.boardId); + if (board === null) { + return yield* intakeError(`Workflow board ${input.boardId} was not found`); + } + const cwd = yield* workspaces + .resolve(board.projectId as ProjectId) + .pipe( + Effect.mapError( + (cause) => + new WorkflowEventStoreError({ message: "intake workspace lookup failed", cause }), + ), + ); + + const threadId = (yield* ids.eventId()) as string; + // Synthetic ids: intake never writes to the dispatch outbox or any + // ticket projection — the live turn port only uses thread/cwd/model. + const syntheticId = `intake-${threadId}`; + const { turnId } = yield* turnPort.ensureTurnStarted({ + dispatchId: syntheticId as never, + ticketId: syntheticId as never, + stepRunId: syntheticId as never, + threadId: threadId as never, + providerInstance: input.agent.instance as string, + model: input.agent.model as string, + instruction: intakeInstruction(input.braindump), + worktreePath: cwd, + ...(input.agent.options === undefined ? {} : { options: input.agent.options }), + projectId: board.projectId, + threadTitle: "Ticket intake", + // Intake runs at the real project root, not a disposable worktree — + // never give an unreviewed braindump write access. A write attempt + // surfaces as awaiting_user, which intake treats as failure. + runtimeMode: "approval-required", + }); + + const readProposals = Effect.gen(function* () { + const awaitTerminal = Effect.gen(function* () { + let state = yield* turnState.read(threadId as never); + while (state._tag === "running") { + yield* Effect.sleep("500 millis"); + state = yield* turnState.read(threadId as never); + } + return state; + }); + const state = yield* awaitTerminal.pipe( + Effect.timeoutOption(INTAKE_TIMEOUT), + Effect.flatMap( + Option.match({ + onNone: () => intakeError("the intake agent did not finish in time"), + onSome: Effect.succeed, + }), + ), + ); + if (state._tag === "awaiting_user") { + return yield* intakeError( + "the intake agent asked a question or requested write access — refine the braindump and retry", + ); + } + if (state._tag === "failed") { + return yield* intakeError(`intake agent turn failed: ${state.error}`); + } + + const output = yield* capturedOutputs.read({ + stepRunId: syntheticId as never, + threadId: threadId as never, + turnId, + }); + const proposals = parseIntakeProposals(output); + if (proposals.length === 0) { + return yield* intakeError("the intake agent did not produce any usable ticket proposals"); + } + return proposals; + }); + // One-shot turn: whatever happens, never leave the provider session + // (or a dangling question) running once intake returns. + return yield* readProposals.pipe(Effect.ensuring(cleanupSession(threadId, turnId))); + }); + + return { proposeTickets } satisfies WorkflowIntakeShape; +}); + +export const WorkflowIntakeLive = Layer.effect(WorkflowIntakeService, make); diff --git a/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts new file mode 100644 index 00000000000..c0e7f4cc1e2 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts @@ -0,0 +1,609 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { WorkflowProjectionPipelineLive } from "./WorkflowProjectionPipeline.ts"; + +const layer = it.layer( + WorkflowProjectionPipelineLive.pipe( + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowProjectionPipeline", (it) => { + it.effect("projects TicketCreated then TicketMovedToLane into projection_ticket", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + + yield* pipeline.projectEvent({ + type: "TicketCreated", + eventId: "e1" as never, + ticketId: "t-1" as never, + streamVersion: 0, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-1" as never, + title: "Export CSV" as never, + laneKey: "backlog" as never, + }, + }); + yield* pipeline.projectEvent({ + type: "TicketMovedToLane", + eventId: "e2" as never, + ticketId: "t-1" as never, + streamVersion: 1, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "implement" as never, + laneEntryToken: "tok-1" as never, + reason: "routed", + }, + }); + + const rows = yield* sql<{ + readonly currentLaneEntryToken: string | null; + readonly currentLaneKey: string; + readonly status: string; + }>` + SELECT + current_lane_entry_token AS "currentLaneEntryToken", + current_lane_key AS "currentLaneKey", + status + FROM projection_ticket + WHERE ticket_id = 't-1' + `; + assert.equal(rows[0]?.currentLaneEntryToken, "tok-1"); + assert.equal(rows[0]?.currentLaneKey, "implement"); + assert.equal(rows[0]?.status, "idle"); + }), + ); + + it.effect("projects ticket descriptions, edits, and ticket messages", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + + yield* pipeline.projectEvent({ + type: "TicketCreated", + eventId: "ticket-collab-a" as never, + ticketId: "ticket-collab" as never, + streamVersion: 0, + occurredAt: "2026-06-08T00:00:00.000Z" as never, + payload: { + boardId: "board-collab" as never, + title: "Original title" as never, + description: "Original description", + laneKey: "backlog" as never, + }, + }); + yield* pipeline.projectEvent({ + type: "TicketEdited", + eventId: "ticket-collab-b" as never, + ticketId: "ticket-collab" as never, + streamVersion: 1, + occurredAt: "2026-06-08T00:00:01.000Z" as never, + payload: { + title: "Updated title" as never, + description: "", + }, + }); + yield* pipeline.projectEvent({ + type: "TicketMessagePosted", + eventId: "ticket-collab-c" as never, + ticketId: "ticket-collab" as never, + streamVersion: 2, + occurredAt: "2026-06-08T00:00:02.000Z" as never, + payload: { + messageId: "message-collab" as never, + stepRunId: "step-collab" as never, + author: "user", + body: "Use the sandbox endpoint.", + attachments: [ + { + kind: "image", + id: "image-collab", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 1200, + dataUrl: "data:image/png;base64,AAAA", + }, + ], + createdAt: "2026-06-08T00:00:02.000Z" as never, + }, + }); + + const tickets = yield* sql<{ + readonly title: string; + readonly description: string | null; + }>` + SELECT title, description + FROM projection_ticket + WHERE ticket_id = 'ticket-collab' + `; + const messages = yield* sql<{ + readonly messageId: string; + readonly stepRunId: string | null; + readonly author: string; + readonly body: string; + readonly attachmentsJson: string; + }>` + SELECT + message_id AS "messageId", + step_run_id AS "stepRunId", + author, + body, + attachments_json AS "attachmentsJson" + FROM projection_ticket_message + WHERE ticket_id = 'ticket-collab' + `; + + assert.equal(tickets[0]?.title, "Updated title"); + assert.equal(tickets[0]?.description, ""); + assert.equal(messages[0]?.messageId, "message-collab"); + assert.equal(messages[0]?.stepRunId, "step-collab"); + assert.equal(messages[0]?.author, "user"); + assert.equal(messages[0]?.body, "Use the sandbox endpoint."); + assert.include(messages[0]?.attachmentsJson ?? "", "data:image/png;base64,AAAA"); + }), + ); + + it.effect("records terminal_at when a ticket enters a terminal lane without later bumps", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + + yield* registry.register("board-terminal-clock" as never, { + name: "terminal clock", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* pipeline.projectEvent({ + type: "TicketCreated", + eventId: "terminal-clock-a" as never, + ticketId: "ticket-terminal-clock" as never, + streamVersion: 0, + occurredAt: "2026-06-08T00:00:00.000Z" as never, + payload: { + boardId: "board-terminal-clock" as never, + title: "Ship cleanup" as never, + laneKey: "backlog" as never, + }, + }); + yield* pipeline.projectEvent({ + type: "TicketMovedToLane", + eventId: "terminal-clock-b" as never, + ticketId: "ticket-terminal-clock" as never, + streamVersion: 1, + occurredAt: "2026-06-08T00:00:01.000Z" as never, + payload: { + toLane: "done" as never, + laneEntryToken: "tok-terminal-clock" as never, + reason: "manual", + }, + }); + yield* pipeline.projectEvent({ + type: "TicketEdited", + eventId: "terminal-clock-c" as never, + ticketId: "ticket-terminal-clock" as never, + streamVersion: 2, + occurredAt: "2026-06-08T00:00:02.000Z" as never, + payload: { title: "Ship cleanup after comment" as never }, + }); + yield* pipeline.projectEvent({ + type: "TicketMessagePosted", + eventId: "terminal-clock-d" as never, + ticketId: "ticket-terminal-clock" as never, + streamVersion: 3, + occurredAt: "2026-06-08T00:00:03.000Z" as never, + payload: { + messageId: "message-terminal-clock" as never, + author: "user", + body: "Post-terminal note.", + attachments: [], + createdAt: "2026-06-08T00:00:03.000Z" as never, + }, + }); + + const rows = yield* sql<{ + readonly terminalAt: string | null; + readonly updatedAt: string; + }>` + SELECT + terminal_at AS "terminalAt", + updated_at AS "updatedAt" + FROM projection_ticket + WHERE ticket_id = 'ticket-terminal-clock' + `; + + assert.equal(rows[0]?.terminalAt, "2026-06-08T00:00:01.000Z"); + assert.equal(rows[0]?.updatedAt, "2026-06-08T00:00:02.000Z"); + }), + ); + + it.effect("projects queued and admitted ticket lane-entry state", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-queue" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "queue-a" as never, + streamVersion: 0, + payload: { + boardId: "b-queue" as never, + title: "Queued ticket" as never, + laneKey: "backlog" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketQueued", + eventId: "queue-b" as never, + streamVersion: 1, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { lane: "implement" as never }, + } as never); + + const queued = yield* sql<{ + readonly currentLaneEntryToken: string | null; + readonly currentLaneKey: string; + readonly queuedAt: string | null; + readonly status: string; + }>` + SELECT + current_lane_entry_token AS "currentLaneEntryToken", + current_lane_key AS "currentLaneKey", + queued_at AS "queuedAt", + status + FROM projection_ticket + WHERE ticket_id = 't-queue' + `; + assert.equal(queued[0]?.currentLaneEntryToken, null); + assert.equal(queued[0]?.currentLaneKey, "implement"); + assert.equal(queued[0]?.queuedAt, "2026-06-07T00:00:01.000Z"); + assert.equal(queued[0]?.status, "queued"); + + yield* pipeline.projectEvent({ + ...base, + type: "TicketAdmitted", + eventId: "queue-c" as never, + streamVersion: 2, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + lane: "implement" as never, + laneEntryToken: "tok-admitted" as never, + }, + } as never); + + const admitted = yield* sql<{ + readonly currentLaneEntryToken: string | null; + readonly queuedAt: string | null; + readonly status: string; + }>` + SELECT + current_lane_entry_token AS "currentLaneEntryToken", + queued_at AS "queuedAt", + status + FROM projection_ticket + WHERE ticket_id = 't-queue' + `; + assert.equal(admitted[0]?.currentLaneEntryToken, "tok-admitted"); + assert.equal(admitted[0]?.queuedAt, null); + assert.equal(admitted[0]?.status, "idle"); + + yield* pipeline.projectEvent({ + ...base, + type: "TicketQueued", + eventId: "queue-d" as never, + streamVersion: 3, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { lane: "review" as never }, + } as never); + yield* pipeline.projectEvent({ + ...base, + type: "TicketMovedToLane", + eventId: "queue-e" as never, + streamVersion: 4, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + toLane: "done" as never, + laneEntryToken: "tok-moved" as never, + reason: "manual", + }, + }); + + const moved = yield* sql<{ + readonly currentLaneEntryToken: string | null; + readonly currentLaneKey: string; + readonly queuedAt: string | null; + readonly status: string; + }>` + SELECT + current_lane_entry_token AS "currentLaneEntryToken", + current_lane_key AS "currentLaneKey", + queued_at AS "queuedAt", + status + FROM projection_ticket + WHERE ticket_id = 't-queue' + `; + assert.equal(moved[0]?.currentLaneEntryToken, "tok-moved"); + assert.equal(moved[0]?.currentLaneKey, "done"); + assert.equal(moved[0]?.queuedAt, null); + assert.equal(moved[0]?.status, "idle"); + }), + ); + + it.effect("projects step lifecycle and waiting_on_user status", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { ticketId: "t-2" as never, occurredAt: "2026-06-07T00:00:00.000Z" as never }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "a" as never, + streamVersion: 0, + payload: { boardId: "b-1" as never, title: "Y" as never, laneKey: "implement" as never }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-1" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-1" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-1" as never, + stepRunId: "sr-1" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepAwaitingUser", + eventId: "d" as never, + streamVersion: 3, + payload: { stepRunId: "sr-1" as never, waitingReason: "which API?" }, + }); + + const ticket = yield* sql<{ readonly status: string }>` + SELECT status FROM projection_ticket WHERE ticket_id = 't-2' + `; + const step = yield* sql<{ + readonly status: string; + readonly waitingReason: string; + readonly providerResponseKind: string | null; + }>` + SELECT + status, + waiting_reason AS "waitingReason", + provider_response_kind AS "providerResponseKind" + FROM projection_step_run + WHERE step_run_id = 'sr-1' + `; + assert.equal(ticket[0]?.status, "waiting_on_user"); + assert.equal(step[0]?.status, "awaiting_user"); + assert.equal(step[0]?.waitingReason, "which API?"); + assert.equal(step[0]?.providerResponseKind, null); + + yield* pipeline.projectEvent({ + ...base, + type: "StepUserResolved", + eventId: "e" as never, + streamVersion: 4, + payload: { stepRunId: "sr-1" as never }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepAwaitingUser", + eventId: "f" as never, + streamVersion: 5, + payload: { + stepRunId: "sr-1" as never, + waitingReason: "approve command?", + providerResponseKind: "request", + }, + }); + + const requestStep = yield* sql<{ readonly providerResponseKind: string | null }>` + SELECT provider_response_kind AS "providerResponseKind" + FROM projection_step_run + WHERE step_run_id = 'sr-1' + `; + assert.equal(requestStep[0]?.providerResponseKind, "request"); + }), + ); + + it.effect("projects a blocked step as terminal with its blocked reason", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-blocked" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "blocked-a" as never, + streamVersion: 0, + payload: { + boardId: "b-1" as never, + title: "Blocked" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "blocked-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-blocked" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-blocked" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "blocked-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-blocked" as never, + stepRunId: "sr-blocked" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepBlocked", + eventId: "blocked-d" as never, + streamVersion: 3, + payload: { + stepRunId: "sr-blocked" as never, + reason: "Project not trusted to run scripts", + }, + } as never); + + const rows = yield* sql<{ + readonly blockedReason: string | null; + readonly finishedAt: string | null; + readonly status: string; + }>` + SELECT + status, + error AS "blockedReason", + finished_at AS "finishedAt" + FROM projection_step_run + WHERE step_run_id = 'sr-blocked' + `; + assert.equal(rows[0]?.status, "blocked"); + assert.equal(rows[0]?.blockedReason, "Project not trusted to run scripts"); + assert.isNotNull(rows[0]?.finishedAt ?? null); + }), + ); + + it.effect("projects script step start and exit into workflow_script_run", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-script-projection" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "script-projection-a" as never, + streamVersion: 0, + payload: { + boardId: "b-script" as never, + title: "Script projection" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "script-projection-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-script-projection" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-script-projection" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "script-projection-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-script-projection" as never, + stepRunId: "sr-script-projection" as never, + stepKey: "tests" as never, + stepType: "script", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "ScriptStepStarted", + eventId: "script-projection-d" as never, + streamVersion: 3, + payload: { + scriptRunId: "script-run-projection" as never, + stepRunId: "sr-script-projection" as never, + scriptThreadId: "workflow-script:script-run-projection" as never, + terminalId: "script-script-run-projection" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "ScriptStepExited", + eventId: "script-projection-e" as never, + streamVersion: 4, + payload: { + scriptRunId: "script-run-projection" as never, + exitCode: 7, + signal: null, + outcome: "exited", + }, + }); + + const rows = yield* sql<{ + readonly exitCode: number | null; + readonly scriptThreadId: string; + readonly signal: number | null; + readonly status: string; + readonly terminalId: string; + }>` + SELECT + script_thread_id AS "scriptThreadId", + terminal_id AS "terminalId", + status, + exit_code AS "exitCode", + signal + FROM workflow_script_run + WHERE script_run_id = 'script-run-projection' + `; + + assert.equal(rows[0]?.scriptThreadId, "workflow-script:script-run-projection"); + assert.equal(rows[0]?.terminalId, "script-script-run-projection"); + assert.equal(rows[0]?.status, "exited"); + assert.equal(rows[0]?.exitCode, 7); + assert.equal(rows[0]?.signal, null); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts new file mode 100644 index 00000000000..4c9307ed830 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts @@ -0,0 +1,465 @@ +import { + TicketAttachment, + type BoardId, + type LaneKey, + type WorkflowEvent, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorkflowProjectionPipeline, + type WorkflowProjectionPipelineShape, +} from "../Services/WorkflowProjectionPipeline.ts"; + +const toProjectionError = (cause: unknown) => + new WorkflowEventStoreError({ message: "projection failed", cause }); + +const encodeOutputJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); +const encodeTicketAttachmentsJson = Schema.encodeUnknownEffect( + Schema.fromJsonString(Schema.Array(TicketAttachment)), +); + +const encodeStepOutput = (output: unknown) => + output === undefined ? Effect.succeed(null) : encodeOutputJson(output); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const getOptionalServices = Effect.context().pipe( + Effect.map((context) => ({ + registry: Context.getOption(context as Context.Context, BoardRegistry), + })), + ); + + const isTerminalLane = (boardId: BoardId, laneKey: LaneKey) => + Effect.gen(function* () { + const { registry } = yield* getOptionalServices; + if (Option.isNone(registry)) { + return false; + } + const lane = yield* registry.value.getLane(boardId, laneKey); + return lane?.terminal === true; + }); + + const terminalAtForBoardLane = (boardId: BoardId, laneKey: LaneKey, occurredAt: string) => + isTerminalLane(boardId, laneKey).pipe( + Effect.map((isTerminal) => (isTerminal ? occurredAt : null)), + ); + + const terminalAtForTicketLane = (ticketId: string, laneKey: LaneKey, occurredAt: string) => + Effect.gen(function* () { + const rows = yield* sql<{ + readonly boardId: BoardId; + readonly currentLaneKey: LaneKey; + readonly terminalAt: string | null; + }>` + SELECT + board_id AS "boardId", + current_lane_key AS "currentLaneKey", + terminal_at AS "terminalAt" + FROM projection_ticket + WHERE ticket_id = ${ticketId} + `; + const row = rows[0]; + if (!row) { + return null; + } + if (!(yield* isTerminalLane(row.boardId, laneKey))) { + return null; + } + return row.currentLaneKey === laneKey && row.terminalAt !== null + ? row.terminalAt + : occurredAt; + }); + + const projectEvent: WorkflowProjectionPipelineShape["projectEvent"] = (event: WorkflowEvent) => + Effect.gen(function* () { + switch (event.type) { + case "TicketCreated": { + const terminalAt = yield* terminalAtForBoardLane( + event.payload.boardId, + event.payload.laneKey, + event.occurredAt, + ); + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + description, + current_lane_key, + status, + terminal_at, + token_budget, + created_at, + updated_at + ) + VALUES ( + ${event.ticketId}, + ${event.payload.boardId}, + ${event.payload.title}, + ${event.payload.description ?? null}, + ${event.payload.laneKey}, + 'idle', + ${terminalAt}, + ${event.payload.tokenBudget ?? null}, + ${event.occurredAt}, + ${event.occurredAt} + ) + ON CONFLICT(ticket_id) DO NOTHING + `; + break; + } + case "TicketMovedToLane": { + const terminalAt = yield* terminalAtForTicketLane( + event.ticketId, + event.payload.toLane, + event.occurredAt, + ); + yield* sql` + UPDATE projection_ticket + SET current_lane_key = ${event.payload.toLane}, + status = 'idle', + current_lane_entry_token = ${event.payload.laneEntryToken}, + queued_at = NULL, + terminal_at = ${terminalAt}, + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "TicketEdited": { + const hasTitle = Object.prototype.hasOwnProperty.call(event.payload, "title"); + const hasDescription = Object.prototype.hasOwnProperty.call(event.payload, "description"); + const hasTokenBudget = Object.prototype.hasOwnProperty.call(event.payload, "tokenBudget"); + yield* sql` + UPDATE projection_ticket + SET title = CASE + WHEN ${hasTitle ? 1 : 0} = 1 THEN ${event.payload.title ?? ""} + ELSE title + END, + description = CASE + WHEN ${hasDescription ? 1 : 0} = 1 THEN ${event.payload.description ?? ""} + ELSE description + END, + token_budget = CASE + WHEN ${hasTokenBudget ? 1 : 0} = 1 THEN ${event.payload.tokenBudget ?? null} + ELSE token_budget + END, + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "TicketDependenciesSet": { + yield* sql` + DELETE FROM projection_ticket_dependency + WHERE ticket_id = ${event.ticketId} + `; + yield* Effect.forEach( + event.payload.dependsOn, + (dependsOn) => sql` + INSERT INTO projection_ticket_dependency (ticket_id, depends_on_ticket_id) + VALUES (${event.ticketId}, ${dependsOn}) + ON CONFLICT DO NOTHING + `, + { discard: true }, + ); + break; + } + case "TicketMessagePosted": { + const attachmentsJson = yield* encodeTicketAttachmentsJson(event.payload.attachments); + yield* sql` + INSERT INTO projection_ticket_message ( + message_id, + ticket_id, + step_run_id, + author, + body, + attachments_json, + created_at + ) + VALUES ( + ${event.payload.messageId}, + ${event.ticketId}, + ${event.payload.stepRunId ?? null}, + ${event.payload.author}, + ${event.payload.body}, + ${attachmentsJson}, + ${event.payload.createdAt} + ) + ON CONFLICT(message_id) DO UPDATE SET + ticket_id = excluded.ticket_id, + step_run_id = excluded.step_run_id, + author = excluded.author, + body = excluded.body, + attachments_json = excluded.attachments_json, + created_at = excluded.created_at + `; + break; + } + case "TicketQueued": { + yield* sql` + UPDATE projection_ticket + SET current_lane_key = ${event.payload.lane}, + status = 'queued', + current_lane_entry_token = NULL, + queued_at = ${event.occurredAt}, + terminal_at = NULL, + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "TicketAdmitted": { + const terminalAt = yield* terminalAtForTicketLane( + event.ticketId, + event.payload.lane, + event.occurredAt, + ); + yield* sql` + UPDATE projection_ticket + SET current_lane_key = ${event.payload.lane}, + status = 'idle', + current_lane_entry_token = ${event.payload.laneEntryToken}, + queued_at = NULL, + terminal_at = ${terminalAt}, + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "TicketRouted": { + const terminalAt = yield* terminalAtForTicketLane( + event.ticketId, + event.payload.toLane, + event.occurredAt, + ); + yield* sql` + UPDATE projection_ticket + SET current_lane_key = ${event.payload.toLane}, + terminal_at = ${terminalAt}, + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "TicketBlocked": { + yield* sql` + UPDATE projection_ticket + SET status = 'blocked', + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "PipelineStarted": { + yield* sql` + INSERT INTO projection_pipeline_run ( + pipeline_run_id, + ticket_id, + lane_key, + lane_entry_token, + status, + started_at + ) + VALUES ( + ${event.payload.pipelineRunId}, + ${event.ticketId}, + ${event.payload.laneKey}, + ${event.payload.laneEntryToken}, + 'running', + ${event.occurredAt} + ) + ON CONFLICT(pipeline_run_id) DO NOTHING + `; + yield* sql` + UPDATE projection_ticket + SET status = 'running', + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "PipelineCompleted": { + yield* sql` + UPDATE projection_pipeline_run + SET status = ${event.payload.result}, + finished_at = ${event.occurredAt} + WHERE pipeline_run_id = ${event.payload.pipelineRunId} + `; + break; + } + case "StepStarted": { + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + attempt, + status, + started_at + ) + VALUES ( + ${event.payload.stepRunId}, + ${event.payload.pipelineRunId}, + ${event.ticketId}, + ${event.payload.stepKey}, + ${event.payload.stepType}, + ${event.payload.attempt ?? 1}, + 'running', + ${event.occurredAt} + ) + ON CONFLICT(step_run_id) DO NOTHING + `; + break; + } + case "StepAwaitingUser": { + yield* sql` + UPDATE projection_step_run + SET status = 'awaiting_user', + waiting_reason = ${event.payload.waitingReason}, + provider_response_kind = ${event.payload.providerResponseKind ?? null} + WHERE step_run_id = ${event.payload.stepRunId} + `; + yield* sql` + UPDATE projection_ticket + SET status = 'waiting_on_user', + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "StepUserResolved": { + yield* sql` + UPDATE projection_step_run + SET status = 'running', + waiting_reason = NULL, + provider_response_kind = NULL + WHERE step_run_id = ${event.payload.stepRunId} + `; + yield* sql` + UPDATE projection_ticket + SET status = 'running', + updated_at = ${event.occurredAt} + WHERE ticket_id = ${event.ticketId} + `; + break; + } + case "StepRefsCaptured": { + yield* sql` + UPDATE projection_step_run + SET pre_checkpoint_ref = ${event.payload.preRef}, + post_checkpoint_ref = ${event.payload.postRef} + WHERE step_run_id = ${event.payload.stepRunId} + `; + break; + } + case "StepCompleted": { + const outputJson = yield* encodeStepOutput(event.payload.output); + const usage = event.payload.usage; + yield* sql` + UPDATE projection_step_run + SET status = 'completed', + waiting_reason = NULL, + provider_response_kind = NULL, + output_json = ${outputJson}, + input_tokens = ${usage?.inputTokens ?? null}, + cached_input_tokens = ${usage?.cachedInputTokens ?? null}, + output_tokens = ${usage?.outputTokens ?? null}, + total_tokens = ${usage?.totalTokens ?? null}, + finished_at = ${event.occurredAt} + WHERE step_run_id = ${event.payload.stepRunId} + `; + break; + } + case "StepFailed": { + const usage = event.payload.usage; + yield* sql` + UPDATE projection_step_run + SET status = 'failed', + waiting_reason = NULL, + provider_response_kind = NULL, + error = ${event.payload.error}, + retryable = ${event.payload.retryable === undefined ? null : event.payload.retryable ? 1 : 0}, + input_tokens = ${usage?.inputTokens ?? null}, + cached_input_tokens = ${usage?.cachedInputTokens ?? null}, + output_tokens = ${usage?.outputTokens ?? null}, + total_tokens = ${usage?.totalTokens ?? null}, + finished_at = ${event.occurredAt} + WHERE step_run_id = ${event.payload.stepRunId} + `; + break; + } + case "StepBlocked": { + yield* sql` + UPDATE projection_step_run + SET status = 'blocked', + waiting_reason = NULL, + provider_response_kind = NULL, + error = ${event.payload.reason}, + finished_at = ${event.occurredAt} + WHERE step_run_id = ${event.payload.stepRunId} + `; + break; + } + case "ScriptStepStarted": { + yield* sql` + INSERT INTO workflow_script_run ( + script_run_id, + step_run_id, + ticket_id, + script_thread_id, + terminal_id, + status, + started_at + ) + VALUES ( + ${event.payload.scriptRunId}, + ${event.payload.stepRunId}, + ${event.ticketId}, + ${event.payload.scriptThreadId}, + ${event.payload.terminalId}, + 'running', + ${event.occurredAt} + ) + ON CONFLICT(script_run_id) DO UPDATE SET + step_run_id = excluded.step_run_id, + ticket_id = excluded.ticket_id, + script_thread_id = excluded.script_thread_id, + terminal_id = excluded.terminal_id, + status = 'running', + exit_code = NULL, + signal = NULL, + started_at = excluded.started_at, + finished_at = NULL + `; + break; + } + case "ScriptStepExited": { + yield* sql` + UPDATE workflow_script_run + SET status = ${event.payload.outcome}, + exit_code = ${event.payload.exitCode}, + signal = ${event.payload.signal}, + finished_at = ${event.occurredAt} + WHERE script_run_id = ${event.payload.scriptRunId} + `; + break; + } + } + }).pipe(Effect.mapError(toProjectionError), Effect.asVoid); + + return { projectEvent } satisfies WorkflowProjectionPipelineShape; +}); + +export const WorkflowProjectionPipelineLive = Layer.effect(WorkflowProjectionPipeline, make); diff --git a/apps/server/src/workflow/Layers/WorkflowReadModel.test.ts b/apps/server/src/workflow/Layers/WorkflowReadModel.test.ts new file mode 100644 index 00000000000..7262ab8c321 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowReadModel.test.ts @@ -0,0 +1,1271 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { WorkflowProjectionPipelineLive } from "./WorkflowProjectionPipeline.ts"; +import { WorkflowReadModelLive } from "./WorkflowReadModel.ts"; + +const layer = it.layer( + Layer.mergeAll(WorkflowReadModelLive, WorkflowProjectionPipelineLive).pipe( + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowReadModel", (it) => { + it.effect("registers a board and lists its tickets", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + + yield* read.registerBoard({ + boardId: "b-1" as never, + projectId: "p-1" as never, + name: "Delivery", + workflowFilePath: ".t3/boards/delivery.json", + workflowVersionHash: "hash1", + maxConcurrentTickets: 3, + }); + yield* pipeline.projectEvent({ + type: "TicketCreated", + eventId: "e1" as never, + ticketId: "t-1" as never, + streamVersion: 0, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-1" as never, + title: "Export" as never, + description: "Export the current list", + laneKey: "backlog" as never, + }, + }); + + const board = yield* read.getBoard("b-1" as never); + assert.equal(board?.name, "Delivery"); + const tickets = yield* read.listTickets("b-1" as never); + assert.equal(tickets.length, 1); + assert.equal(tickets[0]?.title, "Export"); + assert.equal(tickets[0]?.description, "Export the current list"); + }), + ); + + it.effect("counts token-admitted tickets and returns the oldest queued ticket", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "queue-read-a" as never, + ticketId: "t-admitted" as never, + streamVersion: 0, + payload: { + boardId: "b-queue-read" as never, + title: "Admitted" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketMovedToLane", + eventId: "queue-read-b" as never, + ticketId: "t-admitted" as never, + streamVersion: 1, + payload: { + toLane: "implement" as never, + laneEntryToken: "tok-admitted" as never, + reason: "initial", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "queue-read-c" as never, + ticketId: "t-created-no-token" as never, + streamVersion: 0, + payload: { + boardId: "b-queue-read" as never, + title: "Created but not admitted" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "queue-read-d" as never, + ticketId: "t-queued-newer" as never, + streamVersion: 0, + payload: { + boardId: "b-queue-read" as never, + title: "Queued newer" as never, + laneKey: "backlog" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketQueued", + eventId: "queue-read-e" as never, + ticketId: "t-queued-newer" as never, + streamVersion: 1, + occurredAt: "2026-06-07T00:00:05.000Z" as never, + payload: { lane: "implement" as never }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "queue-read-f" as never, + ticketId: "t-queued-older" as never, + streamVersion: 0, + payload: { + boardId: "b-queue-read" as never, + title: "Queued older" as never, + laneKey: "backlog" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketQueued", + eventId: "queue-read-g" as never, + ticketId: "t-queued-older" as never, + streamVersion: 1, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { lane: "implement" as never }, + }); + + const admittedCount = yield* read.countAdmittedInLane( + "b-queue-read" as never, + "implement" as never, + ); + const oldestQueued = yield* read.oldestQueuedForLane( + "b-queue-read" as never, + "implement" as never, + ); + const tickets = yield* read.listTickets("b-queue-read" as never); + const queuedDetail = yield* read.getTicketDetail("t-queued-older" as never); + + assert.equal(admittedCount, 1); + assert.equal(oldestQueued?.ticketId, "t-queued-older"); + assert.equal(oldestQueued?.queuedAt, "2026-06-07T00:00:04.000Z"); + assert.equal(oldestQueued?.currentLaneEntryToken, null); + assert.equal(tickets.find((ticket) => ticket.ticketId === "t-admitted")?.queuedAt, null); + assert.equal( + tickets.find((ticket) => ticket.ticketId === "t-queued-newer")?.queuedAt, + "2026-06-07T00:00:05.000Z", + ); + assert.equal(queuedDetail?.ticket.queuedAt, "2026-06-07T00:00:04.000Z"); + }), + ); + + it.effect("returns ticket detail with step runs", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { ticketId: "t-9" as never, occurredAt: "2026-06-07T00:00:00.000Z" as never }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "a" as never, + streamVersion: 0, + payload: { + boardId: "b-1" as never, + title: "Z" as never, + description: "Ticket detail context", + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr" as never, + laneKey: "implement" as never, + laneEntryToken: "tok" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr" as never, + stepRunId: "sr" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketMessagePosted", + eventId: "d" as never, + streamVersion: 3, + payload: { + messageId: "message-agent" as never, + stepRunId: "sr" as never, + author: "agent", + body: "Which API should I use?", + attachments: [], + createdAt: "2026-06-07T00:00:01.000Z" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketMessagePosted", + eventId: "e" as never, + streamVersion: 4, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + messageId: "message-user" as never, + stepRunId: "sr" as never, + author: "user", + body: "Use the sandbox endpoint.", + attachments: [ + { + kind: "image", + id: "image-detail", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 1200, + dataUrl: "data:image/png;base64,AAAA", + }, + ], + createdAt: "2026-06-07T00:00:02.000Z" as never, + }, + }); + + const detail = yield* read.getTicketDetail("t-9" as never); + const messages = yield* read.listTicketMessages("t-9" as never); + assert.equal(detail?.ticket.title, "Z"); + assert.equal(detail?.ticket.description, "Ticket detail context"); + assert.equal(detail?.steps.length, 1); + assert.equal(detail?.steps[0]?.stepKey, "code"); + assert.deepEqual( + detail?.messages.map((message) => message.body), + ["Which API should I use?", "Use the sandbox endpoint."], + ); + assert.deepEqual( + messages.map((message) => message.messageId), + ["message-agent", "message-user"], + ); + assert.equal(messages[1]?.attachments[0]?.kind, "image"); + }), + ); + + it.effect( + "skips queued tickets with unresolved dependencies and releases them when resolved", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const insertTicket = (input: { + readonly ticketId: string; + readonly queuedAt: string | null; + readonly terminalAt?: string | null; + }) => sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, + queued_at, terminal_at, created_at, updated_at + ) + VALUES ( + ${input.ticketId}, 'board-deps', ${input.ticketId}, 'work', 'queued', + ${input.queuedAt}, ${input.terminalAt ?? null}, + '2026-06-07T00:00:00.000Z', '2026-06-07T00:00:00.000Z' + ) + `; + yield* insertTicket({ ticketId: "ticket-dep-a", queuedAt: null }); + yield* insertTicket({ ticketId: "ticket-dep-b", queuedAt: "2026-06-07T00:00:01.000Z" }); + yield* insertTicket({ ticketId: "ticket-dep-c", queuedAt: "2026-06-07T00:00:02.000Z" }); + yield* sql` + INSERT INTO projection_ticket_dependency (ticket_id, depends_on_ticket_id) + VALUES ('ticket-dep-b', 'ticket-dep-a') + `; + + // B is older but blocked by A; admission picks C. + const eligible = yield* read.oldestQueuedForLane("board-deps" as never, "work" as never); + assert.equal(eligible?.ticketId, "ticket-dep-c"); + + const tickets = yield* read.listTickets("board-deps" as never); + const blocked = tickets.find((ticket) => ticket.ticketId === "ticket-dep-b"); + assert.deepEqual(blocked?.dependsOn, ["ticket-dep-a"]); + assert.equal(blocked?.unresolvedDependencyCount, 1); + + // Nothing releasable while A is not terminal. + assert.deepEqual(yield* read.listReleasableDependents("ticket-dep-a" as never), []); + + yield* sql` + UPDATE projection_ticket + SET terminal_at = '2026-06-07T00:01:00.000Z' + WHERE ticket_id = 'ticket-dep-a' + `; + + const releasable = yield* read.listReleasableDependents("ticket-dep-a" as never); + assert.deepEqual( + releasable.map((row) => [row.ticketId, row.boardId, row.laneKey]), + [["ticket-dep-b", "board-deps", "work"]], + ); + const nowEligible = yield* read.oldestQueuedForLane("board-deps" as never, "work" as never); + assert.equal(nowEligible?.ticketId, "ticket-dep-b"); + assert.equal(nowEligible?.unresolvedDependencyCount, 0); + + // A dependency on a deleted/unknown ticket never blocks. + yield* sql` + INSERT INTO projection_ticket_dependency (ticket_id, depends_on_ticket_id) + VALUES ('ticket-dep-c', 'ticket-gone') + `; + const stillEligible = yield* read.oldestQueuedForLane( + "board-deps" as never, + "work" as never, + ); + assert.equal(stillEligible?.ticketId, "ticket-dep-b"); + }), + ); + + it.effect("lists a capped ticket discussion newest-last without decoding attachments", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO projection_ticket_message ( + message_id, + ticket_id, + step_run_id, + author, + body, + attachments_json, + created_at + ) + VALUES + ('message-d-1', 'ticket-discussion', NULL, 'user', 'first', '[]', '2026-06-07T00:00:00.000Z'), + ('message-d-2', 'ticket-discussion', NULL, 'agent', 'second', '[{"kind":"image"},{"kind":"image"}]', '2026-06-07T00:01:00.000Z'), + ('message-d-3', 'ticket-discussion', NULL, 'user', 'third', '[]', '2026-06-07T00:02:00.000Z') + `; + + const all = yield* read.listTicketDiscussion("ticket-discussion" as never, 10); + assert.deepEqual( + all.map((row) => [row.author, row.body, row.attachmentCount]), + [ + ["user", "first", 0], + ["agent", "second", 2], + ["user", "third", 0], + ], + ); + assert.equal(all[0]?.createdAt, "2026-06-07T00:00:00.000Z"); + + const capped = yield* read.listTicketDiscussion("ticket-discussion" as never, 2); + assert.deepEqual( + capped.map((row) => row.body), + ["second", "third"], + ); + }), + ); + + it.effect("lists route decisions with snapshot highlights and manual moves", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const insertEvent = (input: { + readonly eventId: string; + readonly streamVersion: number; + readonly eventType: string; + readonly occurredAt: string; + readonly payload: unknown; + }) => sql` + INSERT INTO workflow_events ( + event_id, ticket_id, stream_version, event_type, occurred_at, payload_json + ) + VALUES ( + ${input.eventId}, + 'ticket-route-history', + ${input.streamVersion}, + ${input.eventType}, + ${input.occurredAt}, + ${JSON.stringify(input.payload)} + ) + `; + yield* insertEvent({ + eventId: "event-route-1", + streamVersion: 0, + eventType: "TicketRouteDecided", + occurredAt: "2026-06-07T00:00:01.000Z", + payload: { + pipelineRunId: "pipeline-1", + fromLane: "implement", + toLane: "review", + source: "lane_transition", + matchedTransitionIndex: 1, + contextSnapshot: { + pipeline: { result: "success" }, + lane: { runCount: 2 }, + status: "idle", + steps: { + verdict: { status: "completed", exitCode: 0, output: { verdict: "approve" } }, + }, + }, + }, + }); + // The routed TicketMovedToLane twin of the decision above must NOT + // produce a duplicate history entry. + yield* insertEvent({ + eventId: "event-route-2", + streamVersion: 1, + eventType: "TicketMovedToLane", + occurredAt: "2026-06-07T00:00:01.000Z", + payload: { toLane: "review", laneEntryToken: "token-1", reason: "routed" }, + }); + yield* insertEvent({ + eventId: "event-route-3", + streamVersion: 2, + eventType: "TicketMovedToLane", + occurredAt: "2026-06-07T00:00:02.000Z", + payload: { toLane: "implement", laneEntryToken: "token-2", reason: "manual" }, + }); + // Malformed snapshot degrades to just the lanes instead of failing. + yield* insertEvent({ + eventId: "event-route-4", + streamVersion: 3, + eventType: "TicketRouteDecided", + occurredAt: "2026-06-07T00:00:03.000Z", + payload: { + pipelineRunId: "pipeline-2", + fromLane: "implement", + toLane: "stuck", + source: "lane_on", + contextSnapshot: "not an object", + }, + }); + + const decisions = yield* read.listTicketRouteDecisions("ticket-route-history" as never); + + assert.deepEqual( + decisions.map((row) => [row.source, row.fromLane, row.toLane]), + [ + ["lane_transition", "implement", "review"], + ["manual", null, "implement"], + ["lane_on", "implement", "stuck"], + ], + ); + const first = decisions[0]; + assert.equal(first?.matchedTransitionIndex, 1); + assert.equal(first?.pipelineResult, "success"); + assert.equal(first?.laneRunCount, 2); + assert.deepEqual(first?.steps, { + verdict: { status: "completed", exitCode: 0, verdict: "approve" }, + }); + const malformed = decisions[2]; + assert.equal(malformed?.pipelineResult, null); + assert.equal(malformed?.laneRunCount, null); + assert.equal(malformed?.steps, null); + }), + ); + + it.effect("caps route decisions to the newest events", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + yield* Effect.forEach( + Array.from({ length: 105 }, (_, index) => index), + (index) => sql` + INSERT INTO workflow_events ( + event_id, ticket_id, stream_version, event_type, occurred_at, payload_json + ) + VALUES ( + ${`event-route-cap-${index}`}, + 'ticket-route-cap', + ${index}, + 'TicketMovedToLane', + ${`2026-06-07T00:00:${String(index % 60).padStart(2, "0")}.000Z`}, + ${JSON.stringify({ toLane: `lane-${index}`, laneEntryToken: `token-${index}`, reason: "manual" })} + ) + `, + ); + + const decisions = yield* read.listTicketRouteDecisions("ticket-route-cap" as never); + + assert.equal(decisions.length, 100); + assert.equal(decisions[0]?.toLane, "lane-5"); + assert.equal(decisions.at(-1)?.toLane, "lane-104"); + }), + ); + + it.effect("returns blockedReason for blocked step runs", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { + ticketId: "t-blocked-detail" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "blocked-detail-a" as never, + streamVersion: 0, + payload: { + boardId: "b-1" as never, + title: "Blocked detail" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "blocked-detail-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-blocked-detail" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-blocked-detail" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "blocked-detail-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-blocked-detail" as never, + stepRunId: "sr-blocked-detail" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepBlocked", + eventId: "blocked-detail-d" as never, + streamVersion: 3, + payload: { + stepRunId: "sr-blocked-detail" as never, + reason: "Project not trusted to run scripts", + }, + } as never); + + const detail = yield* read.getTicketDetail("t-blocked-detail" as never); + assert.equal(detail?.steps[0]?.status, "blocked"); + assert.equal(detail?.steps[0]?.blockedReason, "Project not trusted to run scripts"); + assert.equal(detail?.steps[0]?.waitingReason, null); + }), + ); + + it.effect("returns script terminal metadata in ticket detail", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { + ticketId: "t-script-detail" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "script-detail-a" as never, + streamVersion: 0, + payload: { + boardId: "b-1" as never, + title: "Script detail" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "script-detail-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-script-detail" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-script-detail" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "script-detail-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-script-detail" as never, + stepRunId: "sr-script-detail" as never, + stepKey: "tests" as never, + stepType: "script", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "ScriptStepStarted", + eventId: "script-detail-d" as never, + streamVersion: 3, + payload: { + scriptRunId: "script-run-detail" as never, + stepRunId: "sr-script-detail" as never, + scriptThreadId: "workflow-script:script-run-detail" as never, + terminalId: "script-script-run-detail" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "ScriptStepExited", + eventId: "script-detail-e" as never, + streamVersion: 4, + payload: { + scriptRunId: "script-run-detail" as never, + exitCode: 0, + signal: null, + outcome: "exited", + }, + }); + + const detail = yield* read.getTicketDetail("t-script-detail" as never); + const step = detail?.steps[0] as any; + + assert.equal(step?.scriptThreadId, "workflow-script:script-run-detail"); + assert.equal(step?.terminalId, "script-script-run-detail"); + assert.equal(step?.scriptStatus, "exited"); + assert.equal(step?.exitCode, 0); + assert.equal(step?.signal, null); + }), + ); + + it.effect("returns completed step output in ticket detail", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { + ticketId: "t-output-detail" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "output-detail-a" as never, + streamVersion: 0, + payload: { + boardId: "b-1" as never, + title: "Output detail" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "output-detail-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-output-detail" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-output-detail" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "output-detail-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-output-detail" as never, + stepRunId: "sr-output-detail" as never, + stepKey: "review" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepCompleted", + eventId: "output-detail-d" as never, + streamVersion: 3, + payload: { + stepRunId: "sr-output-detail" as never, + output: { verdict: "pass", score: 0.98 }, + }, + } as never); + + const detail = yield* read.getTicketDetail("t-output-detail" as never); + assert.deepEqual((detail?.steps[0] as any)?.output, { verdict: "pass", score: 0.98 }); + }), + ); + + it.effect("lists step runs scoped to one pipeline run with script exit codes and output", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { + ticketId: "t-pipeline-steps" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "pipeline-steps-a" as never, + streamVersion: 0, + payload: { + boardId: "b-1" as never, + title: "Pipeline scoped steps" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "pipeline-steps-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-target" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-target" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "pipeline-steps-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-target" as never, + stepRunId: "sr-tests" as never, + stepKey: "tests" as never, + stepType: "script", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "ScriptStepStarted", + eventId: "pipeline-steps-d" as never, + streamVersion: 3, + payload: { + scriptRunId: "script-run-target" as never, + stepRunId: "sr-tests" as never, + scriptThreadId: "workflow-script:script-run-target" as never, + terminalId: "script-target" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "ScriptStepExited", + eventId: "pipeline-steps-e" as never, + streamVersion: 4, + payload: { + scriptRunId: "script-run-target" as never, + exitCode: 2, + signal: null, + outcome: "exited", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepCompleted", + eventId: "pipeline-steps-f" as never, + streamVersion: 5, + payload: { stepRunId: "sr-tests" as never }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "pipeline-steps-g" as never, + streamVersion: 6, + payload: { + pipelineRunId: "pr-target" as never, + stepRunId: "sr-review" as never, + stepKey: "review" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepCompleted", + eventId: "pipeline-steps-h" as never, + streamVersion: 7, + payload: { + stepRunId: "sr-review" as never, + output: { verdict: "needs_attention" }, + }, + } as never); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "pipeline-steps-i" as never, + streamVersion: 8, + payload: { + pipelineRunId: "pr-other" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-other" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "pipeline-steps-j" as never, + streamVersion: 9, + payload: { + pipelineRunId: "pr-other" as never, + stepRunId: "sr-other" as never, + stepKey: "other" as never, + stepType: "agent", + }, + }); + + const rows = yield* read.listStepRunsForPipeline("pr-target" as never); + + assert.deepEqual(rows, [ + { + stepKey: "tests", + stepType: "script", + status: "completed", + exitCode: 2, + output: null, + }, + { + stepKey: "review", + stepType: "agent", + status: "completed", + exitCode: null, + output: { verdict: "needs_attention" }, + }, + ]); + }), + ); + + it.effect("returns provider response kind in ticket step detail", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { + ticketId: "t-provider-kind-detail" as never, + occurredAt: "2026-06-08T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "provider-kind-detail-a" as never, + streamVersion: 0, + payload: { + boardId: "b-provider-kind-detail" as never, + title: "Provider kind detail" as never, + laneKey: "review" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "provider-kind-detail-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-provider-kind-detail" as never, + stepRunId: "sr-provider-kind-detail" as never, + stepKey: "review" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepAwaitingUser", + eventId: "provider-kind-detail-c" as never, + streamVersion: 2, + payload: { + stepRunId: "sr-provider-kind-detail" as never, + waitingReason: "Approve this command?", + providerResponseKind: "request", + }, + }); + + const detail = yield* read.getTicketDetail("t-provider-kind-detail" as never); + assert.equal((detail?.steps[0] as any)?.providerResponseKind, "request"); + }), + ); + + it.effect("lists boards for a project and deletes one", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + yield* read.registerBoard({ + boardId: "p1__a" as never, + projectId: "p1" as never, + name: "A", + workflowFilePath: ".t3/boards/a.json", + workflowVersionHash: "h", + maxConcurrentTickets: 3, + }); + + const before = yield* read.listBoardsForProject("p1" as never); + assert.equal(before.length, 1); + assert.equal(before[0]?.filePath, ".t3/boards/a.json"); + + yield* read.deleteBoard("p1__a" as never); + assert.deepEqual(yield* read.listBoardsForProject("p1" as never), []); + }), + ); + + it.effect("deletes ticket-scoped projections for a board without deleting other boards", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES + ('ticket-cascade', 'board-cascade', 'Cascade', 'backlog', 'idle', ${now}, ${now}), + ('ticket-keep', 'board-keep', 'Keep', 'backlog', 'idle', ${now}, ${now}) + `; + yield* sql` + INSERT INTO projection_pipeline_run ( + pipeline_run_id, + ticket_id, + lane_key, + lane_entry_token, + status, + started_at + ) + VALUES + ('pipeline-cascade', 'ticket-cascade', 'backlog', 'token-cascade', 'running', ${now}), + ('pipeline-keep', 'ticket-keep', 'backlog', 'token-keep', 'running', ${now}) + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + status, + started_at + ) + VALUES + ('step-cascade', 'pipeline-cascade', 'ticket-cascade', 'build', 'script', 'running', ${now}), + ('step-keep', 'pipeline-keep', 'ticket-keep', 'build', 'script', 'running', ${now}) + `; + yield* sql` + INSERT INTO workflow_script_run ( + script_run_id, + step_run_id, + ticket_id, + script_thread_id, + terminal_id, + status, + started_at + ) + VALUES + ('script-cascade', 'step-cascade', 'ticket-cascade', 'thread-cascade', 'terminal-cascade', 'running', ${now}), + ('script-keep', 'step-keep', 'ticket-keep', 'thread-keep', 'terminal-keep', 'running', ${now}) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES + ('dispatch-cascade', 'ticket-cascade', 'step-cascade', 'thread-cascade', 'codex', 'gpt-5.5', 'Do cascade', '/tmp/cascade', 'pending', ${now}), + ('dispatch-keep', 'ticket-keep', 'step-keep', 'thread-keep', 'codex', 'gpt-5.5', 'Keep going', '/tmp/keep', 'pending', ${now}) + `; + yield* sql` + INSERT INTO workflow_setup_run ( + setup_run_id, + ticket_id, + worktree_ref, + status, + started_at + ) + VALUES + ('setup-cascade', 'ticket-cascade', 'worktree-cascade', 'running', ${now}), + ('setup-keep', 'ticket-keep', 'worktree-keep', 'running', ${now}) + `; + yield* sql` + INSERT INTO projection_ticket_message ( + message_id, + ticket_id, + step_run_id, + author, + body, + attachments_json, + created_at + ) + VALUES + ('message-cascade', 'ticket-cascade', 'step-cascade', 'user', 'Delete me', '[]', ${now}), + ('message-keep', 'ticket-keep', 'step-keep', 'user', 'Keep me', '[]', ${now}) + `; + + yield* read.deleteBoardTicketState("board-cascade" as never); + + const remaining = yield* sql<{ readonly tableName: string; readonly count: number }>` + SELECT 'projection_ticket' AS tableName, COUNT(*) AS count + FROM projection_ticket + WHERE ticket_id = 'ticket-cascade' + UNION ALL + SELECT 'projection_pipeline_run' AS tableName, COUNT(*) AS count + FROM projection_pipeline_run + WHERE ticket_id = 'ticket-cascade' + UNION ALL + SELECT 'projection_step_run' AS tableName, COUNT(*) AS count + FROM projection_step_run + WHERE ticket_id = 'ticket-cascade' + UNION ALL + SELECT 'workflow_script_run' AS tableName, COUNT(*) AS count + FROM workflow_script_run + WHERE ticket_id = 'ticket-cascade' + UNION ALL + SELECT 'workflow_dispatch_outbox' AS tableName, COUNT(*) AS count + FROM workflow_dispatch_outbox + WHERE ticket_id = 'ticket-cascade' + UNION ALL + SELECT 'workflow_setup_run' AS tableName, COUNT(*) AS count + FROM workflow_setup_run + WHERE ticket_id = 'ticket-cascade' + UNION ALL + SELECT 'projection_ticket_message' AS tableName, COUNT(*) AS count + FROM projection_ticket_message + WHERE ticket_id = 'ticket-cascade' + `; + assert.deepEqual( + remaining.map((row) => [row.tableName, row.count]), + [ + ["projection_ticket", 0], + ["projection_pipeline_run", 0], + ["projection_step_run", 0], + ["workflow_script_run", 0], + ["workflow_dispatch_outbox", 0], + ["workflow_setup_run", 0], + ["projection_ticket_message", 0], + ], + ); + + const kept = yield* sql<{ readonly tableName: string; readonly count: number }>` + SELECT 'projection_ticket' AS tableName, COUNT(*) AS count + FROM projection_ticket + WHERE ticket_id = 'ticket-keep' + UNION ALL + SELECT 'projection_pipeline_run' AS tableName, COUNT(*) AS count + FROM projection_pipeline_run + WHERE ticket_id = 'ticket-keep' + UNION ALL + SELECT 'projection_step_run' AS tableName, COUNT(*) AS count + FROM projection_step_run + WHERE ticket_id = 'ticket-keep' + UNION ALL + SELECT 'workflow_script_run' AS tableName, COUNT(*) AS count + FROM workflow_script_run + WHERE ticket_id = 'ticket-keep' + UNION ALL + SELECT 'workflow_dispatch_outbox' AS tableName, COUNT(*) AS count + FROM workflow_dispatch_outbox + WHERE ticket_id = 'ticket-keep' + UNION ALL + SELECT 'workflow_setup_run' AS tableName, COUNT(*) AS count + FROM workflow_setup_run + WHERE ticket_id = 'ticket-keep' + UNION ALL + SELECT 'projection_ticket_message' AS tableName, COUNT(*) AS count + FROM projection_ticket_message + WHERE ticket_id = 'ticket-keep' + `; + assert.deepEqual( + kept.map((row) => [row.tableName, row.count]), + [ + ["projection_ticket", 1], + ["projection_pipeline_run", 1], + ["projection_step_run", 1], + ["workflow_script_run", 1], + ["workflow_dispatch_outbox", 1], + ["workflow_setup_run", 1], + ["projection_ticket_message", 1], + ], + ); + }), + ); + + it.effect( + "deletes ticket-scoped projections for one ticket without deleting sibling tickets", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-07T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES + ('ticket-delete-one', 'board-ticket-delete', 'Delete one', 'done', 'done', ${now}, ${now}), + ('ticket-keep-one', 'board-ticket-delete', 'Keep one', 'done', 'done', ${now}, ${now}) + `; + yield* sql` + INSERT INTO projection_pipeline_run ( + pipeline_run_id, + ticket_id, + lane_key, + lane_entry_token, + status, + started_at + ) + VALUES + ('pipeline-delete-one', 'ticket-delete-one', 'done', 'token-delete-one', 'completed', ${now}), + ('pipeline-keep-one', 'ticket-keep-one', 'done', 'token-keep-one', 'completed', ${now}) + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + status, + started_at + ) + VALUES + ('step-delete-one', 'pipeline-delete-one', 'ticket-delete-one', 'cleanup', 'script', 'completed', ${now}), + ('step-keep-one', 'pipeline-keep-one', 'ticket-keep-one', 'cleanup', 'script', 'completed', ${now}) + `; + yield* sql` + INSERT INTO workflow_script_run ( + script_run_id, + step_run_id, + ticket_id, + script_thread_id, + terminal_id, + status, + started_at + ) + VALUES + ('script-delete-one', 'step-delete-one', 'ticket-delete-one', 'thread-delete-one', 'terminal-delete-one', 'completed', ${now}), + ('script-keep-one', 'step-keep-one', 'ticket-keep-one', 'thread-keep-one', 'terminal-keep-one', 'completed', ${now}) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES + ('dispatch-delete-one', 'ticket-delete-one', 'step-delete-one', 'thread-delete-one', 'codex', 'gpt-5.5', 'Delete one', '/tmp/delete-one', 'completed', ${now}), + ('dispatch-keep-one', 'ticket-keep-one', 'step-keep-one', 'thread-keep-one', 'codex', 'gpt-5.5', 'Keep one', '/tmp/keep-one', 'completed', ${now}) + `; + yield* sql` + INSERT INTO workflow_setup_run ( + setup_run_id, + ticket_id, + worktree_ref, + status, + started_at + ) + VALUES + ('setup-delete-one', 'ticket-delete-one', 'worktree-delete-one', 'completed', ${now}), + ('setup-keep-one', 'ticket-keep-one', 'worktree-keep-one', 'completed', ${now}) + `; + yield* sql` + INSERT INTO projection_ticket_message ( + message_id, + ticket_id, + step_run_id, + author, + body, + attachments_json, + created_at + ) + VALUES + ('message-delete-one', 'ticket-delete-one', 'step-delete-one', 'user', 'Delete me', '[]', ${now}), + ('message-keep-one', 'ticket-keep-one', 'step-keep-one', 'user', 'Keep me', '[]', ${now}) + `; + + yield* read.deleteTicketState("ticket-delete-one" as never); + + const counts = yield* sql<{ + readonly tableName: string; + readonly deleted: number; + readonly kept: number; + }>` + SELECT 'projection_ticket' AS tableName, + SUM(CASE WHEN ticket_id = 'ticket-delete-one' THEN 1 ELSE 0 END) AS deleted, + SUM(CASE WHEN ticket_id = 'ticket-keep-one' THEN 1 ELSE 0 END) AS kept + FROM projection_ticket + UNION ALL + SELECT 'projection_pipeline_run' AS tableName, + SUM(CASE WHEN ticket_id = 'ticket-delete-one' THEN 1 ELSE 0 END) AS deleted, + SUM(CASE WHEN ticket_id = 'ticket-keep-one' THEN 1 ELSE 0 END) AS kept + FROM projection_pipeline_run + UNION ALL + SELECT 'projection_step_run' AS tableName, + SUM(CASE WHEN ticket_id = 'ticket-delete-one' THEN 1 ELSE 0 END) AS deleted, + SUM(CASE WHEN ticket_id = 'ticket-keep-one' THEN 1 ELSE 0 END) AS kept + FROM projection_step_run + UNION ALL + SELECT 'workflow_script_run' AS tableName, + SUM(CASE WHEN ticket_id = 'ticket-delete-one' THEN 1 ELSE 0 END) AS deleted, + SUM(CASE WHEN ticket_id = 'ticket-keep-one' THEN 1 ELSE 0 END) AS kept + FROM workflow_script_run + UNION ALL + SELECT 'workflow_dispatch_outbox' AS tableName, + SUM(CASE WHEN ticket_id = 'ticket-delete-one' THEN 1 ELSE 0 END) AS deleted, + SUM(CASE WHEN ticket_id = 'ticket-keep-one' THEN 1 ELSE 0 END) AS kept + FROM workflow_dispatch_outbox + UNION ALL + SELECT 'workflow_setup_run' AS tableName, + SUM(CASE WHEN ticket_id = 'ticket-delete-one' THEN 1 ELSE 0 END) AS deleted, + SUM(CASE WHEN ticket_id = 'ticket-keep-one' THEN 1 ELSE 0 END) AS kept + FROM workflow_setup_run + UNION ALL + SELECT 'projection_ticket_message' AS tableName, + SUM(CASE WHEN ticket_id = 'ticket-delete-one' THEN 1 ELSE 0 END) AS deleted, + SUM(CASE WHEN ticket_id = 'ticket-keep-one' THEN 1 ELSE 0 END) AS kept + FROM projection_ticket_message + `; + + assert.deepEqual( + counts.map((row) => [row.tableName, row.deleted, row.kept]), + [ + ["projection_ticket", 0, 1], + ["projection_pipeline_run", 0, 1], + ["projection_step_run", 0, 1], + ["workflow_script_run", 0, 1], + ["workflow_dispatch_outbox", 0, 1], + ["workflow_setup_run", 0, 1], + ["projection_ticket_message", 0, 1], + ], + ); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowReadModel.ts b/apps/server/src/workflow/Layers/WorkflowReadModel.ts new file mode 100644 index 00000000000..818afa0236b --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowReadModel.ts @@ -0,0 +1,857 @@ +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; +import { TicketAttachment } from "@t3tools/contracts"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorkflowReadModel, + type BoardListRow, + type BoardRow, + type PipelineStepRunRow, + type StepRunRow, + type RouteDecisionStepSnapshot, + type TicketMessageRow, + type TicketRouteDecisionRow, + type TicketRow, + type WorkflowReadModelShape, +} from "../Services/WorkflowReadModel.ts"; + +const toReadModelError = (cause: unknown) => + new WorkflowEventStoreError({ message: "read failed", cause }); + +const wrap = (effect: Effect.Effect) => + effect.pipe(Effect.mapError(toReadModelError)); + +interface StepRunSqlRow extends Omit { + readonly outputJson: string | null; +} + +interface PipelineStepRunSqlRow extends Omit { + readonly outputJson: string | null; +} + +interface TicketMessageSqlRow extends Omit { + readonly attachmentsJson: string; +} + +const decodeOutputJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); +const decodeTicketAttachmentsJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Array(TicketAttachment)), +); + +const parseStepOutput = (outputJson: string | null) => + outputJson === null + ? Effect.succeed(null) + : decodeOutputJson(outputJson).pipe(Effect.mapError(toReadModelError)); + +const toStepRunRow = (row: StepRunSqlRow) => + Effect.gen(function* () { + const { outputJson, ...step } = row; + const output = yield* parseStepOutput(outputJson); + return { ...step, output } satisfies StepRunRow; + }); + +const toPipelineStepRunRow = (row: PipelineStepRunSqlRow) => + Effect.gen(function* () { + const { outputJson, ...step } = row; + const output = yield* parseStepOutput(outputJson); + return { ...step, output } satisfies PipelineStepRunRow; + }); + +const toTicketMessageRow = (row: TicketMessageSqlRow) => + decodeTicketAttachmentsJson(row.attachmentsJson).pipe( + Effect.mapError(toReadModelError), + Effect.map((attachments) => { + const { attachmentsJson: _attachmentsJson, ...message } = row; + return { ...message, attachments } satisfies TicketMessageRow; + }), + ); + +const asRecord = (value: unknown): Record | null => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; + +const ROUTE_SOURCES = ["step_on", "lane_transition", "lane_on", "external_event"] as const; +const PIPELINE_RESULTS = ["success", "failure", "blocked"] as const; + +// Route history is for explaining recent movement, not replaying a ticket's +// whole life — bound the event scan so detail polling stays cheap. +const ROUTE_DECISION_EVENT_CAP = 100; + +// Snapshots can embed arbitrarily large captured outputs; route history only +// ever shows the verdict, so lift that one bounded string and drop the rest. +const ROUTE_VERDICT_MAX_LENGTH = 200; + +const liftVerdict = (output: unknown): string | null => { + const record = asRecord(output); + const verdict = record?.["verdict"]; + return typeof verdict === "string" ? verdict.slice(0, ROUTE_VERDICT_MAX_LENGTH) : null; +}; + +const snapshotSteps = ( + value: unknown, +): Readonly> | null => { + const record = asRecord(value); + if (record === null) { + return null; + } + const steps: Record = {}; + for (const [stepKey, raw] of Object.entries(record)) { + const step = asRecord(raw); + if (step === null || typeof step["status"] !== "string") { + continue; + } + steps[stepKey] = { + status: step["status"], + exitCode: typeof step["exitCode"] === "number" ? step["exitCode"] : null, + verdict: liftVerdict(step["output"]), + }; + } + return Object.keys(steps).length > 0 ? steps : null; +}; + +/** + * Map a routing event to a history row. The contextSnapshot is stored as + * opaque JSON, so highlights are lifted defensively — a missing or malformed + * snapshot degrades to just the lane movement. Returns null for events that + * are not history entries (routed TicketMovedToLane rows duplicate their + * TicketRouteDecided twin; initial placement is not a decision). + */ +const toRouteDecisionRow = ( + eventType: string, + occurredAt: string, + payload: unknown, +): TicketRouteDecisionRow | null => { + const record = asRecord(payload); + if (record === null || typeof record["toLane"] !== "string") { + return null; + } + if (eventType === "TicketMovedToLane") { + // routed/external moves duplicate their TicketRouteDecided twin. + return record["reason"] === "manual" + ? { + occurredAt, + fromLane: null, + toLane: record["toLane"], + source: "manual", + matchedTransitionIndex: null, + eventName: null, + pipelineResult: null, + laneRunCount: null, + steps: null, + } + : null; + } + const source = ROUTE_SOURCES.find((candidate) => candidate === record["source"]); + if (source === undefined) { + return null; + } + const snapshot = asRecord(record["contextSnapshot"]); + const pipeline = asRecord(snapshot?.["pipeline"]); + const lane = asRecord(snapshot?.["lane"]); + const runCount = lane?.["runCount"]; + const eventRecord = asRecord(snapshot?.["event"]); + const eventName = typeof eventRecord?.["name"] === "string" ? eventRecord["name"] : null; + return { + occurredAt, + fromLane: typeof record["fromLane"] === "string" ? record["fromLane"] : null, + toLane: record["toLane"], + source, + matchedTransitionIndex: + typeof record["matchedTransitionIndex"] === "number" + ? record["matchedTransitionIndex"] + : null, + eventName, + pipelineResult: + PIPELINE_RESULTS.find((candidate) => candidate === pipeline?.["result"]) ?? null, + laneRunCount: typeof runCount === "number" && Number.isInteger(runCount) ? runCount : null, + steps: snapshotSteps(snapshot?.["steps"]), + }; +}; + +interface TicketDependencySqlRow extends TicketRow { + readonly dependsOnJson?: string | null; +} + +function withDependencyFields(row: TicketDependencySqlRow): TicketRow; +function withDependencyFields(row: TicketDependencySqlRow | null): TicketRow | null; +function withDependencyFields(row: TicketDependencySqlRow | null): TicketRow | null { + if (row === null) { + return null; + } + const { dependsOnJson, ...ticket } = row; + let dependsOn: ReadonlyArray = []; + if (typeof dependsOnJson === "string" && dependsOnJson.length > 0) { + try { + const parsed: unknown = JSON.parse(dependsOnJson); + if (Array.isArray(parsed)) { + dependsOn = parsed.filter((value): value is string => typeof value === "string"); + } + } catch { + // Malformed aggregate degrades to "no dependencies" rather than failing + // the whole board read. + } + } + return { + ...ticket, + dependsOn, + unresolvedDependencyCount: ticket.unresolvedDependencyCount ?? 0, + }; +} + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const registerBoard: WorkflowReadModelShape["registerBoard"] = (board) => + wrap(sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + ${board.boardId}, + ${board.projectId}, + ${board.name}, + ${board.workflowFilePath}, + ${board.workflowVersionHash}, + ${board.maxConcurrentTickets} + ) + ON CONFLICT(board_id) DO UPDATE SET + project_id = excluded.project_id, + name = excluded.name, + workflow_file_path = excluded.workflow_file_path, + workflow_version_hash = excluded.workflow_version_hash, + max_concurrent_tickets = excluded.max_concurrent_tickets + `).pipe(Effect.asVoid); + + const getBoard: WorkflowReadModelShape["getBoard"] = (boardId) => + wrap(sql` + SELECT + board_id AS "boardId", + project_id AS "projectId", + name, + workflow_file_path AS "workflowFilePath", + workflow_version_hash AS "workflowVersionHash", + max_concurrent_tickets AS "maxConcurrentTickets" + FROM projection_board + WHERE board_id = ${boardId} + `).pipe(Effect.map((rows) => rows[0] ?? null)); + + const deleteBoard: WorkflowReadModelShape["deleteBoard"] = (boardId) => + wrap(sql` + DELETE FROM projection_board + WHERE board_id = ${boardId} + `).pipe(Effect.asVoid); + + const deleteBoardTicketState: WorkflowReadModelShape["deleteBoardTicketState"] = (boardId) => + wrap(sql` + DELETE FROM workflow_dispatch_outbox + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `).pipe( + Effect.andThen( + wrap(sql` + DELETE FROM workflow_setup_run + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM workflow_script_run + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_step_run + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_pipeline_run + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_ticket_message + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_ticket_dependency + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + OR depends_on_ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_ticket + WHERE board_id = ${boardId} + `), + ), + Effect.asVoid, + ); + + const deleteTicketState: WorkflowReadModelShape["deleteTicketState"] = (ticketId) => + wrap(sql` + DELETE FROM workflow_dispatch_outbox + WHERE ticket_id = ${ticketId} + `).pipe( + Effect.andThen( + wrap(sql` + DELETE FROM workflow_setup_run + WHERE ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM workflow_script_run + WHERE ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_step_run + WHERE ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_pipeline_run + WHERE ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_ticket_message + WHERE ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_ticket_dependency + WHERE ticket_id = ${ticketId} + OR depends_on_ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM projection_ticket + WHERE ticket_id = ${ticketId} + `), + ), + Effect.asVoid, + ); + + const listBoardsForProject: WorkflowReadModelShape["listBoardsForProject"] = (projectId) => + wrap(sql` + SELECT + board_id AS "boardId", + name, + workflow_file_path AS "filePath" + FROM projection_board + WHERE project_id = ${projectId} + ORDER BY name COLLATE NOCASE ASC, board_id ASC + `); + + const listTickets: WorkflowReadModelShape["listTickets"] = (boardId) => + wrap(sql` + SELECT + ticket_id AS "ticketId", + board_id AS "boardId", + title, + description, + current_lane_key AS "currentLaneKey", + current_lane_entry_token AS "currentLaneEntryToken", + queued_at AS "queuedAt", + token_budget AS "tokenBudget", + updated_at AS "updatedAt", + ( + SELECT SUM(step.total_tokens) + FROM projection_step_run AS step + WHERE step.ticket_id = projection_ticket.ticket_id + ) AS "totalTokens", + ( + SELECT CAST( + SUM((julianday(step.finished_at) - julianday(step.started_at)) * 86400000.0) + AS INTEGER + ) + FROM projection_step_run AS step + WHERE step.ticket_id = projection_ticket.ticket_id + AND step.started_at IS NOT NULL + AND step.finished_at IS NOT NULL + ) AS "totalDurationMs", + ( + SELECT COUNT(*) + FROM projection_ticket_dependency AS dep + LEFT JOIN projection_ticket AS dep_ticket + ON dep_ticket.ticket_id = dep.depends_on_ticket_id + WHERE dep.ticket_id = projection_ticket.ticket_id + AND dep_ticket.ticket_id IS NOT NULL + AND dep_ticket.terminal_at IS NULL + ) AS "unresolvedDependencyCount", + ( + SELECT json_group_array(dep.depends_on_ticket_id) + FROM projection_ticket_dependency AS dep + WHERE dep.ticket_id = projection_ticket.ticket_id + ) AS "dependsOnJson", + status + FROM projection_ticket + WHERE board_id = ${boardId} + ORDER BY created_at ASC + `).pipe(Effect.map((rows) => rows.map((row) => withDependencyFields(row)))); + + const countAdmittedInLane: WorkflowReadModelShape["countAdmittedInLane"] = (boardId, laneKey) => + wrap(sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM projection_ticket + WHERE board_id = ${boardId} + AND current_lane_key = ${laneKey} + AND current_lane_entry_token IS NOT NULL + `).pipe(Effect.map((rows) => rows[0]?.count ?? 0)); + + const oldestQueuedForLane: WorkflowReadModelShape["oldestQueuedForLane"] = (boardId, laneKey) => + wrap(sql` + SELECT + ticket_id AS "ticketId", + board_id AS "boardId", + title, + description, + current_lane_key AS "currentLaneKey", + current_lane_entry_token AS "currentLaneEntryToken", + queued_at AS "queuedAt", + token_budget AS "tokenBudget", + updated_at AS "updatedAt", + ( + SELECT SUM(step.total_tokens) + FROM projection_step_run AS step + WHERE step.ticket_id = projection_ticket.ticket_id + ) AS "totalTokens", + ( + SELECT CAST( + SUM((julianday(step.finished_at) - julianday(step.started_at)) * 86400000.0) + AS INTEGER + ) + FROM projection_step_run AS step + WHERE step.ticket_id = projection_ticket.ticket_id + AND step.started_at IS NOT NULL + AND step.finished_at IS NOT NULL + ) AS "totalDurationMs", + ( + SELECT COUNT(*) + FROM projection_ticket_dependency AS dep + LEFT JOIN projection_ticket AS dep_ticket + ON dep_ticket.ticket_id = dep.depends_on_ticket_id + WHERE dep.ticket_id = projection_ticket.ticket_id + AND dep_ticket.ticket_id IS NOT NULL + AND dep_ticket.terminal_at IS NULL + ) AS "unresolvedDependencyCount", + ( + SELECT json_group_array(dep.depends_on_ticket_id) + FROM projection_ticket_dependency AS dep + WHERE dep.ticket_id = projection_ticket.ticket_id + ) AS "dependsOnJson", + status + FROM projection_ticket + WHERE board_id = ${boardId} + AND current_lane_key = ${laneKey} + AND queued_at IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM projection_ticket_dependency AS dep + INNER JOIN projection_ticket AS dep_ticket + ON dep_ticket.ticket_id = dep.depends_on_ticket_id + WHERE dep.ticket_id = projection_ticket.ticket_id + AND dep_ticket.terminal_at IS NULL + ) + ORDER BY queued_at ASC, ticket_id ASC + LIMIT 1 + `).pipe(Effect.map((rows) => withDependencyFields(rows[0] ?? null))); + + const getTicketDetail: WorkflowReadModelShape["getTicketDetail"] = (ticketId) => + Effect.gen(function* () { + const ticketRows = yield* wrap(sql` + SELECT + ticket_id AS "ticketId", + board_id AS "boardId", + title, + description, + current_lane_key AS "currentLaneKey", + current_lane_entry_token AS "currentLaneEntryToken", + queued_at AS "queuedAt", + token_budget AS "tokenBudget", + updated_at AS "updatedAt", + ( + SELECT SUM(step.total_tokens) + FROM projection_step_run AS step + WHERE step.ticket_id = projection_ticket.ticket_id + ) AS "totalTokens", + ( + SELECT CAST( + SUM((julianday(step.finished_at) - julianday(step.started_at)) * 86400000.0) + AS INTEGER + ) + FROM projection_step_run AS step + WHERE step.ticket_id = projection_ticket.ticket_id + AND step.started_at IS NOT NULL + AND step.finished_at IS NOT NULL + ) AS "totalDurationMs", + ( + SELECT COUNT(*) + FROM projection_ticket_dependency AS dep + LEFT JOIN projection_ticket AS dep_ticket + ON dep_ticket.ticket_id = dep.depends_on_ticket_id + WHERE dep.ticket_id = projection_ticket.ticket_id + AND dep_ticket.ticket_id IS NOT NULL + AND dep_ticket.terminal_at IS NULL + ) AS "unresolvedDependencyCount", + ( + SELECT json_group_array(dep.depends_on_ticket_id) + FROM projection_ticket_dependency AS dep + WHERE dep.ticket_id = projection_ticket.ticket_id + ) AS "dependsOnJson", + status + FROM projection_ticket + WHERE ticket_id = ${ticketId} + `); + const rawTicket = ticketRows[0]; + if (!rawTicket) { + return null; + } + const ticket = withDependencyFields(rawTicket); + + const stepRows = yield* wrap(sql` + SELECT + step.step_run_id AS "stepRunId", + step.step_key AS "stepKey", + step.step_type AS "stepType", + step.attempt, + step.status, + step.waiting_reason AS "waitingReason", + step.provider_response_kind AS "providerResponseKind", + CASE + WHEN step.status = 'blocked' THEN step.error + ELSE NULL + END AS "blockedReason", + script.script_thread_id AS "scriptThreadId", + script.terminal_id AS "terminalId", + script.status AS "scriptStatus", + script.exit_code AS "exitCode", + script.signal, + step.output_json AS "outputJson", + step.started_at AS "startedAt", + step.finished_at AS "finishedAt", + ( + SELECT outbox.thread_id + FROM workflow_dispatch_outbox AS outbox + WHERE outbox.step_run_id = step.step_run_id + ORDER BY outbox.rowid DESC + LIMIT 1 + ) AS "providerThreadId", + step.input_tokens AS "inputTokens", + step.cached_input_tokens AS "cachedInputTokens", + step.output_tokens AS "outputTokens", + step.total_tokens AS "totalTokens" + FROM projection_step_run AS step + LEFT JOIN workflow_script_run AS script + ON script.step_run_id = step.step_run_id + WHERE step.ticket_id = ${ticketId} + ORDER BY step.started_at ASC, step.rowid ASC + `); + const steps = yield* Effect.forEach(stepRows, toStepRunRow); + const messages = yield* listTicketMessages(ticketId); + return { ticket, steps, messages }; + }); + + const listTicketMessages: WorkflowReadModelShape["listTicketMessages"] = (ticketId) => + Effect.gen(function* () { + const rows = yield* wrap(sql` + SELECT + message_id AS "messageId", + ticket_id AS "ticketId", + step_run_id AS "stepRunId", + author, + body, + attachments_json AS "attachmentsJson", + created_at AS "createdAt" + FROM projection_ticket_message + WHERE ticket_id = ${ticketId} + ORDER BY created_at ASC, message_id ASC + `); + return yield* Effect.forEach(rows, toTicketMessageRow); + }); + + const listTicketDiscussion: WorkflowReadModelShape["listTicketDiscussion"] = (ticketId, limit) => + Effect.gen(function* () { + const rows = yield* wrap(sql<{ + readonly author: "agent" | "user"; + readonly body: string; + readonly createdAt: string; + readonly attachmentCount: number; + }>` + SELECT + author, + body, + created_at AS "createdAt", + json_array_length(attachments_json) AS "attachmentCount" + FROM projection_ticket_message + WHERE ticket_id = ${ticketId} + ORDER BY created_at DESC, message_id DESC + LIMIT ${limit} + `); + return [...rows].toReversed(); + }); + + const listReleasableDependents: WorkflowReadModelShape["listReleasableDependents"] = (ticketId) => + wrap(sql<{ readonly ticketId: string; readonly boardId: string; readonly laneKey: string }>` + SELECT + dependent.ticket_id AS "ticketId", + dependent.board_id AS "boardId", + dependent.current_lane_key AS "laneKey" + FROM projection_ticket_dependency AS dep + INNER JOIN projection_ticket AS dependent + ON dependent.ticket_id = dep.ticket_id + WHERE dep.depends_on_ticket_id = ${ticketId} + AND dependent.queued_at IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM projection_ticket_dependency AS other + INNER JOIN projection_ticket AS other_ticket + ON other_ticket.ticket_id = other.depends_on_ticket_id + WHERE other.ticket_id = dependent.ticket_id + AND other_ticket.terminal_at IS NULL + ) + ORDER BY dependent.queued_at ASC, dependent.ticket_id ASC + `); + + const listDependentTicketIds: WorkflowReadModelShape["listDependentTicketIds"] = (ticketId) => + wrap(sql<{ readonly ticketId: string }>` + SELECT ticket_id AS "ticketId" + FROM projection_ticket_dependency + WHERE depends_on_ticket_id = ${ticketId} + ORDER BY ticket_id ASC + `).pipe(Effect.map((rows) => rows.map((row) => row.ticketId))); + + const getBoardDigest: WorkflowReadModelShape["getBoardDigest"] = (boardId, windowHours) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const nowMs = DateTime.toEpochMillis(now); + const sinceIso = DateTime.formatIso(DateTime.subtract(now, { hours: windowHours })); + const counts = yield* wrap(sql<{ + readonly createdCount: number; + readonly shippedCount: number; + }>` + SELECT + SUM(CASE WHEN created_at >= ${sinceIso} THEN 1 ELSE 0 END) AS "createdCount", + SUM(CASE WHEN terminal_at IS NOT NULL AND terminal_at >= ${sinceIso} THEN 1 ELSE 0 END) AS "shippedCount" + FROM projection_ticket + WHERE board_id = ${boardId} + `); + const usage = yield* wrap(sql<{ + readonly totalTokens: number | null; + readonly totalDurationMs: number | null; + }>` + SELECT + SUM(step.total_tokens) AS "totalTokens", + CAST( + SUM((julianday(step.finished_at) - julianday(step.started_at)) * 86400000.0) + AS INTEGER + ) AS "totalDurationMs" + FROM projection_step_run AS step + INNER JOIN projection_ticket AS ticket ON ticket.ticket_id = step.ticket_id + WHERE ticket.board_id = ${boardId} + AND step.finished_at IS NOT NULL + AND step.finished_at >= ${sinceIso} + `); + const attention = yield* wrap(sql<{ + readonly ticketId: string; + readonly title: string; + readonly status: string; + readonly laneKey: string; + readonly updatedAt: string; + }>` + SELECT + ticket_id AS "ticketId", + title, + status, + current_lane_key AS "laneKey", + updated_at AS "updatedAt" + FROM projection_ticket + WHERE board_id = ${boardId} + AND status IN ('waiting_on_user', 'blocked') + ORDER BY updated_at ASC + LIMIT 20 + `); + return { + windowHours, + createdCount: counts[0]?.createdCount ?? 0, + shippedCount: counts[0]?.shippedCount ?? 0, + totalTokens: usage[0]?.totalTokens ?? 0, + totalDurationMs: usage[0]?.totalDurationMs ?? 0, + needsAttention: attention.map((row) => ({ + ticketId: row.ticketId, + title: row.title, + status: row.status, + laneKey: row.laneKey, + sinceMs: Math.max(0, nowMs - Date.parse(row.updatedAt)), + })), + }; + }); + + const listTicketRouteDecisions: WorkflowReadModelShape["listTicketRouteDecisions"] = (ticketId) => + Effect.gen(function* () { + // Newest events first with a hard cap — looping tickets accumulate + // routing events forever and detail is polled while steps run. + const rows = yield* wrap(sql<{ + readonly eventType: string; + readonly occurredAt: string; + readonly payloadJson: string; + }>` + SELECT "eventType", "occurredAt", "payloadJson" + FROM ( + SELECT + sequence, + event_type AS "eventType", + occurred_at AS "occurredAt", + payload_json AS "payloadJson" + FROM workflow_events + WHERE ticket_id = ${ticketId} + AND event_type IN ('TicketRouteDecided', 'TicketMovedToLane') + ORDER BY sequence DESC + LIMIT ${ROUTE_DECISION_EVENT_CAP} + ) + ORDER BY sequence ASC + `); + const decisions: TicketRouteDecisionRow[] = []; + for (const row of rows) { + const payload = yield* decodeOutputJson(row.payloadJson).pipe( + Effect.mapError(toReadModelError), + ); + const decision = toRouteDecisionRow(row.eventType, row.occurredAt, payload); + if (decision !== null) { + decisions.push(decision); + } + } + return decisions; + }); + + // Counts the CURRENT streak of pipeline runs in the lane, not all-time + // visits: a pipeline run in another lane or a manual move resets the count, + // so a human pulling a ticket back into a looping lane gets a fresh budget. + // Computed over the totally-ordered event log (sequence) so same-instant + // timestamps cannot blur the reset boundary. + const countLanePipelineRuns: WorkflowReadModelShape["countLanePipelineRuns"] = (pipelineRunId) => + wrap(sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_events AS started + INNER JOIN projection_pipeline_run AS current + ON current.pipeline_run_id = ${pipelineRunId} + WHERE started.ticket_id = current.ticket_id + AND started.event_type = 'PipelineStarted' + AND json_extract(started.payload_json, '$.laneKey') = current.lane_key + AND started.sequence > COALESCE( + ( + SELECT MAX(reset.sequence) + FROM workflow_events AS reset + WHERE reset.ticket_id = current.ticket_id + AND ( + ( + reset.event_type = 'TicketMovedToLane' + AND json_extract(reset.payload_json, '$.reason') = 'manual' + ) + OR ( + reset.event_type = 'PipelineStarted' + AND json_extract(reset.payload_json, '$.laneKey') != current.lane_key + ) + ) + ), + 0 + ) + `).pipe(Effect.map((rows) => rows[0]?.count ?? 0)); + + const listStepRunsForPipeline: WorkflowReadModelShape["listStepRunsForPipeline"] = ( + pipelineRunId, + ) => + Effect.gen(function* () { + const stepRows = yield* wrap(sql` + SELECT + step.step_key AS "stepKey", + step.step_type AS "stepType", + step.status, + script.exit_code AS "exitCode", + step.output_json AS "outputJson" + FROM projection_step_run AS step + LEFT JOIN workflow_script_run AS script + ON script.step_run_id = step.step_run_id + WHERE step.pipeline_run_id = ${pipelineRunId} + ORDER BY step.started_at ASC, step.rowid ASC + `); + return yield* Effect.forEach(stepRows, toPipelineStepRunRow); + }); + + return { + registerBoard, + getBoard, + deleteBoard, + deleteBoardTicketState, + deleteTicketState, + listBoardsForProject, + listTickets, + countAdmittedInLane, + oldestQueuedForLane, + getTicketDetail, + countLanePipelineRuns, + listTicketMessages, + listTicketDiscussion, + listTicketRouteDecisions, + listReleasableDependents, + listDependentTicketIds, + getBoardDigest, + listStepRunsForPipeline, + } satisfies WorkflowReadModelShape; +}); + +export const WorkflowReadModelLive = Layer.effect(WorkflowReadModel, make); diff --git a/apps/server/src/workflow/Layers/WorkflowRecovery.test.ts b/apps/server/src/workflow/Layers/WorkflowRecovery.test.ts new file mode 100644 index 00000000000..0e17a1e98bb --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRecovery.test.ts @@ -0,0 +1,2233 @@ +// @effect-diagnostics globalTimers:off +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { WorkflowRpcError } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { ApprovalGateLive } from "./ApprovalGate.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { DurableApprovalResumeLive } from "./DurableApprovalResume.ts"; +import { ProviderDispatchOutboxLive } from "./ProviderDispatchOutbox.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; +import { ProviderTurnPort } from "../Services/ProviderDispatchOutbox.ts"; +import { + ProviderResponsePort, + type ProviderResponseInput, +} from "../Services/ProviderResponsePort.ts"; +import { ProjectWorkspaceResolver } from "../Services/ProjectWorkspaceResolver.ts"; +import { WorkflowEventCommitterLive } from "./WorkflowEventCommitter.ts"; +import { + WorkflowEventCommitter, + type WorkflowEventCommitterShape, +} from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowFoundationLive } from "../WorkflowFoundationLive.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowFileLoader } from "../Services/WorkflowFileLoader.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowRecovery } from "../Services/WorkflowRecovery.ts"; +import { WorktreeLeaseServiceLive } from "./WorktreeLeaseService.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { PredicateEvaluatorLive } from "./PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEngineLayer } from "./WorkflowEngine.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRecoveryLive } from "./WorkflowRecovery.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +const completedRecoveredSteps: Array<{ + readonly stepRunId: string; + readonly result: unknown; + readonly captureTurn?: unknown; +}> = []; +let recoveryEventId = 0; +const loadedRecoveryBoards: string[] = []; +let recoveryStepExecutions = 0; +let delayedPipelineStartRelease: Deferred.Deferred | null = null; +let delayedPipelineStartAttempts = 0; + +const recoveryPreloadFileSystem = FileSystem.layerNoop({ + exists: () => Effect.succeed(true), +}); + +const recoveryPreloadSupport = Layer.mergeAll( + WorkflowFoundationLive, + NodeServices.layer, + recoveryPreloadFileSystem, +); + +const layer = it.layer( + WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.succeed({ turnId: "turn-1" as never }), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(WorkflowFileLoader, { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => Effect.succeed(input.boardId), + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed("/tmp/recovery-project"), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowEngine, { + createTicket: () => Effect.die("unused createTicket"), + editTicket: () => Effect.void, + moveTicket: () => Effect.die("unused moveTicket"), + runLane: () => Effect.die("unused runLane"), + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.die("unused resolveApproval"), + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.die("unused cancelStep"), + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: (stepRunId, result, captureTurn) => + Effect.sync(() => { + completedRecoveredSteps.push({ + stepRunId, + result, + ...(captureTurn === undefined ? {} : { captureTurn }), + }); + }), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowIds, { + ticketId: () => Effect.succeed("ticket-unused" as never), + pipelineRunId: () => Effect.succeed("pipeline-unused" as never), + scriptRunId: () => Effect.succeed("script-unused" as never), + stepRunId: () => Effect.succeed("step-unused" as never), + messageId: () => Effect.succeed("message-unused" as never), + eventId: () => + Effect.sync(() => { + recoveryEventId += 1; + return `evt-recovery-${recoveryEventId}` as never; + }), + token: () => Effect.succeed("token-unused" as never), + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.succeed({ turnId: "turn-1" as never }), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(recoveryPreloadSupport), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const recoveryWipDefinition = { + name: "recovery wip", + lanes: [ + { + key: "queue", + name: "Queue", + entry: "auto", + wipLimit: 1, + pipeline: [ + { + key: "queue-step", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "recover queued", + }, + ], + }, + { + key: "stranded", + name: "Stranded", + entry: "auto", + wipLimit: 1, + pipeline: [ + { + key: "stranded-step", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "recover stranded", + }, + ], + }, + ], +}; +const recoveryDefinitions = new Map(); + +const recoveryWipExecutor = Layer.succeed(StepExecutor, { + execute: () => + Effect.sync(() => { + recoveryStepExecutions += 1; + return { _tag: "failed" as const, error: "recovered pipeline holds its slot" }; + }), +} satisfies StepExecutorShape); + +const recoveryBoardRegistry = Layer.succeed(BoardRegistry, { + register: (boardId, definition) => + Effect.sync(() => { + recoveryDefinitions.set(boardId as string, definition as typeof recoveryWipDefinition); + return definition as never; + }), + unregister: (boardId) => + Effect.sync(() => { + recoveryDefinitions.delete(boardId as string); + }), + getDefinition: (boardId) => + Effect.succeed((recoveryDefinitions.get(boardId as string) ?? null) as never), + listDefinitions: () => + Effect.succeed( + Array.from(recoveryDefinitions.entries(), ([boardId, definition]) => ({ + boardId: boardId as never, + definition: definition as never, + })), + ), + getLane: (boardId, laneKey) => + Effect.succeed( + (recoveryDefinitions.get(boardId as string)?.lanes.find((lane) => lane.key === laneKey) ?? + null) as never, + ), +}); + +const recoveryWipFileLoader = Layer.succeed(WorkflowFileLoader, { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => + Effect.sync(() => { + loadedRecoveryBoards.push(input.boardId as string); + recoveryDefinitions.set(input.boardId as string, recoveryWipDefinition); + return input.boardId; + }), +}); + +const isWorkflowEventStoreError = Schema.is(WorkflowEventStoreError); +const toDelayedCommitterError = (cause: unknown) => + isWorkflowEventStoreError(cause) + ? cause + : new WorkflowEventStoreError({ message: "delayed workflow commit transaction failed", cause }); + +const delayedPipelineStartCommitter = Layer.effect( + WorkflowEventCommitter, + Effect.gen(function* () { + const release = yield* Deferred.make(); + const store = yield* WorkflowEventStore; + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + delayedPipelineStartRelease = release; + delayedPipelineStartAttempts = 0; + + const appendAndProject = (event: Parameters[0]) => + Effect.gen(function* () { + if (event.type === "PipelineStarted") { + delayedPipelineStartAttempts += 1; + yield* Deferred.await(release); + } + const persisted = yield* store.append(event); + yield* pipeline.projectEvent(persisted); + return persisted; + }); + + return { + commit: (event) => appendAndProject(event).pipe(Effect.asVoid), + commitMany: (events) => + sql + .withTransaction(Effect.forEach(events, appendAndProject, { concurrency: 1 })) + .pipe(Effect.mapError(toDelayedCommitterError), Effect.asVoid), + } satisfies WorkflowEventCommitterShape; + }), +); + +const recoveryWipLayer = it.layer( + WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.succeed({ turnId: "turn-1" as never }), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(WorkflowEngineLayer), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(recoveryWipExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(recoveryBoardRegistry), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(recoveryWipFileLoader), + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed("/tmp/recovery-project"), + }), + ), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(recoveryPreloadSupport), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const delayedPipelineStartRecoveryLayer = it.layer( + WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.succeed({ turnId: "turn-1" as never }), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(WorkflowEngineLayer), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(recoveryWipExecutor), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(recoveryBoardRegistry), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(recoveryWipFileLoader), + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed("/tmp/recovery-project"), + }), + ), + Layer.provideMerge(delayedPipelineStartCommitter), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(recoveryPreloadSupport), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const waitForRecoveryCondition = ( + condition: Effect.Effect, + label: string, +): Effect.Effect => + Effect.gen(function* () { + for (let attempt = 0; attempt < 50; attempt += 1) { + if (yield* condition) { + return; + } + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 10))); + yield* Effect.yieldNow; + } + assert.fail(`Timed out waiting for ${label}`); + }); + +const workflowEventCount = (sql: SqlClient.SqlClient, ticketId: string, eventType: string) => + sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_events + WHERE ticket_id = ${ticketId} + AND event_type = ${eventType} + `.pipe(Effect.map((rows) => rows[0]?.count ?? 0)); + +const pipelineStartsForToken = ( + sql: SqlClient.SqlClient, + ticketId: string, + laneEntryToken: string, +) => + sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_events + WHERE ticket_id = ${ticketId} + AND event_type = 'PipelineStarted' + AND json_extract(payload_json, '$.laneEntryToken') = ${laneEntryToken} + `.pipe(Effect.map((rows) => rows[0]?.count ?? 0)); + +const decodeAwaitingPayloadJson = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + providerRequestId: Schema.optional(Schema.String), + providerQuestionId: Schema.optional(Schema.String), + }), + ), +); + +it.effect("recovers provider user-input waits with a fresh request before accepting answers", () => + Effect.gen(function* () { + const providerStarts = yield* Ref.make>([]); + const responses = yield* Ref.make>([]); + const providerTestLayer = Layer.mergeAll( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: (request) => + Ref.update(providerStarts, (starts) => [...starts, request.dispatchId as string]).pipe( + Effect.as({ turnId: "turn-live" as never }), + ), + }), + Layer.succeed(TurnStateReader, { + read: (threadId) => + Ref.get(responses).pipe( + Effect.map((calls) => + calls.length > 0 + ? ({ _tag: "completed" } as const) + : ({ + _tag: "awaiting_user", + waitingReason: "Live provider question", + providerThreadId: threadId, + providerRequestId: "request-live" as never, + providerResponseKind: "user-input" as const, + providerQuestionId: "question-live", + } as const), + ), + ), + }), + Layer.succeed(ProviderResponsePort, { + respond: (input) => Ref.update(responses, (calls) => [...calls, input]), + }), + ); + const workflowTestLayer = Layer.mergeAll( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + Layer.succeed(StepExecutor, { execute: () => Effect.die("unused") }), + Layer.succeed(WorkflowFileLoader, { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => Effect.succeed(input.boardId), + }), + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed("/tmp/recovery-project"), + }), + ); + const recoveryLayer = WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge(providerTestLayer), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(WorkflowEngineLayer), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(workflowTestLayer), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(recoveryPreloadSupport), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const recovery = yield* WorkflowRecovery; + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + + yield* registry.register("board-live-wait" as never, { + name: "Live Wait", + lanes: [ + { + key: "impl", + name: "Impl", + entry: "manual", + pipeline: [ + { + key: "ask", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "ask", + }, + ], + }, + ], + }); + yield* read.registerBoard({ + boardId: "board-live-wait" as never, + projectId: "project-live-wait" as never, + name: "Live Wait", + workflowFilePath: ".t3/boards/live-wait.json", + workflowVersionHash: "hash-live-wait", + maxConcurrentTickets: 1, + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-live-wait-created" as never, + ticketId: "ticket-live-wait" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "board-live-wait" as never, + title: "Live wait", + laneKey: "impl" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-live-wait-moved" as never, + ticketId: "ticket-live-wait" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "impl" as never, + laneEntryToken: "token-live-wait" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-live-wait-pipeline" as never, + ticketId: "ticket-live-wait" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-live-wait" as never, + laneKey: "impl" as never, + laneEntryToken: "token-live-wait" as never, + }, + } as never); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-live-wait-step" as never, + ticketId: "ticket-live-wait" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-live-wait" as never, + stepRunId: "step-live-wait" as never, + stepKey: "ask" as never, + stepType: "agent", + }, + } as never); + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "evt-live-wait-stale-await" as never, + ticketId: "ticket-live-wait" as never, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + stepRunId: "step-live-wait" as never, + waitingReason: "Stale provider question", + providerThreadId: "thread-live-wait" as never, + providerRequestId: "request-stale" as never, + providerResponseKind: "user-input", + providerQuestionId: "question-stale", + }, + } as never); + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + 'thread-live-wait', + 'turn-stale', + NULL, + NULL, + NULL, + NULL, + 'running', + '2026-06-07T00:00:04.000Z', + '2026-06-07T00:00:04.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + turn_id, + created_at, + started_at + ) + VALUES ( + 'dispatch-live-wait', + 'ticket-live-wait', + 'step-live-wait', + 'thread-live-wait', + 'codex', + 'gpt-5.5', + 'ask', + '/tmp/live-wait', + 'started', + 'turn-stale', + '2026-06-07T00:00:04.000Z', + '2026-06-07T00:00:04.000Z' + ) + `; + + yield* recovery.recover(); + + assert.deepEqual(yield* Ref.get(providerStarts), ["dispatch-live-wait"]); + const waitRows = yield* sql<{ readonly payloadJson: string }>` + SELECT payload_json AS "payloadJson" + FROM workflow_events + WHERE ticket_id = 'ticket-live-wait' + AND event_type = 'StepAwaitingUser' + ORDER BY sequence ASC + `; + const latestPayload = yield* decodeAwaitingPayloadJson(waitRows.at(-1)?.payloadJson ?? "{}"); + assert.equal(latestPayload.providerRequestId, "request-live"); + assert.equal(latestPayload.providerQuestionId, "question-live"); + + yield* engine.answerTicketStep({ + stepRunId: "step-live-wait" as never, + text: "Use the live answer.", + }); + + assert.deepEqual( + (yield* Ref.get(responses)).map((response) => ({ + requestId: response.requestId as string, + questionId: response.questionId, + text: response.text, + })), + [ + { + requestId: "request-live", + questionId: "question-live", + text: "Use the live answer.", + }, + ], + ); + }).pipe(Effect.provide(recoveryLayer)); + }), +); + +it.effect("starts recovered provider waits once when the fresh turn is still running", () => + Effect.gen(function* () { + const providerStarts = yield* Ref.make>([]); + const runningTurnLayer = WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: (request) => + Ref.modify(providerStarts, (starts) => [ + { turnId: `turn-live-${starts.length + 1}` as never }, + [...starts, request.dispatchId as string], + ]), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "running" as const }), + }), + ), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(WorkflowFileLoader, { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => Effect.succeed(input.boardId), + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed("/tmp/recovery-project"), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowEngine, { + createTicket: () => Effect.die("unused createTicket"), + editTicket: () => Effect.void, + moveTicket: () => Effect.die("unused moveTicket"), + runLane: () => Effect.die("unused runLane"), + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.die("unused resolveApproval"), + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.die("unused cancelStep"), + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowIds, { + ticketId: () => Effect.succeed("ticket-unused" as never), + pipelineRunId: () => Effect.succeed("pipeline-unused" as never), + scriptRunId: () => Effect.succeed("script-unused" as never), + stepRunId: () => Effect.succeed("step-unused" as never), + messageId: () => Effect.succeed("message-unused" as never), + eventId: () => + Effect.sync(() => { + recoveryEventId += 1; + return `evt-running-recovery-${recoveryEventId}` as never; + }), + token: () => Effect.succeed("token-unused" as never), + }), + ), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(recoveryPreloadSupport), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const recovery = yield* WorkflowRecovery; + const sql = yield* SqlClient.SqlClient; + + yield* sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + 'board-running-wait', + 'project-running-wait', + 'Running Wait', + '.t3/boards/running-wait.json', + 'hash-running-wait', + 1 + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-running-wait', + 'board-running-wait', + 'Running wait', + 'impl', + 'waiting_on_user', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:04.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_events ( + event_id, + ticket_id, + stream_version, + event_type, + occurred_at, + payload_json + ) + VALUES ( + 'evt-running-wait-stale', + 'ticket-running-wait', + 0, + 'StepAwaitingUser', + '2026-06-07T00:00:04.000Z', + '{"stepRunId":"step-running-wait","waitingReason":"Stale provider question","providerThreadId":"thread-running-wait","providerRequestId":"request-stale","providerResponseKind":"user-input","providerQuestionId":"question-stale"}' + ) + `; + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + 'thread-running-wait', + 'turn-stale', + NULL, + NULL, + NULL, + NULL, + 'running', + '2026-06-07T00:00:03.000Z', + '2026-06-07T00:00:03.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + turn_id, + created_at, + started_at + ) + VALUES ( + 'dispatch-running-wait', + 'ticket-running-wait', + 'step-running-wait', + 'thread-running-wait', + 'codex', + 'gpt-5.5', + 'ask', + '/tmp/running-wait', + 'started', + 'turn-stale', + '2026-06-07T00:00:03.000Z', + '2026-06-07T00:00:03.000Z' + ) + `; + + yield* recovery.recover(); + + assert.deepEqual(yield* Ref.get(providerStarts), ["dispatch-running-wait"]); + const dispatchRows = yield* sql<{ + readonly status: string; + readonly turnId: string | null; + }>` + SELECT + status, + turn_id AS "turnId" + FROM workflow_dispatch_outbox + WHERE dispatch_id = 'dispatch-running-wait' + `; + assert.deepEqual(dispatchRows[0], { status: "started", turnId: "turn-live-1" }); + }).pipe(Effect.provide(runningTurnLayer)); + }), +); + +it.effect("recommits recovered provider approval requests after stale dispatch cleanup", () => + Effect.gen(function* () { + const providerStarts = yield* Ref.make>([]); + const requestRecoveryLayer = WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: (request) => + Ref.update(providerStarts, (starts) => [...starts, request.dispatchId as string]).pipe( + Effect.as({ turnId: "turn-request-live" as never }), + ), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: (threadId) => + Ref.get(providerStarts).pipe( + Effect.map((starts) => + starts.length === 0 + ? ({ _tag: "running" } as const) + : ({ + _tag: "awaiting_user" as const, + waitingReason: "Approve the recovered command?", + providerThreadId: threadId, + providerRequestId: "request-approval-live" as never, + providerResponseKind: "request" as const, + } as const), + ), + ), + }), + ), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(WorkflowFileLoader, { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => Effect.succeed(input.boardId), + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed("/tmp/recovery-project"), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowEngine, { + createTicket: () => Effect.die("unused createTicket"), + editTicket: () => Effect.void, + moveTicket: () => Effect.die("unused moveTicket"), + runLane: () => Effect.die("unused runLane"), + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.die("unused resolveApproval"), + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.die("unused cancelStep"), + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowIds, { + ticketId: () => Effect.succeed("ticket-unused" as never), + pipelineRunId: () => Effect.succeed("pipeline-unused" as never), + scriptRunId: () => Effect.succeed("script-unused" as never), + stepRunId: () => Effect.succeed("step-unused" as never), + messageId: () => Effect.succeed("message-unused" as never), + eventId: () => + Effect.sync(() => { + recoveryEventId += 1; + return `evt-request-recovery-${recoveryEventId}` as never; + }), + token: () => Effect.succeed("token-unused" as never), + }), + ), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(recoveryPreloadSupport), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const recovery = yield* WorkflowRecovery; + const registry = yield* BoardRegistry; + const sql = yield* SqlClient.SqlClient; + + yield* registry.register("board-request-wait" as never, { + name: "Request Wait", + lanes: [{ key: "impl", name: "Impl", entry: "manual" }], + }); + yield* sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + 'board-request-wait', + 'project-request-wait', + 'Request Wait', + '.t3/boards/request-wait.json', + 'hash-request-wait', + 1 + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-request-wait', + 'board-request-wait', + 'Request wait', + 'impl', + 'waiting_on_user', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:04.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_events ( + event_id, + ticket_id, + stream_version, + event_type, + occurred_at, + payload_json + ) + VALUES ( + 'evt-request-wait-stale', + 'ticket-request-wait', + 0, + 'StepAwaitingUser', + '2026-06-07T00:00:04.000Z', + '{"stepRunId":"step-request-wait","waitingReason":"Stale approval","providerThreadId":"thread-request-wait","providerRequestId":"request-approval-stale","providerResponseKind":"request"}' + ) + `; + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + 'thread-request-wait', + 'turn-request-stale', + NULL, + NULL, + NULL, + NULL, + 'running', + '2026-06-07T00:00:03.000Z', + '2026-06-07T00:00:03.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + turn_id, + created_at, + started_at + ) + VALUES ( + 'dispatch-request-wait', + 'ticket-request-wait', + 'step-request-wait', + 'thread-request-wait', + 'codex', + 'gpt-5.5', + 'approve', + '/tmp/request-wait', + 'started', + 'turn-request-stale', + '2026-06-07T00:00:03.000Z', + '2026-06-07T00:00:03.000Z' + ) + `; + + yield* recovery.recover(); + assert.deepEqual(yield* Ref.get(providerStarts), ["dispatch-request-wait"]); + const dispatchRows = yield* sql<{ + readonly status: string; + readonly turnId: string | null; + }>` + SELECT + status, + turn_id AS "turnId" + FROM workflow_dispatch_outbox + WHERE dispatch_id = 'dispatch-request-wait' + `; + assert.deepEqual(dispatchRows[0], { + status: "started", + turnId: "turn-request-live", + }); + yield* waitForRecoveryCondition( + workflowEventCount(sql, "ticket-request-wait", "StepAwaitingUser").pipe( + Effect.map((count) => count === 2), + ), + "recovered provider approval wait", + ); + + const waitRows = yield* sql<{ readonly payloadJson: string }>` + SELECT payload_json AS "payloadJson" + FROM workflow_events + WHERE ticket_id = 'ticket-request-wait' + AND event_type = 'StepAwaitingUser' + ORDER BY sequence ASC + `; + const latestPayload = yield* decodeAwaitingPayloadJson(waitRows.at(-1)?.payloadJson ?? "{}"); + assert.equal(latestPayload.providerRequestId, "request-approval-live"); + }).pipe(Effect.provide(requestRecoveryLayer)); + }), +); + +recoveryWipLayer("WorkflowRecovery WIP admission", (it) => { + it.effect( + "preloads persisted boards, admits queued tickets, and restarts stranded auto tickets", + () => + Effect.gen(function* () { + loadedRecoveryBoards.length = 0; + recoveryStepExecutions = 0; + const recovery = yield* WorkflowRecovery; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + + yield* registry.register("b-recovery-wip" as never, recoveryWipDefinition); + yield* read.registerBoard({ + boardId: "b-recovery-wip" as never, + projectId: "p-recovery-wip" as never, + name: "Recovery WIP", + workflowFilePath: ".t3/boards/recovery-wip.json", + workflowVersionHash: "hash-recovery-wip", + maxConcurrentTickets: 3, + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-recovery-queued-created" as never, + ticketId: "ticket-recovery-queued" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-recovery-wip" as never, + title: "Queued recovery", + laneKey: "queue" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketQueued", + eventId: "evt-recovery-queued" as never, + ticketId: "ticket-recovery-queued" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { lane: "queue" as never }, + } as never); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-recovery-stranded-created" as never, + ticketId: "ticket-recovery-stranded" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + boardId: "b-recovery-wip" as never, + title: "Stranded recovery", + laneKey: "stranded" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-recovery-stranded-admitted" as never, + ticketId: "ticket-recovery-stranded" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + toLane: "stranded" as never, + laneEntryToken: "tok-recovery-stranded" as never, + reason: "initial", + }, + } as never); + + yield* recovery.recover(); + + assert.deepEqual(loadedRecoveryBoards, ["b-recovery-wip"]); + yield* waitForRecoveryCondition( + Effect.gen(function* () { + const queued = yield* read.getTicketDetail("ticket-recovery-queued" as never); + return ( + queued !== null && + queued.ticket.currentLaneEntryToken !== null && + queued.ticket.queuedAt === null + ); + }), + "queued ticket admission", + ); + yield* waitForRecoveryCondition( + Effect.gen(function* () { + const queuedStarts = yield* workflowEventCount( + sql, + "ticket-recovery-queued", + "PipelineStarted", + ); + const strandedStarts = yield* workflowEventCount( + sql, + "ticket-recovery-stranded", + "PipelineStarted", + ); + return queuedStarts === 1 && strandedStarts === 1; + }), + "recovered auto pipeline starts", + ); + assert.equal(yield* workflowEventCount(sql, "ticket-recovery-queued", "TicketAdmitted"), 1); + + yield* recovery.recover(); + yield* waitForRecoveryCondition( + Effect.gen(function* () { + const queuedAdmits = yield* workflowEventCount( + sql, + "ticket-recovery-queued", + "TicketAdmitted", + ); + const queuedStarts = yield* workflowEventCount( + sql, + "ticket-recovery-queued", + "PipelineStarted", + ); + const strandedStarts = yield* workflowEventCount( + sql, + "ticket-recovery-stranded", + "PipelineStarted", + ); + return queuedAdmits === 1 && queuedStarts === 1 && strandedStarts === 1; + }), + "idempotent WIP recovery", + ); + assert.equal(recoveryStepExecutions, 2); + }), + ); +}); + +delayedPipelineStartRecoveryLayer("WorkflowEngine delayed start idempotency", (it) => { + it.effect("skips duplicate runLane starts for the same token while allowing a new token", () => + Effect.gen(function* () { + delayedPipelineStartAttempts = 0; + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + + yield* registry.register("b-runlane-idempotent" as never, recoveryWipDefinition); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-runlane-idempotent-created" as never, + ticketId: "ticket-runlane-idempotent" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-runlane-idempotent" as never, + title: "Run lane idempotent", + laneKey: "queue" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-runlane-idempotent-admitted" as never, + ticketId: "ticket-runlane-idempotent" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "queue" as never, + laneEntryToken: "tok-runlane-idempotent" as never, + reason: "initial", + }, + } as never); + + yield* engine.runLane("ticket-runlane-idempotent" as never); + yield* waitForRecoveryCondition( + Effect.sync(() => delayedPipelineStartAttempts === 1), + "first delayed runLane start", + ); + + yield* engine.runLane("ticket-runlane-idempotent" as never); + yield* Effect.yieldNow; + assert.equal(delayedPipelineStartAttempts, 1); + assert.equal( + yield* pipelineStartsForToken(sql, "ticket-runlane-idempotent", "tok-runlane-idempotent"), + 0, + ); + + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-runlane-idempotent-new-token" as never, + ticketId: "ticket-runlane-idempotent" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + toLane: "queue" as never, + laneEntryToken: "tok-runlane-idempotent-new" as never, + reason: "manual", + }, + } as never); + yield* engine.runLane("ticket-runlane-idempotent" as never); + yield* waitForRecoveryCondition( + Effect.sync(() => delayedPipelineStartAttempts === 2), + "new-token delayed runLane start", + ); + + const release = delayedPipelineStartRelease; + assert.isNotNull(release); + if (release === null) { + assert.fail("expected delayed pipeline start release gate"); + } + yield* Deferred.succeed(release, undefined); + yield* waitForRecoveryCondition( + Effect.gen(function* () { + const originalStarts = yield* pipelineStartsForToken( + sql, + "ticket-runlane-idempotent", + "tok-runlane-idempotent", + ); + const newStarts = yield* pipelineStartsForToken( + sql, + "ticket-runlane-idempotent", + "tok-runlane-idempotent-new", + ); + return originalStarts === 1 && newStarts === 1; + }), + "original and new-token pipeline starts", + ); + assert.equal( + yield* pipelineStartsForToken(sql, "ticket-runlane-idempotent", "tok-runlane-idempotent"), + 1, + ); + assert.equal( + yield* pipelineStartsForToken( + sql, + "ticket-runlane-idempotent", + "tok-runlane-idempotent-new", + ), + 1, + ); + }), + ); +}); + +delayedPipelineStartRecoveryLayer("WorkflowRecovery delayed WIP start", (it) => { + it.effect("starts recovered auto tickets once across two in-flight recoveries", () => + Effect.gen(function* () { + loadedRecoveryBoards.length = 0; + recoveryStepExecutions = 0; + const recovery = yield* WorkflowRecovery; + const read = yield* WorkflowReadModel; + const committer = yield* WorkflowEventCommitter; + const sql = yield* SqlClient.SqlClient; + + yield* read.registerBoard({ + boardId: "b-recovery-delayed-start" as never, + projectId: "p-recovery-delayed-start" as never, + name: "Recovery delayed start", + workflowFilePath: ".t3/boards/recovery-delayed-start.json", + workflowVersionHash: "hash-recovery-delayed-start", + maxConcurrentTickets: 3, + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-recovery-delayed-created" as never, + ticketId: "ticket-recovery-delayed" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "b-recovery-delayed-start" as never, + title: "Queued delayed recovery", + laneKey: "queue" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketQueued", + eventId: "evt-recovery-delayed-queued" as never, + ticketId: "ticket-recovery-delayed" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { lane: "queue" as never }, + } as never); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-recovery-delayed-stranded-created" as never, + ticketId: "ticket-recovery-delayed-stranded" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + boardId: "b-recovery-delayed-start" as never, + title: "Stranded delayed recovery", + laneKey: "stranded" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-recovery-delayed-stranded-admitted" as never, + ticketId: "ticket-recovery-delayed-stranded" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + toLane: "stranded" as never, + laneEntryToken: "tok-recovery-delayed-stranded" as never, + reason: "initial", + }, + } as never); + + const recoveryFiber = yield* recovery.recover().pipe(Effect.forkScoped); + yield* waitForRecoveryCondition( + Effect.sync(() => delayedPipelineStartAttempts === 2), + "delayed pipeline start attempts", + ); + yield* Fiber.join(recoveryFiber); + + const admitted = yield* read.getTicketDetail("ticket-recovery-delayed" as never); + const laneEntryToken = admitted?.ticket.currentLaneEntryToken; + assert.isNotNull(laneEntryToken ?? null); + if (laneEntryToken === null || laneEntryToken === undefined) { + assert.fail("expected recovery admission to assign a token"); + } + + yield* recovery.recover(); + yield* Effect.yieldNow; + assert.equal(delayedPipelineStartAttempts, 2); + assert.equal( + yield* pipelineStartsForToken(sql, "ticket-recovery-delayed", laneEntryToken), + 0, + ); + assert.equal( + yield* pipelineStartsForToken( + sql, + "ticket-recovery-delayed-stranded", + "tok-recovery-delayed-stranded", + ), + 0, + ); + + const release = delayedPipelineStartRelease; + assert.isNotNull(release); + if (release === null) { + assert.fail("expected delayed pipeline start release gate"); + } + yield* Deferred.succeed(release, undefined); + yield* waitForRecoveryCondition( + Effect.gen(function* () { + const queuedStarts = yield* pipelineStartsForToken( + sql, + "ticket-recovery-delayed", + laneEntryToken, + ); + const strandedStarts = yield* pipelineStartsForToken( + sql, + "ticket-recovery-delayed-stranded", + "tok-recovery-delayed-stranded", + ); + return queuedStarts === 1 && strandedStarts === 1; + }), + "single delayed pipeline starts", + ); + assert.equal( + yield* pipelineStartsForToken(sql, "ticket-recovery-delayed", laneEntryToken), + 1, + ); + assert.equal( + yield* pipelineStartsForToken( + sql, + "ticket-recovery-delayed-stranded", + "tok-recovery-delayed-stranded", + ), + 1, + ); + + yield* recovery.recover(); + assert.equal( + yield* pipelineStartsForToken(sql, "ticket-recovery-delayed", laneEntryToken), + 1, + ); + assert.equal( + yield* pipelineStartsForToken( + sql, + "ticket-recovery-delayed-stranded", + "tok-recovery-delayed-stranded", + ), + 1, + ); + }), + ); +}); + +it.effect("cascades persisted boards whose workflow file is missing during preload", () => + Effect.gen(function* () { + const cancelledBoards = yield* Ref.make>([]); + const unregisteredBoards = yield* Ref.make>([]); + const missingFileLayer = WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.succeed({ turnId: "turn-1" as never }), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.succeed(WorkflowFileLoader, { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => + Effect.fail( + new WorkflowRpcError({ + message: "workflow file read failed", + cause: { reason: { _tag: "NotFound" } } as never, + }), + ), + }), + ), + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed("/tmp/recovery-project"), + }), + ), + Layer.provideMerge( + Layer.succeed(BoardRegistry, { + register: () => Effect.die("unused"), + unregister: (boardId) => + Ref.update(unregisteredBoards, (boards) => [...boards, boardId as string]), + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowEngine, { + createTicket: () => Effect.die("unused createTicket"), + editTicket: () => Effect.void, + moveTicket: () => Effect.die("unused moveTicket"), + runLane: () => Effect.die("unused runLane"), + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.die("unused resolveApproval"), + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.die("unused cancelStep"), + cancelBoardPipelines: (boardId) => + Ref.update(cancelledBoards, (boards) => [...boards, boardId as string]), + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.die("stale board must not recover wip"), + completeRecoveredStep: () => Effect.die("unused completeRecoveredStep"), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowIds, { + ticketId: () => Effect.succeed("ticket-unused" as never), + pipelineRunId: () => Effect.succeed("pipeline-unused" as never), + scriptRunId: () => Effect.succeed("script-unused" as never), + stepRunId: () => Effect.succeed("step-unused" as never), + messageId: () => Effect.succeed("message-unused" as never), + eventId: () => Effect.succeed("event-unused" as never), + token: () => Effect.succeed("token-unused" as never), + }), + ), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(recoveryPreloadSupport), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const recovery = yield* WorkflowRecovery; + const now = "2026-06-07T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + 'board-stale-file', + 'project-stale-file', + 'Stale File', + '.t3/boards/stale-file.json', + 'hash-stale-file', + 3 + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-stale-file', + 'board-stale-file', + 'Stale ticket', + 'impl', + 'running', + ${now}, + ${now} + ) + `; + yield* sql` + INSERT INTO workflow_events ( + event_id, + ticket_id, + stream_version, + event_type, + occurred_at, + payload_json + ) + VALUES ( + 'event-stale-file', + 'ticket-stale-file', + 0, + 'TicketCreated', + ${now}, + '{"boardId":"board-stale-file","title":"Stale ticket","laneKey":"impl"}' + ) + `; + yield* sql` + INSERT INTO workflow_board_version ( + board_id, + version_hash, + content_json, + source, + created_at + ) + VALUES ( + 'board-stale-file', + 'hash-stale-file-version', + '{"name":"Stale File"}', + 'save', + ${now} + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES ( + 'dispatch-stale-file', + 'ticket-stale-file', + 'step-stale-file', + 'thread-stale-file', + 'codex', + 'gpt-5.5', + 'stale dispatch', + '/tmp/stale-file', + 'pending', + ${now} + ) + `; + yield* sql` + INSERT INTO workflow_setup_run ( + setup_run_id, + ticket_id, + worktree_ref, + status, + started_at + ) + VALUES ( + 'setup-stale-file', + 'ticket-stale-file', + 'worktree-stale-file', + 'running', + ${now} + ) + `; + + yield* recovery.recover(); + + assert.deepEqual(yield* Ref.get(cancelledBoards), ["board-stale-file"]); + assert.deepEqual(yield* Ref.get(unregisteredBoards), ["board-stale-file"]); + const counts = yield* sql<{ readonly tableName: string; readonly count: number }>` + SELECT 'projection_board' AS tableName, COUNT(*) AS count + FROM projection_board + WHERE board_id = 'board-stale-file' + UNION ALL + SELECT 'projection_ticket' AS tableName, COUNT(*) AS count + FROM projection_ticket + WHERE board_id = 'board-stale-file' + UNION ALL + SELECT 'workflow_events' AS tableName, COUNT(*) AS count + FROM workflow_events + WHERE ticket_id = 'ticket-stale-file' + UNION ALL + SELECT 'workflow_board_version' AS tableName, COUNT(*) AS count + FROM workflow_board_version + WHERE board_id = 'board-stale-file' + UNION ALL + SELECT 'workflow_dispatch_outbox' AS tableName, COUNT(*) AS count + FROM workflow_dispatch_outbox + WHERE ticket_id = 'ticket-stale-file' + UNION ALL + SELECT 'workflow_setup_run' AS tableName, COUNT(*) AS count + FROM workflow_setup_run + WHERE ticket_id = 'ticket-stale-file' + `; + assert.deepEqual( + counts.map((row) => [row.tableName, row.count]), + [ + ["projection_board", 0], + ["projection_ticket", 0], + ["workflow_events", 0], + ["workflow_board_version", 0], + ["workflow_dispatch_outbox", 0], + ["workflow_setup_run", 0], + ], + ); + }).pipe(Effect.provide(missingFileLayer)); + }), +); + +it.effect("preload does not resurrect a board deleted while its save lock is held", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-workflow-recovery-preload-delete-", + }); + const boardsDir = path.join(workspaceRoot, ".t3/boards"); + const boardPath = path.join(boardsDir, "preload-delete.json"); + const boardId = "board-preload-delete" as never; + const projectId = "project-preload-delete" as never; + const finishLoad = yield* Deferred.make(); + const deleteLockHeld = yield* Deferred.make(); + const finishDelete = yield* Deferred.make(); + const loadedBoards = yield* Ref.make>([]); + const recoveredBoards = yield* Ref.make>([]); + yield* fs.makeDirectory(boardsDir, { recursive: true }); + yield* fs.writeFileString( + boardPath, + '{"name":"Preload Delete","lanes":[{"key":"impl","name":"Impl","entry":"manual"}]}', + ); + + const preloadDeleteLayer = WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + ensureTurnStarted: () => Effect.succeed({ turnId: "turn-1" as never }), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + read: () => Effect.succeed({ _tag: "completed" as const }), + }), + ), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge( + Layer.effect( + WorkflowFileLoader, + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + return { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => + Effect.gen(function* () { + yield* Ref.update(loadedBoards, (boards) => [ + ...boards, + input.boardId as string, + ]); + yield* Deferred.await(finishLoad); + yield* registry + .register(input.boardId, { + name: "Preload Delete", + lanes: [{ key: "impl", name: "Impl", entry: "manual" }], + }) + .pipe( + Effect.mapError( + (cause) => + new WorkflowRpcError({ + message: "test board registration failed", + cause, + }), + ), + ); + yield* read + .registerBoard({ + boardId: input.boardId, + projectId: input.projectId, + name: "Preload Delete", + workflowFilePath: input.relativePath, + workflowVersionHash: "hash-preload-delete-resurrected", + maxConcurrentTickets: 1, + }) + .pipe( + Effect.mapError( + (cause) => + new WorkflowRpcError({ + message: "test board projection registration failed", + cause, + }), + ), + ); + return input.boardId; + }), + }; + }), + ), + ), + Layer.provideMerge( + Layer.succeed(ProjectWorkspaceResolver, { + resolve: () => Effect.succeed(workspaceRoot), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowEngine, { + createTicket: () => Effect.die("unused createTicket"), + editTicket: () => Effect.void, + moveTicket: () => Effect.die("unused moveTicket"), + runLane: () => Effect.die("unused runLane"), + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.die("unused resolveApproval"), + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.die("unused cancelStep"), + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: (recoveredBoardId) => + Ref.update(recoveredBoards, (boards) => [...boards, recoveredBoardId as string]), + completeRecoveredStep: () => Effect.die("unused completeRecoveredStep"), + }), + ), + Layer.provideMerge( + Layer.succeed(WorkflowIds, { + ticketId: () => Effect.succeed("ticket-unused" as never), + pipelineRunId: () => Effect.succeed("pipeline-unused" as never), + scriptRunId: () => Effect.succeed("script-unused" as never), + stepRunId: () => Effect.succeed("step-unused" as never), + messageId: () => Effect.succeed("message-unused" as never), + eventId: () => Effect.succeed("event-unused" as never), + token: () => Effect.succeed("token-unused" as never), + }), + ), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + + yield* Effect.gen(function* () { + const recovery = yield* WorkflowRecovery; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const saveLocks = yield* WorkflowBoardSaveLocks; + + yield* registry.register(boardId, { + name: "Preload Delete", + lanes: [{ key: "impl", name: "Impl", entry: "manual" }], + }); + yield* read.registerBoard({ + boardId, + projectId, + name: "Preload Delete", + workflowFilePath: ".t3/boards/preload-delete.json", + workflowVersionHash: "hash-preload-delete", + maxConcurrentTickets: 1, + }); + + const deleteFiber = yield* saveLocks + .withSaveLock( + boardId, + Effect.gen(function* () { + yield* Deferred.succeed(deleteLockHeld, undefined); + yield* Deferred.await(finishDelete); + yield* fs.remove(boardPath); + yield* registry.unregister(boardId); + yield* read.deleteBoard(boardId); + }), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(deleteLockHeld); + + const recoveryFiber = yield* recovery.recover().pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + const loaderEnteredWhileDeleteHeld = (yield* Ref.get(loadedBoards)).length > 0; + + yield* Deferred.succeed(finishDelete, undefined); + yield* Fiber.join(deleteFiber); + yield* Deferred.succeed(finishLoad, undefined).pipe(Effect.ignore); + yield* Fiber.join(recoveryFiber).pipe(Effect.timeout("1 second")); + + assert.isFalse(loaderEnteredWhileDeleteHeld); + assert.deepEqual(yield* Ref.get(loadedBoards), []); + assert.deepEqual(yield* Ref.get(recoveredBoards), []); + assert.isNull(yield* registry.getDefinition(boardId)); + assert.isNull(yield* read.getBoard(boardId)); + }).pipe(Effect.provide(preloadDeleteLayer)); + }).pipe(Effect.provide(NodeServices.layer)), + ), +); + +layer("WorkflowRecovery", (it) => { + it.effect("confirms recovered dispatches and completes terminal steps", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const recovery = yield* WorkflowRecovery; + completedRecoveredSteps.length = 0; + + yield* sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + 'board-1', + 'project-1', + 'Recovery Board', + '.t3/boards/recovery.json', + 'hash-recovery', + 3 + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-1', + 'board-1', + 'Recover dispatch', + 'impl', + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES ( + 'dispatch-1', + 'ticket-1', + 'step-run-1', + 'thread-1', + 'codex', + 'gpt-5.5', + 'finish the step', + '/tmp/wt-ticket-1', + 'pending', + '2026-06-07T00:00:00.000Z' + ) + `; + + yield* recovery.recover(); + + const rows = yield* sql<{ readonly status: string }>` + SELECT status FROM workflow_dispatch_outbox WHERE dispatch_id = 'dispatch-1' + `; + assert.equal(rows[0]?.status, "confirmed"); + + assert.deepEqual(completedRecoveredSteps, [ + { + stepRunId: "step-run-1", + result: { _tag: "completed" }, + captureTurn: { threadId: "thread-1", turnId: "turn-1" }, + }, + ]); + }), + ); + + it.effect("releases worktree leases for steps that ended blocked", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const recovery = yield* WorkflowRecovery; + + yield* sql` + INSERT INTO workflow_events ( + event_id, + ticket_id, + stream_version, + event_type, + occurred_at, + payload_json + ) + VALUES ( + 'evt-step-blocked', + 'ticket-blocked', + 0, + 'StepBlocked', + '2026-06-07T00:00:00.000Z', + '{"stepRunId":"step-run-blocked","reason":"Project not trusted to run scripts"}' + ) + `; + yield* sql` + INSERT INTO worktree_lease ( + worktree_ref, + owner_kind, + owner_id, + fence_token, + acquired_at, + expires_at + ) + VALUES ( + 'wt-blocked', + 'step', + 'step-run-blocked', + 7, + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:30:00.000Z' + ) + `; + + yield* recovery.recover(); + + const rows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-blocked' + `; + assert.equal(rows[0]?.ownerKind, "released"); + }), + ); + + it.effect("fails running script runs after restart and releases their step lease", () => + Effect.gen(function* () { + completedRecoveredSteps.length = 0; + const sql = yield* SqlClient.SqlClient; + const recovery = yield* WorkflowRecovery; + const registry = yield* BoardRegistry; + const store = yield* WorkflowEventStore; + + yield* registry.register("board-script-recovery" as never, { + name: "Script recovery", + lanes: [{ key: "impl", name: "Impl", entry: "manual" }], + }); + yield* sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + 'board-script-recovery', + 'project-script-recovery', + 'Script Recovery', + '.t3/boards/script-recovery.json', + 'hash-script-recovery', + 3 + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-script-recovery', + 'board-script-recovery', + 'Recover script', + 'impl', + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_events ( + event_id, + ticket_id, + stream_version, + event_type, + occurred_at, + payload_json + ) + VALUES + ( + 'evt-script-started', + 'ticket-script-recovery', + 0, + 'StepStarted', + '2026-06-07T00:00:00.000Z', + '{"pipelineRunId":"pipeline-script-recovery","stepRunId":"step-run-script-recovery","stepKey":"tests","stepType":"script"}' + ), + ( + 'evt-script-run-started', + 'ticket-script-recovery', + 1, + 'ScriptStepStarted', + '2026-06-07T00:00:01.000Z', + '{"scriptRunId":"script-run-recovery","stepRunId":"step-run-script-recovery","scriptThreadId":"workflow-script:script-run-recovery","terminalId":"script-script-run-recovery"}' + ) + `; + yield* sql` + INSERT INTO workflow_script_run ( + script_run_id, + step_run_id, + ticket_id, + script_thread_id, + terminal_id, + status, + started_at + ) + VALUES ( + 'script-run-recovery', + 'step-run-script-recovery', + 'ticket-script-recovery', + 'workflow-script:script-run-recovery', + 'script-script-run-recovery', + 'running', + '2026-06-07T00:00:01.000Z' + ) + `; + yield* sql` + INSERT INTO worktree_lease ( + worktree_ref, + owner_kind, + owner_id, + fence_token, + acquired_at, + expires_at + ) + VALUES ( + 'wt-script-recovery', + 'step', + 'step-run-script-recovery', + 11, + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:30:00.000Z' + ) + `; + + yield* recovery.recover(); + + const scriptRows = yield* sql<{ readonly status: string }>` + SELECT status + FROM workflow_script_run + WHERE script_run_id = 'script-run-recovery' + `; + const leaseRows = yield* sql<{ readonly ownerKind: string }>` + SELECT owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = 'wt-script-recovery' + `; + const events = yield* Stream.runCollect( + store.readByTicket("ticket-script-recovery" as never), + ).pipe(Effect.map((chunk) => Array.from(chunk))); + + assert.equal(scriptRows[0]?.status, "cancelled"); + assert.isTrue( + events.some( + (event) => + event.type === "ScriptStepExited" && + event.payload.scriptRunId === "script-run-recovery" && + event.payload.outcome === "cancelled", + ), + ); + assert.isTrue( + events.some( + (event) => + event.type === "StepFailed" && + event.payload.stepRunId === "step-run-script-recovery" && + event.payload.error === "script interrupted by server restart", + ), + ); + assert.deepEqual(completedRecoveredSteps, [ + { + stepRunId: "step-run-script-recovery", + result: { _tag: "failed", error: "script interrupted by server restart" }, + }, + ]); + assert.equal(leaseRows[0]?.ownerKind, "released"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowRecovery.ts b/apps/server/src/workflow/Layers/WorkflowRecovery.ts new file mode 100644 index 00000000000..e00cf4d8ec9 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRecovery.ts @@ -0,0 +1,771 @@ +import type { + BoardId, + MessageId, + ProjectId, + ScriptRunId, + StepRunId, + ThreadId, + TicketId, + TurnId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { DurableApprovalResume } from "../Services/DurableApprovalResume.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + ProviderDispatchOutbox, + type ProviderDispatchTerminalResult, +} from "../Services/ProviderDispatchOutbox.ts"; +import { ProjectWorkspaceResolver } from "../Services/ProjectWorkspaceResolver.ts"; +import { TurnStateReader } from "../Services/TurnStateReader.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowBoardVersionStore } from "../Services/WorkflowBoardVersionStore.ts"; +import { WorkflowFileLoader } from "../Services/WorkflowFileLoader.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import type { PersistedWorkflowEvent, WorkflowEventInput } from "../Services/WorkflowEventStore.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowWebhook } from "../Services/WorkflowWebhook.ts"; +import { WorkflowRecovery, type WorkflowRecoveryShape } from "../Services/WorkflowRecovery.ts"; +import { MergeGitPort } from "../Services/TicketMergeService.ts"; +import type { RecoveredStepResult } from "../Services/WorkflowEngine.ts"; +import { WorktreeLeaseService } from "../Services/WorktreeLeaseService.ts"; +import { WorkflowWorktreeJanitor } from "../Services/WorkflowWorktreeJanitor.ts"; +import { deleteWorkflowBoardOwnedState } from "../boardDeletion.ts"; +import { truncateTicketMessageBody } from "../ticketMessageBody.ts"; + +interface DispatchRecoveryRow { + readonly dispatchId: string; + readonly ticketId: TicketId; + readonly stepRunId: StepRunId; + readonly threadId: string; + readonly turnId: string | null; + readonly status: "pending" | "started" | "confirmed"; +} + +interface LeaseRecoveryRow { + readonly worktreeRef: string; + readonly ownerId: string; + readonly fenceToken: number; +} + +interface ScriptRecoveryRow { + readonly scriptRunId: ScriptRunId; + readonly ticketId: TicketId; + readonly stepRunId: StepRunId; +} + +interface PersistedBoardRecoveryRow { + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly workflowFilePath: string; +} + +const SCRIPT_RESTART_ERROR = "script interrupted by server restart"; +const MERGE_RESTART_ERROR = "merge interrupted by server restart"; + +interface MergeRecoveryRow { + readonly ticketId: TicketId; + readonly stepRunId: StepRunId; + readonly repoRoot: string | null; +} + +interface StrandedPipelineRow { + readonly stepRunId: StepRunId; + readonly status: "completed" | "failed" | "blocked"; + readonly error: string | null; + readonly retryable: number | null; + readonly outputJson: string | null; +} + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +const toRecoveryError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrapSql = (effect: Effect.Effect) => + effect.pipe(Effect.mapError(toRecoveryError("workflow recovery sql failed"))); + +const hasNotFoundReason = (cause: unknown): boolean => { + if (typeof cause !== "object" || cause === null) { + return false; + } + if ("reason" in cause) { + const reason = (cause as { readonly reason?: unknown }).reason; + if ( + typeof reason === "object" && + reason !== null && + "_tag" in reason && + (reason as { readonly _tag?: unknown })._tag === "NotFound" + ) { + return true; + } + } + if ("cause" in cause) { + return hasNotFoundReason((cause as { readonly cause?: unknown }).cause); + } + return false; +}; + +const isMissingWorkflowFileError = (cause: unknown): boolean => + typeof cause === "object" && + cause !== null && + "message" in cause && + String((cause as { readonly message?: unknown }).message).includes("workflow file read failed") && + hasNotFoundReason(cause); + +const isTerminalStepEvent = ( + event: PersistedWorkflowEvent, +): event is Extract< + PersistedWorkflowEvent, + { readonly type: "StepCompleted" | "StepFailed" | "StepBlocked" } +> => event.type === "StepCompleted" || event.type === "StepFailed" || event.type === "StepBlocked"; + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sql = yield* SqlClient.SqlClient; + const outbox = yield* ProviderDispatchOutbox; + const turns = yield* TurnStateReader; + const approvals = yield* DurableApprovalResume; + const committer = yield* WorkflowEventCommitter; + const engine = yield* WorkflowEngine; + const ids = yield* WorkflowIds; + const store = yield* WorkflowEventStore; + const leases = yield* WorktreeLeaseService; + const boardRegistry = yield* BoardRegistry; + const readModel = yield* WorkflowReadModel; + const saveLocks = yield* WorkflowBoardSaveLocks; + const versionStore = yield* WorkflowBoardVersionStore; + const worktreeJanitor = Context.getOption( + (yield* Effect.context()) as Context.Context, + WorkflowWorktreeJanitor, + ); + const mergeGit = Context.getOption( + (yield* Effect.context()) as Context.Context, + MergeGitPort, + ); + const webhook = Context.getOption( + (yield* Effect.context()) as Context.Context, + WorkflowWebhook, + ); + + const getOptionalBoardLoaders = Effect.context().pipe( + Effect.map((context) => ({ + fileLoader: Context.getOption( + context as Context.Context, + WorkflowFileLoader, + ), + projectWorkspaceResolver: Context.getOption( + context as Context.Context, + ProjectWorkspaceResolver, + ), + })), + ); + + const ticketEvents = (ticketId: TicketId) => + Stream.runCollect(store.readByTicket(ticketId)).pipe(Effect.map((chunk) => Array.from(chunk))); + + const hasTerminalStepEvent = ( + events: ReadonlyArray, + stepRunId: StepRunId, + ) => events.some((event) => isTerminalStepEvent(event) && event.payload.stepRunId === stepRunId); + + const latestAwaitingStepEvent = ( + events: ReadonlyArray, + stepRunId: StepRunId, + ) => + events.reduce | null>( + (latest, event) => { + if (event.type === "StepAwaitingUser" && event.payload.stepRunId === stepRunId) { + return event; + } + if (event.type === "StepUserResolved" && event.payload.stepRunId === stepRunId) { + return null; + } + return latest; + }, + null, + ); + + const hasScriptExitedEvent = ( + events: ReadonlyArray, + scriptRunId: ScriptRunId, + ) => + events.some( + (event) => event.type === "ScriptStepExited" && event.payload.scriptRunId === scriptRunId, + ); + + const commitAwaitingTerminalStep = ( + row: DispatchRecoveryRow, + result: Extract, + ) => + Effect.gen(function* () { + const events = yield* ticketEvents(row.ticketId); + if (hasTerminalStepEvent(events, row.stepRunId)) { + return; + } + const latestAwait = latestAwaitingStepEvent(events, row.stepRunId); + if ( + latestAwait !== null && + latestAwait.payload.waitingReason === result.waitingReason && + latestAwait.payload.providerThreadId === result.providerThreadId && + latestAwait.payload.providerRequestId === result.providerRequestId && + latestAwait.payload.providerResponseKind === result.providerResponseKind && + latestAwait.payload.providerQuestionId === result.providerQuestionId + ) { + return; + } + + const eventId = yield* ids.eventId(); + const occurredAt = yield* nowIso; + const awaitEvent = { + type: "StepAwaitingUser", + eventId, + ticketId: row.ticketId, + occurredAt, + payload: { + stepRunId: row.stepRunId, + waitingReason: result.waitingReason, + providerThreadId: result.providerThreadId, + providerRequestId: result.providerRequestId, + providerResponseKind: result.providerResponseKind, + ...(result.providerQuestionId === undefined + ? {} + : { providerQuestionId: result.providerQuestionId }), + }, + } satisfies WorkflowEventInput; + if (result.providerResponseKind !== "user-input") { + yield* committer.commit(awaitEvent); + return; + } + yield* committer.commitMany([ + awaitEvent, + { + type: "TicketMessagePosted", + eventId: yield* ids.eventId(), + ticketId: row.ticketId, + occurredAt, + payload: { + messageId: (yield* ids.messageId()) as MessageId, + stepRunId: row.stepRunId, + author: "agent", + body: truncateTicketMessageBody(result.waitingReason), + attachments: [], + createdAt: occurredAt, + }, + } satisfies WorkflowEventInput, + ]); + }); + + const completeTerminalPipeline = ( + row: DispatchRecoveryRow, + result: ProviderDispatchTerminalResult, + ) => + "awaitingUser" in result + ? Effect.void + : engine.completeRecoveredStep( + row.stepRunId, + result.ok + ? { _tag: "completed" } + : { _tag: "failed", error: result.error ?? "turn failed" }, + row.turnId === null + ? undefined + : { threadId: row.threadId as ThreadId, turnId: row.turnId as TurnId }, + ); + + const interruptProjectedTurn = (row: DispatchRecoveryRow) => + row.turnId === null + ? Effect.void + : nowIso.pipe( + Effect.flatMap((interruptedAt) => + wrapSql(sql` + UPDATE projection_turns + SET state = 'interrupted', + completed_at = ${interruptedAt} + WHERE thread_id = ${row.threadId} + AND turn_id = ${row.turnId} + AND state IN ('pending', 'running') + `), + ), + ); + + const deleteOrphanDispatches = wrapSql(sql` + DELETE FROM workflow_dispatch_outbox + WHERE NOT EXISTS ( + SELECT 1 + FROM projection_ticket AS ticket + INNER JOIN projection_board AS board + ON board.board_id = ticket.board_id + WHERE ticket.ticket_id = workflow_dispatch_outbox.ticket_id + ) + `).pipe(Effect.asVoid); + + const recoverTerminalDispatches = Effect.gen(function* () { + yield* deleteOrphanDispatches; + const rows = yield* wrapSql(sql` + SELECT + dispatch_id AS "dispatchId", + ticket_id AS "ticketId", + step_run_id AS "stepRunId", + thread_id AS "threadId", + turn_id AS "turnId", + status + FROM workflow_dispatch_outbox + WHERE status != 'confirmed' + `); + + for (const row of rows) { + if (row.status === "pending") { + continue; + } + const state = yield* turns.read(row.threadId as never); + if (state._tag === "running") { + if (row.status === "started") { + yield* interruptProjectedTurn(row); + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'pending', + started_at = NULL, + turn_id = NULL + WHERE dispatch_id = ${row.dispatchId} + AND status = 'started' + `); + } + continue; + } + const result = yield* outbox.awaitTerminal(row.dispatchId as never, row.threadId as never); + if ("awaitingUser" in result) { + yield* commitAwaitingTerminalStep(row, result); + } + yield* completeTerminalPipeline(row, result); + } + }); + + const releaseTerminalStepLeases = Effect.gen(function* () { + const rows = yield* wrapSql(sql` + SELECT + leases.worktree_ref AS "worktreeRef", + leases.owner_id AS "ownerId", + leases.fence_token AS "fenceToken" + FROM worktree_lease AS leases + WHERE leases.owner_kind = 'step' + AND EXISTS ( + SELECT 1 + FROM workflow_events AS events + WHERE events.event_type IN ('StepCompleted', 'StepFailed', 'StepBlocked') + AND json_extract(events.payload_json, '$.stepRunId') = leases.owner_id + ) + `); + for (const row of rows) { + yield* leases.release(row.worktreeRef, row.fenceToken); + } + }); + + const recoverRunningScriptRuns = Effect.gen(function* () { + const rows = yield* wrapSql(sql` + SELECT + script_run_id AS "scriptRunId", + ticket_id AS "ticketId", + step_run_id AS "stepRunId" + FROM workflow_script_run + WHERE status = 'running' + `); + + for (const row of rows) { + const events = yield* ticketEvents(row.ticketId); + if (!hasScriptExitedEvent(events, row.scriptRunId)) { + yield* committer.commit({ + type: "ScriptStepExited", + eventId: yield* ids.eventId(), + ticketId: row.ticketId, + occurredAt: yield* nowIso, + payload: { + scriptRunId: row.scriptRunId, + exitCode: null, + signal: null, + outcome: "cancelled", + }, + } satisfies WorkflowEventInput); + } + if (!hasTerminalStepEvent(events, row.stepRunId)) { + yield* committer.commit({ + type: "StepFailed", + eventId: yield* ids.eventId(), + ticketId: row.ticketId, + occurredAt: yield* nowIso, + payload: { + stepRunId: row.stepRunId, + error: SCRIPT_RESTART_ERROR, + }, + } satisfies WorkflowEventInput); + } + yield* engine.completeRecoveredStep(row.stepRunId, { + _tag: "failed", + error: SCRIPT_RESTART_ERROR, + }); + } + }); + + // Decide what a crash mid-merge actually did to the repo: the merge may + // have landed (commit created before the event commit), be sitting half + // done with MERGE_HEAD set, or never have started. Without git access we + // conservatively report failure. + const inspectInterruptedMerge = ( + repoRoot: string | null, + ticketId: TicketId, + ): Effect.Effect => + Effect.gen(function* () { + const failed: RecoveredStepResult = { _tag: "failed", error: MERGE_RESTART_ERROR }; + if (repoRoot === null || Option.isNone(mergeGit)) { + return failed; + } + const git = mergeGit.value; + const worktreeRef = `workflow/${ticketId}`; + + const mergeHead = yield* git.run({ + cwd: repoRoot, + args: ["rev-parse", "-q", "--verify", "MERGE_HEAD"], + allowNonZeroExit: true, + }); + if (mergeHead.exitCode === 0) { + const refTip = yield* git.run({ + cwd: repoRoot, + args: ["rev-parse", "-q", "--verify", `refs/heads/${worktreeRef}`], + allowNonZeroExit: true, + }); + if (refTip.exitCode === 0 && refTip.stdout.trim() === mergeHead.stdout.trim()) { + // The half-finished merge is ours: clean the repo up and let a + // human re-run the lane. + yield* git + .run({ cwd: repoRoot, args: ["merge", "--abort"], allowNonZeroExit: true }) + .pipe(Effect.ignore); + return { + _tag: "blocked", + reason: "Merge interrupted by server restart; the in-progress merge was aborted.", + } satisfies RecoveredStepResult; + } + // Someone else's merge — leave the repo alone. + return { + _tag: "blocked", + reason: + "Merge interrupted by server restart and the repo has an unrelated in-progress merge.", + } satisfies RecoveredStepResult; + } + + const ancestor = yield* git.run({ + cwd: repoRoot, + args: ["merge-base", "--is-ancestor", worktreeRef, "HEAD"], + allowNonZeroExit: true, + }); + if (ancestor.exitCode === 0) { + // The ticket branch is fully contained in HEAD: the merge landed + // before the crash (or there was nothing to merge). + return { _tag: "completed" } satisfies RecoveredStepResult; + } + return failed; + }).pipe( + Effect.orElseSucceed( + (): RecoveredStepResult => ({ _tag: "failed", error: MERGE_RESTART_ERROR }), + ), + ); + + const recoverRunningMergeSteps = Effect.gen(function* () { + const rows = yield* wrapSql(sql` + SELECT + step.ticket_id AS "ticketId", + step.step_run_id AS "stepRunId", + ( + SELECT projects.workspace_root + FROM projection_ticket AS ticket + INNER JOIN projection_board AS board + ON board.board_id = ticket.board_id + INNER JOIN projection_projects AS projects + ON projects.project_id = board.project_id + WHERE ticket.ticket_id = step.ticket_id + ) AS "repoRoot" + FROM projection_step_run AS step + WHERE step.step_type = 'merge' + AND step.status IN ('running', 'dispatch_requested') + `); + + for (const row of rows) { + const events = yield* ticketEvents(row.ticketId); + if (hasTerminalStepEvent(events, row.stepRunId)) { + yield* engine.completeRecoveredStep(row.stepRunId, { + _tag: "failed", + error: MERGE_RESTART_ERROR, + }); + continue; + } + const result = yield* inspectInterruptedMerge(row.repoRoot, row.ticketId); + yield* engine.completeRecoveredStep(row.stepRunId, result); + } + }); + + // A crash between a step's terminal event and the next step (or the + // PipelineCompleted commit) leaves the pipeline run 'running' with no live + // fiber: nothing would ever route the ticket or release its WIP slot. + // Resume those pipelines from their latest terminal step. Pipelines with a + // pending/started dispatch are owned by the outbox monitors, and pipelines + // whose ticket has already moved lanes are excluded by the token match. + const resumeStrandedPipelines = Effect.gen(function* () { + const rows = yield* wrapSql(sql` + SELECT + step.step_run_id AS "stepRunId", + step.status, + step.error, + step.retryable, + step.output_json AS "outputJson" + FROM projection_pipeline_run AS pipeline + INNER JOIN projection_step_run AS step + ON step.rowid = ( + SELECT candidate.rowid + FROM projection_step_run AS candidate + WHERE candidate.pipeline_run_id = pipeline.pipeline_run_id + ORDER BY candidate.started_at DESC, candidate.rowid DESC + LIMIT 1 + ) + WHERE pipeline.status = 'running' + AND step.status IN ('completed', 'failed', 'blocked') + AND pipeline.lane_entry_token = ( + SELECT ticket.current_lane_entry_token + FROM projection_ticket AS ticket + WHERE ticket.ticket_id = pipeline.ticket_id + ) + AND NOT EXISTS ( + SELECT 1 + FROM workflow_dispatch_outbox AS outbox + WHERE outbox.ticket_id = pipeline.ticket_id + AND outbox.status IN ('pending', 'started') + ) + `); + + const parseOutput = (outputJson: string | null): unknown => { + if (outputJson === null) { + return undefined; + } + try { + return JSON.parse(outputJson) as unknown; + } catch { + return undefined; + } + }; + + for (const row of rows) { + const output = parseOutput(row.outputJson); + const result = + row.status === "completed" + ? ({ + _tag: "completed", + ...(output === undefined ? {} : { output }), + } as const) + : row.status === "blocked" + ? ({ _tag: "blocked", reason: row.error ?? "step blocked" } as const) + : ({ + _tag: "failed", + error: row.error ?? "step failed", + ...(row.retryable === 0 ? { retryable: false } : {}), + } as const); + yield* engine.completeRecoveredStep(row.stepRunId, result); + } + }); + + const monitorStartedDispatches = Effect.gen(function* () { + yield* deleteOrphanDispatches; + const allRows = yield* wrapSql(sql` + SELECT + dispatch_id AS "dispatchId", + ticket_id AS "ticketId", + step_run_id AS "stepRunId", + thread_id AS "threadId", + turn_id AS "turnId", + status + FROM workflow_dispatch_outbox + WHERE status = 'started' + `); + + // Review-panel steps fan out several dispatches under one stepRunId. + // Single-dispatch recovery would let the first member's terminal state + // complete the whole step without a majority, so an interrupted panel + // fails honestly (retryable) and its member rows are settled instead. + const rowsByStep = new Map(); + for (const row of allRows) { + const group = rowsByStep.get(row.stepRunId as string) ?? []; + group.push(row); + rowsByStep.set(row.stepRunId as string, group); + } + const rows: DispatchRecoveryRow[] = []; + for (const [stepRunId, group] of rowsByStep) { + if (group.length === 1 && group[0] !== undefined) { + rows.push(group[0]); + continue; + } + yield* Effect.gen(function* () { + const confirmedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'confirmed', + confirmed_at = ${confirmedAt} + WHERE step_run_id = ${stepRunId} + `); + yield* engine.completeRecoveredStep(stepRunId as never, { + _tag: "failed", + error: "review panel interrupted by restart", + retryable: true, + }); + }).pipe(Effect.ignoreCause({ log: true })); + } + + yield* Effect.forEach( + rows, + (row) => + Effect.gen(function* () { + const result = yield* outbox.awaitTerminal( + row.dispatchId as never, + row.threadId as never, + ); + if ("awaitingUser" in result) { + yield* commitAwaitingTerminalStep(row, result); + } + yield* completeTerminalPipeline(row, result); + yield* releaseTerminalStepLeases; + }).pipe( + // Recovery monitors must not block startup. These continuations are not + // registered as live pipeline fibers, so manual moves cannot interrupt + // this narrow restart window. + Effect.ignoreCause({ log: true }), + Effect.forkDetach({ startImmediately: true }), + Effect.asVoid, + ), + { discard: true }, + ); + }); + + const preloadPersistedBoards = Effect.gen(function* () { + const rows = yield* wrapSql(sql` + SELECT + board_id AS "boardId", + project_id AS "projectId", + workflow_file_path AS "workflowFilePath" + FROM projection_board + ORDER BY board_id ASC + `); + + const { fileLoader, projectWorkspaceResolver } = yield* getOptionalBoardLoaders; + const staleBoardIds = new Set(); + if (Option.isSome(fileLoader) && Option.isSome(projectWorkspaceResolver)) { + for (const row of rows) { + yield* saveLocks.withSaveLock( + row.boardId, + Effect.gen(function* () { + const currentBoard = yield* readModel.getBoard(row.boardId); + if (currentBoard === null) { + staleBoardIds.add(row.boardId as string); + return; + } + + const workspaceRoot = yield* projectWorkspaceResolver.value + .resolve(currentBoard.projectId as ProjectId) + .pipe(Effect.mapError(toRecoveryError("workflow recovery project resolve failed"))); + const workflowFilePath = currentBoard.workflowFilePath; + const fileExists = yield* fileSystem + .exists(path.resolve(workspaceRoot, workflowFilePath)) + .pipe(Effect.mapError(toRecoveryError("workflow recovery board file check failed"))); + + if (!fileExists) { + yield* deleteWorkflowBoardOwnedState( + { + boardRegistry, + engine, + eventStore: store, + readModel, + versionStore, + ...(Option.isSome(worktreeJanitor) + ? { worktreeJanitor: worktreeJanitor.value } + : {}), + ...(Option.isSome(webhook) ? { webhook: webhook.value } : {}), + }, + row.boardId, + ); + staleBoardIds.add(row.boardId as string); + return; + } + + yield* fileLoader.value + .loadAndRegister({ + boardId: row.boardId, + projectId: currentBoard.projectId as ProjectId, + workspaceRoot, + relativePath: workflowFilePath, + }) + .pipe( + Effect.catch((cause) => + isMissingWorkflowFileError(cause) + ? deleteWorkflowBoardOwnedState( + { + boardRegistry, + engine, + eventStore: store, + readModel, + versionStore, + ...(Option.isSome(worktreeJanitor) + ? { worktreeJanitor: worktreeJanitor.value } + : {}), + ...(Option.isSome(webhook) ? { webhook: webhook.value } : {}), + }, + row.boardId, + ).pipe( + Effect.tap(() => + Effect.sync(() => staleBoardIds.add(row.boardId as string)), + ), + ) + : Effect.fail(toRecoveryError("workflow recovery board preload failed")(cause)), + ), + ); + }), + ); + } + } + + return rows + .filter((row) => !staleBoardIds.has(row.boardId as string)) + .map((row) => row.boardId); + }); + + const recoverWorkflowWip = Effect.gen(function* () { + const boardIds = yield* preloadPersistedBoards; + for (const boardId of boardIds) { + yield* engine.recoverBoardWip(boardId); + } + }); + + const recover: WorkflowRecoveryShape["recover"] = () => + Effect.gen(function* () { + yield* recoverWorkflowWip; + yield* approvals.resume(); + yield* recoverTerminalDispatches; + yield* recoverRunningScriptRuns; + yield* recoverRunningMergeSteps; + yield* outbox.recoverPending(); + yield* monitorStartedDispatches; + yield* resumeStrandedPipelines; + yield* releaseTerminalStepLeases; + }); + + return { recover } satisfies WorkflowRecoveryShape; +}); + +export const WorkflowRecoveryLive = Layer.effect(WorkflowRecovery, make); diff --git a/apps/server/src/workflow/Layers/WorkflowRoutingContextBuilder.test.ts b/apps/server/src/workflow/Layers/WorkflowRoutingContextBuilder.test.ts new file mode 100644 index 00000000000..2b36f7d0882 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRoutingContextBuilder.test.ts @@ -0,0 +1,156 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { WorkflowRoutingContextBuilder } from "../Services/WorkflowRoutingContextBuilder.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { WorkflowProjectionPipelineLive } from "./WorkflowProjectionPipeline.ts"; +import { WorkflowReadModelLive } from "./WorkflowReadModel.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +const layer = it.layer( + WorkflowRoutingContextBuilderLive.pipe( + Layer.provideMerge(WorkflowProjectionPipelineLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowRoutingContextBuilder", (it) => { + it.effect("builds routing context from the pipeline-scoped read model", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const builder = yield* WorkflowRoutingContextBuilder; + const base = { + ticketId: "t-routing-context" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "routing-context-a" as never, + streamVersion: 0, + payload: { + boardId: "b-1" as never, + title: "Routing context" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "routing-context-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-routing-context" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-routing-context" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "routing-context-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-routing-context" as never, + stepRunId: "sr-routing-tests" as never, + stepKey: "tests" as never, + stepType: "script", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "ScriptStepStarted", + eventId: "routing-context-d" as never, + streamVersion: 3, + payload: { + scriptRunId: "script-routing-context" as never, + stepRunId: "sr-routing-tests" as never, + scriptThreadId: "workflow-script:script-routing-context" as never, + terminalId: "script-routing-context" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "ScriptStepExited", + eventId: "routing-context-e" as never, + streamVersion: 4, + payload: { + scriptRunId: "script-routing-context" as never, + exitCode: 1, + signal: null, + outcome: "exited", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepCompleted", + eventId: "routing-context-f" as never, + streamVersion: 5, + payload: { stepRunId: "sr-routing-tests" as never }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "routing-context-g" as never, + streamVersion: 6, + payload: { + pipelineRunId: "pr-routing-context" as never, + stepRunId: "sr-routing-review" as never, + stepKey: "review" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepCompleted", + eventId: "routing-context-h" as never, + streamVersion: 7, + payload: { + stepRunId: "sr-routing-review" as never, + output: { verdict: "block" }, + }, + } as never); + + // lane.runCount is computed over the ordered event log; mirror the + // projected PipelineStarted there. + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO workflow_events + (event_id, ticket_id, stream_version, event_type, occurred_at, payload_json) + VALUES ( + 'routing-context-pipeline-started', + 't-routing-context', + 100, + 'PipelineStarted', + '2026-06-07T00:00:00.000Z', + '{"pipelineRunId":"pr-routing-context","laneKey":"implement","laneEntryToken":"tok-routing-context"}' + ) + `; + + const context = yield* builder.build({ + ticketId: "t-routing-context" as never, + pipelineRunId: "pr-routing-context" as never, + result: "failure", + }); + + assert.deepEqual(context, { + pipeline: { result: "failure" }, + lane: { runCount: 1 }, + status: "running", + steps: { + tests: { exitCode: 1, status: "completed", output: null }, + review: { exitCode: null, status: "completed", output: { verdict: "block" } }, + }, + }); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowRoutingContextBuilder.ts b/apps/server/src/workflow/Layers/WorkflowRoutingContextBuilder.ts new file mode 100644 index 00000000000..4942f7a2d09 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRoutingContextBuilder.ts @@ -0,0 +1,47 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { + WorkflowRoutingContextBuilder, + type WorkflowRoutingContextBuilderShape, +} from "../Services/WorkflowRoutingContextBuilder.ts"; + +const make = Effect.gen(function* () { + const readModel = yield* WorkflowReadModel; + + const build: WorkflowRoutingContextBuilderShape["build"] = (input) => + Effect.gen(function* () { + const detail = yield* readModel.getTicketDetail(input.ticketId); + if (!detail) { + return yield* new WorkflowEventStoreError({ + message: `ticket not found while building routing context: ${input.ticketId}`, + }); + } + + const laneRunCount = yield* readModel.countLanePipelineRuns(input.pipelineRunId); + const rows = yield* readModel.listStepRunsForPipeline(input.pipelineRunId); + const steps = Object.fromEntries( + rows.map((row) => [ + row.stepKey, + { + exitCode: row.exitCode, + status: row.status, + output: row.output, + }, + ]), + ); + + return { + pipeline: { result: input.result }, + lane: { runCount: laneRunCount }, + status: detail.ticket.status, + steps, + }; + }); + + return { build } satisfies WorkflowRoutingContextBuilderShape; +}); + +export const WorkflowRoutingContextBuilderLive = Layer.effect(WorkflowRoutingContextBuilder, make); diff --git a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts new file mode 100644 index 00000000000..3dc2108ff52 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts @@ -0,0 +1,5195 @@ +import { createHash } from "node:crypto"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + type BoardListEntry, + BoardId, + LaneKey, + type ProjectId, + StepKey, + StepRunId, + TicketId, + WORKFLOW_WS_METHODS, + WorkflowDefinition, + type WorkflowDefinition as WorkflowDefinitionType, + type WorkflowDefinitionEncoded, + WorkflowRpcError, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { workflowRpcHandlers } from "./WorkflowRpcHandlers.ts"; +import { makeWorkflowBoardSaveLocks } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowBoardVersionStoreLive } from "./WorkflowBoardVersionStore.ts"; +import { defaultBoardDefinition } from "../defaultBoard.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import type { ProjectScriptTrustShape } from "../Services/ProjectScriptTrust.ts"; +import type { + WorkflowBoardVersionRecordInput, + WorkflowBoardVersionSource, + WorkflowBoardVersionStoreShape, +} from "../Services/WorkflowBoardVersionStore.ts"; +import { WorkflowBoardVersionStore } from "../Services/WorkflowBoardVersionStore.ts"; +import { + encodeWorkflowDefinitionJson, + lintWorkflowDefinition, + type LintError, +} from "../workflowFile.ts"; + +const noopProjectScriptTrust = { + isTrusted: () => Effect.succeed(false), + setTrusted: () => Effect.void, +} satisfies ProjectScriptTrustShape; + +const noopVersionStore = { + record: () => Effect.void, + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, +} satisfies WorkflowBoardVersionStoreShape; + +const decodeWorkflowDefinition = Schema.decodeUnknownEffect(WorkflowDefinition); +const decodeWorkflowDefinitionJson = Schema.decodeEffect(Schema.fromJsonString(WorkflowDefinition)); +const encodeWorkflowDefinition = Schema.encodeSync(WorkflowDefinition); +const sha256Hex = (value: string) => createHash("sha256").update(value).digest("hex"); + +const versionRoundTripLayer = it.layer( + WorkflowBoardVersionStoreLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +const invokeWorkflowHandler = ( + handlers: ReturnType, + method: string, + input: unknown, +): Effect.Effect => { + const handler = ( + handlers as unknown as Record Effect.Effect> + )[method]; + return handler + ? handler(input) + : Effect.fail(new WorkflowRpcError({ message: `${method} handler is not registered` })); +}; + +it.effect("workflowRpcHandlers maps createTicket and subscribeBoard", () => + Effect.gen(function* () { + const boardId = BoardId.make("board-1"); + const backlog = LaneKey.make("backlog"); + const review = LaneKey.make("review"); + const definition = { + name: "Delivery", + lanes: [ + { key: backlog, name: "Backlog", entry: "manual" }, + { + key: review, + name: "Review", + entry: "manual", + wipLimit: 2, + pipeline: [{ key: StepKey.make("approve"), type: "approval", prompt: "Approve?" }], + }, + ], + } satisfies WorkflowDefinitionType; + let editedTicket: unknown = null; + let answeredStep: unknown = null; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.succeed(TicketId.make("ticket-created")), + editTicket: (input) => + Effect.sync(() => { + editedTicket = input; + }), + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: (input) => + Effect.sync(() => { + answeredStep = input; + }), + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => + Effect.succeed({ + boardId, + projectId: "project-1", + name: "Delivery", + workflowFilePath: ".t3/boards/delivery.json", + workflowVersionHash: "hash", + maxConcurrentTickets: 2, + }), + listTickets: () => + Effect.succeed([ + { + ticketId: "ticket-1", + boardId, + title: "Existing", + description: null, + currentLaneKey: "backlog", + currentLaneEntryToken: null, + queuedAt: "2026-06-07T00:00:00.000Z", + totalTokens: null, + totalDurationMs: null, + status: "idle", + }, + ]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(definition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.succeed(boardId), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/tmp/project"), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const created = yield* handlers[WORKFLOW_WS_METHODS.createTicket]({ + boardId, + title: "New ticket", + initialLane: backlog, + }); + yield* handlers[WORKFLOW_WS_METHODS.editTicket]({ + ticketId: TicketId.make("ticket-1"), + title: "Updated", + description: "", + }); + yield* handlers[WORKFLOW_WS_METHODS.answerTicketStep]({ + stepRunId: StepRunId.make("step-1"), + text: "Use sandbox.", + attachments: [], + }); + const streamItems = Array.from( + yield* handlers[WORKFLOW_WS_METHODS.subscribeBoard]({ boardId }).pipe( + Stream.take(1), + Stream.runCollect, + ), + ); + + assert.deepEqual(created, { ticketId: "ticket-created" }); + assert.deepEqual(editedTicket, { + ticketId: TicketId.make("ticket-1"), + title: "Updated", + description: "", + }); + assert.deepEqual(answeredStep, { + stepRunId: StepRunId.make("step-1"), + text: "Use sandbox.", + attachments: [], + }); + assert.equal(streamItems[0]?.kind, "snapshot"); + if (streamItems[0]?.kind === "snapshot") { + assert.equal(streamItems[0].snapshot.board.name, "Delivery"); + assert.equal(streamItems[0].snapshot.board.lanes[0]?.pipelineStepCount, 0); + assert.equal(streamItems[0].snapshot.board.lanes[1]?.pipelineStepCount, 1); + assert.equal(streamItems[0].snapshot.board.lanes[1]?.wipLimit, 2); + assert.equal(streamItems[0].snapshot.tickets[0]?.title, "Existing"); + assert.equal(streamItems[0].snapshot.tickets[0]?.queuedAt, "2026-06-07T00:00:00.000Z"); + } + }), +); + +it.effect("workflowRpcHandlers lists and creates boards without a client path", () => + Effect.gen(function* () { + const projectId = "project-rpc" as ProjectId; + const projectRoot = "/tmp/project-rpc-root"; + const rows = new Map< + string, + { + readonly boardId: string; + readonly projectId: string; + readonly name: string; + readonly workflowFilePath: string; + readonly workflowVersionHash: string; + readonly maxConcurrentTickets: number; + } + >(); + const definitions = new Map(); + const entries: BoardListEntry[] = []; + const writes: Array<{ + readonly projectRoot: string; + readonly relativePath: string; + readonly contents: string; + }> = []; + const versionRecords: WorkflowBoardVersionRecordInput[] = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: (boardId) => Effect.succeed(rows.get(boardId as string) ?? null), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: (boardId) => Effect.succeed(definitions.get(boardId as string) ?? null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => + Effect.sync(() => { + const content = writes.find( + (write) => write.relativePath === input.relativePath, + )?.contents; + const definition = defaultBoardDefinition({ + name: input.relativePath.includes("-2") ? "Workflow Board" : "Workflow Board", + agent: { instance: "codex_main", model: "gpt-5.5" }, + }); + rows.set(input.boardId as string, { + boardId: input.boardId, + projectId: input.projectId, + name: definition.name, + workflowFilePath: input.relativePath, + workflowVersionHash: sha256Hex(content ?? ""), + maxConcurrentTickets: 3, + }); + definitions.set(input.boardId as string, definition); + entries.push({ + boardId: input.boardId, + name: definition.name, + filePath: input.relativePath, + error: null, + }); + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: { + record: (input) => + Effect.sync(() => { + versionRecords.push(input); + }), + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed(entries), + list: () => Effect.succeed(entries), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(projectRoot), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("writeFile must not be used"), + createFileExclusive: (input) => + Effect.sync(() => { + writes.push(input); + return { relativePath: input.relativePath }; + }), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const overlongCreate = yield* Effect.exit( + handlers[WORKFLOW_WS_METHODS.createBoard]({ + projectId, + name: "A".repeat(129), + agent: { instance: "codex_main", model: "gpt-5.5" }, + }), + ); + assert.strictEqual(overlongCreate._tag, "Failure"); + assert.deepEqual(writes, []); + + assert.deepEqual(yield* handlers[WORKFLOW_WS_METHODS.listBoards]({ projectId }), []); + + const first = yield* handlers[WORKFLOW_WS_METHODS.createBoard]({ + projectId, + name: "Workflow Board", + agent: { instance: "codex_main", model: "gpt-5.5" }, + }); + const second = yield* handlers[WORKFLOW_WS_METHODS.createBoard]({ + projectId, + name: "Workflow Board", + agent: { instance: "codex_main", model: "gpt-5.5" }, + }); + + assert.equal(first.boardId, `${projectId}__workflow-board`); + assert.equal(first.snapshot.projectId, projectId); + assert.equal(second.boardId, `${projectId}__workflow-board-2`); + assert.deepEqual( + writes.map((write) => ({ + projectRoot: write.projectRoot, + relativePath: write.relativePath, + })), + [ + { projectRoot, relativePath: ".t3/boards/workflow-board.json" }, + { projectRoot, relativePath: ".t3/boards/workflow-board-2.json" }, + ], + ); + assert.deepEqual( + versionRecords.map((record) => ({ + boardId: record.boardId, + versionHash: record.versionHash, + contentJson: record.contentJson, + source: record.source, + })), + [ + { + boardId: first.boardId, + versionHash: sha256Hex(writes[0]!.contents), + contentJson: writes[0]!.contents, + source: "create", + }, + { + boardId: second.boardId, + versionHash: sha256Hex(writes[1]!.contents), + contentJson: writes[1]!.contents, + source: "create", + }, + ], + ); + assert.deepEqual( + (yield* handlers[WORKFLOW_WS_METHODS.listBoards]({ projectId })).map( + (entry) => entry.boardId, + ), + [`${projectId}__workflow-board`, `${projectId}__workflow-board-2`], + ); + }), +); + +it.effect( + "workflowRpcHandlers deletes the board file before clearing registration and history", + () => + Effect.gen(function* () { + const boardId = BoardId.make("project-rpc__delete-me"); + const projectId = "project-rpc" as ProjectId; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-delete-board-", + }); + const boardFilePath = path.join(workspaceRoot, ".t3/boards/delete-me.json"); + yield* fileSystem.makeDirectory(path.join(workspaceRoot, ".t3/boards"), { recursive: true }); + yield* fileSystem.writeFileString(boardFilePath, "{}\n"); + const operations: string[] = []; + const fileDeletes: Array<{ readonly cwd: string; readonly relativePath: string }> = []; + const registryUnregistered: BoardId[] = []; + const readModelDeleted: BoardId[] = []; + const versionsDeleted: BoardId[] = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: (inputBoardId) => + Effect.succeed( + inputBoardId === boardId + ? { + boardId, + projectId, + name: "Delete Me", + workflowFilePath: ".t3/boards/delete-me.json", + workflowVersionHash: "hash-delete-me", + maxConcurrentTickets: 3, + } + : null, + ), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: (inputBoardId) => + Effect.sync(() => { + operations.push("delete-projection"); + readModelDeleted.push(inputBoardId); + }), + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: (inputBoardId) => + Effect.sync(() => { + operations.push("unregister"); + registryUnregistered.push(inputBoardId); + }), + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: { + record: () => Effect.void, + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: (inputBoardId) => + Effect.sync(() => { + operations.push("delete-versions"); + versionsDeleted.push(inputBoardId); + }), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: (input) => + Effect.gen(function* () { + operations.push("delete-file"); + fileDeletes.push(input); + yield* fileSystem + .remove(path.join(input.cwd, input.relativePath), { force: true }) + .pipe(Effect.orDie); + }), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + yield* invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.deleteBoard, { + boardId, + relativePath: "../client-supplied-escape.json", + }); + + const deletedStat = yield* fileSystem + .stat(boardFilePath) + .pipe(Effect.orElseSucceed(() => null)); + assert.isNull(deletedStat); + assert.deepEqual(fileDeletes, [ + { cwd: workspaceRoot, relativePath: ".t3/boards/delete-me.json" }, + ]); + assert.deepEqual(operations, [ + "delete-file", + "delete-versions", + "unregister", + "delete-projection", + ]); + assert.deepEqual(registryUnregistered, [boardId]); + assert.deepEqual(readModelDeleted, [boardId]); + assert.deepEqual(versionsDeleted, [boardId]); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect( + "workflowRpcHandlers cascades board-owned state before deleting the board projection", + () => + Effect.gen(function* () { + const boardId = BoardId.make("project-rpc__cascade-delete"); + const projectId = "project-rpc" as ProjectId; + const operations: string[] = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: (inputBoardId: BoardId) => + Effect.sync(() => { + assert.equal(inputBoardId, boardId); + operations.push("cancel-pipelines"); + }), + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: (inputBoardId) => + Effect.succeed( + inputBoardId === boardId + ? { + boardId, + projectId, + name: "Cascade Delete", + workflowFilePath: ".t3/boards/cascade-delete.json", + workflowVersionHash: "hash-cascade-delete", + maxConcurrentTickets: 3, + } + : null, + ), + listTickets: (inputBoardId) => + Effect.succeed( + inputBoardId === boardId + ? [ + { + ticketId: "ticket-cascade-a", + boardId, + title: "A", + description: null, + currentLaneKey: "backlog", + currentLaneEntryToken: null, + queuedAt: null, + totalTokens: null, + totalDurationMs: null, + status: "idle", + }, + { + ticketId: "ticket-cascade-b", + boardId, + title: "B", + description: null, + currentLaneKey: "backlog", + currentLaneEntryToken: null, + queuedAt: null, + totalTokens: null, + totalDurationMs: null, + status: "idle", + }, + ] + : [], + ), + deleteBoardTicketState: (inputBoardId: BoardId) => + Effect.sync(() => { + assert.equal(inputBoardId, boardId); + operations.push("delete-ticket-state"); + }), + deleteTicketState: () => Effect.void, + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: (inputBoardId) => + Effect.sync(() => { + assert.equal(inputBoardId, boardId); + operations.push("delete-board"); + }), + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + eventStore: { + deleteForBoard: (inputBoardId: BoardId) => + Effect.sync(() => { + assert.equal(inputBoardId, boardId); + operations.push("delete-events"); + }), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: (inputBoardId) => + Effect.sync(() => { + assert.equal(inputBoardId, boardId); + operations.push("unregister"); + }), + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: { + record: () => Effect.void, + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: (inputBoardId) => + Effect.sync(() => { + assert.equal(inputBoardId, boardId); + operations.push("delete-versions"); + }), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/workspace/project-rpc"), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => + Effect.sync(() => { + operations.push("delete-file"); + }), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + yield* invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.deleteBoard, { boardId }); + + assert.deepEqual(operations, [ + "delete-file", + "cancel-pipelines", + "delete-versions", + "delete-events", + "delete-ticket-state", + "unregister", + "delete-board", + ]); + }), +); + +it.effect("workflowRpcHandlers completes deleteBoard retry after a mid-cascade failure", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-rpc__retry-delete"); + const projectId = "project-rpc" as ProjectId; + let boardProjectionPresent = true; + let versionRows = 1; + let ticketRows = 1; + let eventRows = 1; + let outboxRows = 1; + let setupRows = 1; + let failProjectionDeleteOnce = true; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: (inputBoardId) => + Effect.succeed( + inputBoardId === boardId && boardProjectionPresent + ? { + boardId, + projectId, + name: "Retry Delete", + workflowFilePath: ".t3/boards/retry-delete.json", + workflowVersionHash: "hash-retry-delete", + maxConcurrentTickets: 3, + } + : null, + ), + listTickets: (inputBoardId) => + Effect.succeed( + inputBoardId === boardId && ticketRows > 0 + ? [ + { + ticketId: "ticket-retry-delete", + boardId, + title: "Retry ticket", + description: null, + currentLaneKey: "backlog", + currentLaneEntryToken: null, + queuedAt: null, + totalTokens: null, + totalDurationMs: null, + status: "idle", + }, + ] + : [], + ), + deleteBoardTicketState: () => + Effect.sync(() => { + ticketRows = 0; + outboxRows = 0; + setupRows = 0; + }), + deleteTicketState: () => Effect.void, + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => + Effect.sync(() => { + boardProjectionPresent = false; + }).pipe( + Effect.andThen( + failProjectionDeleteOnce + ? Effect.sync(() => { + failProjectionDeleteOnce = false; + }).pipe( + Effect.andThen( + Effect.fail( + new WorkflowEventStoreError({ + message: "simulated post-projection failure", + }), + ), + ), + ) + : Effect.void, + ), + ), + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + eventStore: { + deleteForBoard: () => + Effect.sync(() => { + eventRows = 0; + }), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: { + record: () => Effect.void, + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: () => + Effect.sync(() => { + versionRows = 0; + }), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/workspace/project-rpc"), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.void, + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + let firstAttemptFailed = false; + yield* invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.deleteBoard, { + boardId, + }).pipe( + Effect.catch((error) => + Effect.sync(() => { + firstAttemptFailed = error.message === "Failed to delete workflow board state"; + }), + ), + ); + assert.isTrue(firstAttemptFailed); + assert.isFalse(boardProjectionPresent); + assert.equal(versionRows, 0); + + versionRows = 1; + ticketRows = 1; + eventRows = 1; + outboxRows = 1; + setupRows = 1; + + yield* invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.deleteBoard, { boardId }); + + assert.deepEqual( + { + boardProjectionPresent, + versionRows, + ticketRows, + eventRows, + outboxRows, + setupRows, + }, + { + boardProjectionPresent: false, + versionRows: 0, + ticketRows: 0, + eventRows: 0, + outboxRows: 0, + setupRows: 0, + }, + ); + }), +); + +it.effect("workflowRpcHandlers rejects deleteBoard whose derived path is not a board file", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-rpc__unsafe-delete"); + const sideEffects: string[] = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => + Effect.succeed({ + boardId, + projectId: "project-rpc", + name: "Unsafe Delete", + workflowFilePath: ".t3/boards/../escape.json", + workflowVersionHash: "hash-unsafe-delete", + maxConcurrentTickets: 3, + }), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => + Effect.sync(() => { + sideEffects.push("delete-projection"); + }), + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => + Effect.sync(() => { + sideEffects.push("unregister"); + }), + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: { + record: () => Effect.void, + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: () => + Effect.sync(() => { + sideEffects.push("delete-versions"); + }), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.die("resolve must not run for unsafe delete paths"), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => + Effect.sync(() => { + sideEffects.push("delete-file"); + }), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const result = yield* Effect.exit( + invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.deleteBoard, { boardId }), + ); + + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.isTrue(result.cause.toString().includes("not a deletable workflow board file")); + } + assert.deepEqual(sideEffects, []); + }), +); + +it.effect("workflowRpcHandlers includes route history in ticket detail", () => + Effect.gen(function* () { + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => Effect.succeed(null), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => + Effect.succeed({ + ticket: { + ticketId: "ticket-route-rpc", + boardId: "board-route-rpc", + title: "Routed", + description: null, + currentLaneKey: "review", + currentLaneEntryToken: null, + queuedAt: null, + totalTokens: null, + totalDurationMs: null, + status: "idle", + }, + steps: [], + messages: [], + } as never), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => + Effect.succeed([ + { + occurredAt: "2026-06-07T00:00:01.000Z", + fromLane: "implement", + toLane: "review", + source: "lane_transition" as const, + matchedTransitionIndex: 1, + eventName: null, + pipelineResult: "success" as const, + laneRunCount: 2, + steps: { + verdict: { status: "completed", exitCode: 0, verdict: "approve" }, + }, + }, + { + occurredAt: "2026-06-07T00:00:02.000Z", + fromLane: null, + toLane: "implement", + source: "manual" as const, + matchedTransitionIndex: null, + eventName: null, + pipelineResult: null, + laneRunCount: null, + steps: null, + }, + ]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/tmp/project"), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: noopVersionStore, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const detail = yield* handlers[WORKFLOW_WS_METHODS.getTicketDetail]({ + ticketId: TicketId.make("ticket-route-rpc"), + }); + + assert.equal(detail.routeHistory?.length, 2); + const first = detail.routeHistory?.[0]; + assert.equal(first?.fromLane, "implement"); + assert.equal(first?.source, "lane_transition"); + assert.equal(first?.matchedTransitionIndex, 1); + assert.equal(first?.pipelineResult, "success"); + assert.equal(first?.laneRunCount, 2); + assert.deepEqual(first?.steps?.["verdict"], { + status: "completed", + exitCode: 0, + verdict: "approve", + }); + const second = detail.routeHistory?.[1]; + assert.equal(second?.source, "manual"); + assert.equal(second?.fromLane, undefined); + assert.equal(second?.matchedTransitionIndex, undefined); + assert.equal(second?.steps, undefined); + }), +); + +it.effect("workflowRpcHandlers delegates project script trust updates", () => + Effect.gen(function* () { + const projectId = "project-trust-rpc" as ProjectId; + const updates: Array<{ readonly projectId: ProjectId; readonly trusted: boolean }> = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => Effect.succeed(null), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/tmp/project"), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + projectScriptTrust: { + isTrusted: () => Effect.die("unused"), + setTrusted: (inputProjectId, trusted) => + Effect.sync(() => { + updates.push({ projectId: inputProjectId, trusted }); + }), + }, + versionStore: noopVersionStore, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + yield* handlers[WORKFLOW_WS_METHODS.setProjectScriptTrust]({ + projectId, + trusted: true, + }); + + assert.deepEqual(updates, [{ projectId, trusted: true }]); + }), +); + +it.effect("workflowRpcHandlers delegates cooperative step cancellation", () => + Effect.gen(function* () { + const stepRunId = StepRunId.make("step-run-cancel-rpc"); + const cancelled: StepRunId[] = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + completeRecoveredStep: () => Effect.void, + recoverBoardWip: () => Effect.void, + cancelStep: (inputStepRunId) => + Effect.sync(() => { + cancelled.push(inputStepRunId); + }), + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + }, + readModel: { + getBoard: () => Effect.succeed(null), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/tmp/project"), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("unused"), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: noopVersionStore, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + yield* handlers[WORKFLOW_WS_METHODS.cancelStep]({ stepRunId }); + + assert.deepEqual(cancelled, [stepRunId]); + }), +); + +it.effect("workflowRpcHandlers gets and saves encoded board definitions", () => + Effect.gen(function* () { + const projectId = "project-editor-rpc" as ProjectId; + const boardId = BoardId.make("project-editor-rpc__delivery"); + const workspaceRoot = "/tmp/editor-rpc-project"; + const workflowFilePath = ".t3/boards/delivery.json"; + const originalDefinition = yield* decodeWorkflowDefinition({ + name: "Delivery", + lanes: [ + { + key: "run", + name: "Run", + entry: "auto", + pipeline: [{ key: "smoke", type: "script", run: "pnpm test", timeout: "5 minutes" }], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const editedDefinition = yield* decodeWorkflowDefinition({ + name: "Delivery Edited", + lanes: [ + { key: "queue", name: "Queue", entry: "manual", wipLimit: 2 }, + { + key: "run", + name: "Run", + entry: "auto", + pipeline: [{ key: "smoke", type: "script", run: "pnpm test", timeout: "5 minutes" }], + transitions: [{ when: { var: "pipeline.result" }, to: "done" }], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const editedDefinitionEncoded = encodeWorkflowDefinition(editedDefinition); + const originalRaw = `${encodeWorkflowDefinitionJson(originalDefinition)}\n`; + const originalHash = sha256Hex(originalRaw); + let fileContents = originalRaw; + let registryDefinition = originalDefinition; + let boardRow = { + boardId, + projectId, + name: originalDefinition.name, + workflowFilePath, + workflowVersionHash: originalHash, + maxConcurrentTickets: 3, + }; + const writes: Array<{ + readonly cwd: string; + readonly relativePath: string; + readonly contents: string; + }> = []; + const versionRecords: WorkflowBoardVersionRecordInput[] = []; + let failNextVersionRecord = false; + let failedVersionRecordAttempts = 0; + const lintedDefinitions: WorkflowDefinitionType[] = []; + const loadedBoards: Array<{ + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly relativePath: string; + }> = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => Effect.succeed(boardRow), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: (input) => + Effect.sync(() => { + lintedDefinitions.push(input.definition); + return []; + }), + loadAndRegister: (input) => + Effect.sync(() => { + loadedBoards.push(input); + registryDefinition = editedDefinition; + boardRow = { + ...boardRow, + name: editedDefinition.name, + workflowVersionHash: sha256Hex(fileContents), + }; + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: { + record: (input) => + failNextVersionRecord + ? Effect.sync(() => { + failNextVersionRecord = false; + failedVersionRecordAttempts += 1; + }).pipe( + Effect.andThen( + Effect.fail( + new WorkflowEventStoreError({ message: "version record unavailable" }), + ), + ), + ) + : Effect.sync(() => { + versionRecords.push(input); + }), + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: (input) => + Effect.sync(() => { + assert.deepEqual(input, { cwd: workspaceRoot, relativePath: workflowFilePath }); + return fileContents; + }), + writeFile: (input) => + Effect.sync(() => { + writes.push(input); + fileContents = input.contents; + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const loaded = yield* invokeWorkflowHandler<{ + readonly definition: unknown; + readonly versionHash: string; + }>(handlers, WORKFLOW_WS_METHODS.getBoardDefinition, { boardId }); + assert.equal(loaded.versionHash, originalHash); + const loadedStep = ( + (loaded.definition as { readonly lanes: readonly unknown[] }).lanes[0] as { + readonly pipeline?: readonly unknown[]; + } + ).pipeline?.[0] as { readonly timeout?: unknown } | undefined; + assert.isDefined(loadedStep); + assert.isString(loadedStep.timeout); + + const saved = yield* invokeWorkflowHandler< + | { + readonly ok: true; + readonly definition: unknown; + readonly versionHash: string; + readonly snapshot: { readonly board: { readonly name: string } }; + } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: editedDefinitionEncoded, + expectedVersionHash: originalHash, + workflowFilePath: ".t3/boards/client-supplied.json", + }); + + assert.equal(saved.ok, true); + if (saved.ok !== true) { + assert.fail("expected successful save"); + } + assert.equal(saved.versionHash, sha256Hex(writes[0]!.contents)); + assert.equal(saved.snapshot.board.name, "Delivery Edited"); + assert.equal(lintedDefinitions[0]?.name, "Delivery Edited"); + assert.deepEqual( + versionRecords.map((record) => ({ + boardId: record.boardId, + versionHash: record.versionHash, + contentJson: record.contentJson, + source: record.source, + })), + [ + { + boardId, + versionHash: sha256Hex(writes[0]!.contents), + contentJson: writes[0]!.contents, + source: "save", + }, + ], + ); + assert.deepEqual( + writes.map((write) => ({ + cwd: write.cwd, + relativePath: write.relativePath, + })), + [{ cwd: workspaceRoot, relativePath: workflowFilePath }], + ); + const writtenDefinition = yield* decodeWorkflowDefinitionJson(writes[0]!.contents); + assert.equal(writtenDefinition.name, "Delivery Edited"); + const writtenStep = writtenDefinition.lanes[1]?.pipeline?.[0]; + assert.isDefined(writtenStep); + assert.equal(writtenStep.type, "script"); + assert.deepEqual(loadedBoards, [ + { boardId, projectId, workspaceRoot, relativePath: workflowFilePath }, + ]); + const savedStep = ( + (saved.definition as { readonly lanes: readonly unknown[] }).lanes[1] as { + readonly pipeline?: readonly unknown[]; + } + ).pipeline?.[0] as { readonly timeout?: unknown } | undefined; + assert.isDefined(savedStep); + assert.isString(savedStep.timeout); + + const revertedDefinition = yield* decodeWorkflowDefinition({ + name: "Delivery Reverted", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const reverted = yield* invokeWorkflowHandler< + | { + readonly ok: true; + readonly definition: unknown; + readonly versionHash: string; + } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(revertedDefinition), + expectedVersionHash: saved.versionHash, + source: "revert", + }); + assert.equal(reverted.ok, true); + if (reverted.ok !== true) { + assert.fail("expected successful revert save"); + } + assert.equal(versionRecords.at(-1)?.source, "revert"); + assert.equal(versionRecords.at(-1)?.contentJson, writes.at(-1)?.contents); + + const afterBestEffortFailureDefinition = yield* decodeWorkflowDefinition({ + name: "Delivery After History Failure", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + failNextVersionRecord = true; + const savedDespiteHistoryFailure = yield* invokeWorkflowHandler< + | { readonly ok: true; readonly versionHash: string } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(afterBestEffortFailureDefinition), + expectedVersionHash: reverted.versionHash, + }); + assert.equal(savedDespiteHistoryFailure.ok, true); + assert.equal(failedVersionRecordAttempts, 1); + }), +); + +it.effect( + "workflowRpcHandlers renames a board display name in file, projection, registry, and history", + () => + Effect.gen(function* () { + const projectId = "project-rename-rpc" as ProjectId; + const boardId = BoardId.make("project-rename-rpc__delivery"); + const workspaceRoot = "/tmp/rename-rpc-project"; + const workflowFilePath = ".t3/boards/delivery.json"; + const originalDefinition = yield* decodeWorkflowDefinition({ + name: "Delivery", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + let fileContents = `${encodeWorkflowDefinitionJson(originalDefinition)}\n`; + let registryDefinition = originalDefinition; + let boardRow = { + boardId, + projectId, + name: originalDefinition.name, + workflowFilePath, + workflowVersionHash: sha256Hex(fileContents), + maxConcurrentTickets: 3, + }; + const writes: Array<{ + readonly cwd: string; + readonly relativePath: string; + readonly contents: string; + }> = []; + const versionRecords: WorkflowBoardVersionRecordInput[] = []; + const lintedDefinitions: WorkflowDefinitionType[] = []; + const loadedBoards: Array<{ + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly relativePath: string; + }> = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => Effect.succeed(boardRow), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: (input) => + Effect.sync(() => { + lintedDefinitions.push(input.definition); + return []; + }), + loadAndRegister: (input) => + Effect.gen(function* () { + loadedBoards.push(input); + const definition = yield* decodeWorkflowDefinitionJson(fileContents).pipe( + Effect.orDie, + ); + registryDefinition = definition; + boardRow = { + ...boardRow, + name: definition.name, + workflowVersionHash: sha256Hex(fileContents), + }; + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: { + record: (input) => + Effect.sync(() => { + versionRecords.push(input); + }), + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: (input) => + Effect.sync(() => { + assert.deepEqual(input, { cwd: workspaceRoot, relativePath: workflowFilePath }); + return fileContents; + }), + writeFile: (input) => + Effect.sync(() => { + writes.push(input); + fileContents = input.contents; + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + saveLocks: yield* makeWorkflowBoardSaveLocks, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + yield* invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.renameBoard, { + boardId, + name: "Delivery Renamed", + }); + + assert.equal(boardRow.name, "Delivery Renamed"); + assert.equal(registryDefinition.name, "Delivery Renamed"); + assert.equal(lintedDefinitions[0]?.name, "Delivery Renamed"); + assert.deepEqual( + writes.map((write) => ({ + cwd: write.cwd, + relativePath: write.relativePath, + })), + [{ cwd: workspaceRoot, relativePath: workflowFilePath }], + ); + const writtenDefinition = yield* decodeWorkflowDefinitionJson(writes[0]!.contents); + assert.equal(writtenDefinition.name, "Delivery Renamed"); + assert.deepEqual(loadedBoards, [ + { boardId, projectId, workspaceRoot, relativePath: workflowFilePath }, + ]); + assert.deepEqual( + versionRecords.map((record) => ({ + boardId: record.boardId, + versionHash: record.versionHash, + contentJson: record.contentJson, + source: record.source, + })), + [ + { + boardId, + versionHash: sha256Hex(writes[0]!.contents), + contentJson: writes[0]!.contents, + source: "rename", + }, + ], + ); + }), +); + +it.effect( + "workflowRpcHandlers repairs a same-name retry after registration failed post-write", + () => + Effect.gen(function* () { + const projectId = "project-rename-rpc" as ProjectId; + const boardId = BoardId.make("project-rename-rpc__retry"); + const workspaceRoot = "/tmp/rename-rpc-retry"; + const workflowFilePath = ".t3/boards/retry.json"; + const originalDefinition = yield* decodeWorkflowDefinition({ + name: "Delivery", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + let fileContents = `${encodeWorkflowDefinitionJson(originalDefinition)}\n`; + let registryDefinition = originalDefinition; + let boardRow = { + boardId, + projectId, + name: originalDefinition.name, + workflowFilePath, + workflowVersionHash: sha256Hex(fileContents), + maxConcurrentTickets: 3, + }; + let failNextRegistration = true; + const writes: Array<{ + readonly cwd: string; + readonly relativePath: string; + readonly contents: string; + }> = []; + const loadedBoards: Array<{ + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly relativePath: string; + }> = []; + const versionRecords: WorkflowBoardVersionRecordInput[] = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => Effect.succeed(boardRow), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => + Effect.gen(function* () { + loadedBoards.push(input); + if (failNextRegistration) { + failNextRegistration = false; + return yield* new WorkflowRpcError({ message: "registration unavailable" }); + } + + const definition = yield* decodeWorkflowDefinitionJson(fileContents).pipe( + Effect.orDie, + ); + registryDefinition = definition; + boardRow = { + ...boardRow, + name: definition.name, + workflowVersionHash: sha256Hex(fileContents), + }; + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: { + record: (input) => + Effect.sync(() => { + versionRecords.push(input); + }), + list: () => + Effect.succeed( + versionRecords.map((record, index) => ({ + versionId: versionRecords.length - index, + versionHash: record.versionHash, + source: record.source, + createdAt: `2026-06-08T00:00:0${index}.000Z`, + })), + ), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(fileContents), + writeFile: (input) => + Effect.sync(() => { + writes.push(input); + fileContents = input.contents; + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + saveLocks: yield* makeWorkflowBoardSaveLocks, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const failed = yield* Effect.exit( + invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.renameBoard, { + boardId, + name: "Delivery Renamed", + }), + ); + assert.strictEqual(failed._tag, "Failure"); + assert.equal(boardRow.name, "Delivery"); + assert.equal(registryDefinition.name, "Delivery"); + const failedWrite = yield* decodeWorkflowDefinitionJson(writes[0]!.contents); + assert.equal(failedWrite.name, "Delivery Renamed"); + + yield* invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.renameBoard, { + boardId, + name: "Delivery Renamed", + }); + + assert.equal(boardRow.name, "Delivery Renamed"); + assert.equal(registryDefinition.name, "Delivery Renamed"); + assert.deepEqual( + writes.map((write) => write.relativePath), + [workflowFilePath], + ); + assert.deepEqual( + loadedBoards.map((loaded) => loaded.relativePath), + [workflowFilePath, workflowFilePath], + ); + assert.deepEqual( + versionRecords.map((record) => ({ + boardId: record.boardId, + versionHash: record.versionHash, + contentJson: record.contentJson, + source: record.source, + })), + [ + { + boardId, + versionHash: sha256Hex(fileContents), + contentJson: fileContents, + source: "rename", + }, + ], + ); + }), +); + +it.effect("workflowRpcHandlers rejects blank board rename names before touching the file", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-rename-rpc__blank"); + const sideEffects: string[] = []; + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => + Effect.sync(() => { + sideEffects.push("get-board"); + return null; + }), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => + Effect.sync(() => { + sideEffects.push("lint"); + return []; + }), + loadAndRegister: () => + Effect.sync(() => { + sideEffects.push("load"); + return boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => + Effect.sync(() => { + sideEffects.push("resolve"); + return "/tmp/blank-rename"; + }), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => + Effect.sync(() => { + sideEffects.push("read"); + return "{}"; + }), + writeFile: () => + Effect.sync(() => { + sideEffects.push("write"); + return { relativePath: ".t3/boards/blank.json" }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const blank = yield* Effect.exit( + invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.renameBoard, { + boardId, + name: " ", + }), + ); + + assert.strictEqual(blank._tag, "Failure"); + assert.deepEqual(sideEffects, []); + + const overlong = yield* Effect.exit( + invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.renameBoard, { + boardId, + name: "A".repeat(129), + }), + ); + assert.strictEqual(overlong._tag, "Failure"); + assert.deepEqual(sideEffects, []); + }), +); + +it.effect("workflowRpcHandlers treats unchanged board rename names as a no-op", () => + Effect.gen(function* () { + const projectId = "project-rename-rpc" as ProjectId; + const boardId = BoardId.make("project-rename-rpc__unchanged"); + const workspaceRoot = "/tmp/rename-rpc-unchanged"; + const workflowFilePath = ".t3/boards/unchanged.json"; + const definition = yield* decodeWorkflowDefinition({ + name: "Delivery", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }); + const fileContents = `${encodeWorkflowDefinitionJson(definition)}\n`; + const sideEffects: string[] = []; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => + Effect.succeed({ + boardId, + projectId, + name: "Delivery", + workflowFilePath, + workflowVersionHash: sha256Hex(fileContents), + maxConcurrentTickets: 3, + }), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(definition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => + Effect.sync(() => { + sideEffects.push("lint"); + return []; + }), + loadAndRegister: () => + Effect.sync(() => { + sideEffects.push("load"); + return boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: { + record: () => + Effect.sync(() => { + sideEffects.push("version"); + }), + list: () => + Effect.succeed([ + { + versionId: 1, + versionHash: sha256Hex(fileContents), + source: "rename", + createdAt: "2026-06-08T00:00:00.000Z", + }, + ]), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(fileContents), + writeFile: () => + Effect.sync(() => { + sideEffects.push("write"); + return { relativePath: workflowFilePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + saveLocks: yield* makeWorkflowBoardSaveLocks, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + yield* invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.renameBoard, { + boardId, + name: "Delivery", + }); + + assert.deepEqual(sideEffects, []); + }), +); + +it.effect("workflowRpcHandlers reports missing boards during rename without writing", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-rename-rpc__missing"); + const sideEffects: string[] = []; + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => Effect.succeed(null), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(null), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => + Effect.sync(() => { + sideEffects.push("lint"); + return []; + }), + loadAndRegister: () => + Effect.sync(() => { + sideEffects.push("load"); + return boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => + Effect.sync(() => { + sideEffects.push("resolve"); + return "/tmp/missing-rename"; + }), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => + Effect.sync(() => { + sideEffects.push("read"); + return "{}"; + }), + writeFile: () => + Effect.sync(() => { + sideEffects.push("write"); + return { relativePath: ".t3/boards/missing.json" }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + saveLocks: yield* makeWorkflowBoardSaveLocks, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const result = yield* Effect.exit( + invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.renameBoard, { + boardId, + name: "Missing renamed", + }), + ); + + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.isTrue(result.cause.toString().includes(`Workflow board ${boardId} was not found`)); + } + assert.deepEqual(sideEffects, []); + }), +); + +it.effect( + "workflowRpcHandlers serializes rename racing delete without resurrecting board state", + () => + Effect.gen(function* () { + const projectId = "project-rename-rpc" as ProjectId; + const boardId = BoardId.make("project-rename-rpc__race-delete"); + const workspaceRoot = "/tmp/rename-rpc-race-delete"; + const workflowFilePath = ".t3/boards/race-delete.json"; + const originalDefinition = yield* decodeWorkflowDefinition({ + name: "Race Delete", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }); + let filePresent = true; + let fileContents = `${encodeWorkflowDefinitionJson(originalDefinition)}\n`; + let registryDefinition: WorkflowDefinitionType | null = originalDefinition; + let boardProjectionPresent = true; + let boardRow = { + boardId, + projectId, + name: originalDefinition.name, + workflowFilePath, + workflowVersionHash: sha256Hex(fileContents), + maxConcurrentTickets: 3, + }; + const versionRecords: WorkflowBoardVersionRecordInput[] = []; + const renameWriteStarted = yield* Deferred.make(); + const allowRenameWrite = yield* Deferred.make(); + const saveLocks = yield* makeWorkflowBoardSaveLocks; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => Effect.succeed(boardProjectionPresent ? boardRow : null), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => + Effect.sync(() => { + boardProjectionPresent = false; + }), + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => + Effect.sync(() => { + registryDefinition = null; + }), + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinitionJson(fileContents).pipe( + Effect.orDie, + ); + registryDefinition = definition; + boardRow = { + ...boardRow, + name: definition.name, + workflowVersionHash: sha256Hex(fileContents), + }; + boardProjectionPresent = true; + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: { + record: (input) => + Effect.sync(() => { + versionRecords.push(input); + }), + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: () => + Effect.sync(() => { + versionRecords.splice(0, versionRecords.length); + }), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(fileContents), + writeFile: (input) => + Effect.gen(function* () { + yield* Deferred.succeed(renameWriteStarted, undefined); + yield* Deferred.await(allowRenameWrite); + filePresent = true; + fileContents = input.contents; + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => + Effect.sync(() => { + filePresent = false; + }), + }, + saveLocks, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const renameFiber = yield* invokeWorkflowHandler( + handlers, + WORKFLOW_WS_METHODS.renameBoard, + { + boardId, + name: "Race Delete Renamed", + }, + ).pipe(Effect.forkChild); + yield* Deferred.await(renameWriteStarted); + const deleteFiber = yield* invokeWorkflowHandler( + handlers, + WORKFLOW_WS_METHODS.deleteBoard, + { + boardId, + }, + ).pipe(Effect.forkChild); + yield* Deferred.succeed(allowRenameWrite, undefined); + + yield* Fiber.join(renameFiber); + yield* Fiber.join(deleteFiber); + + assert.isFalse(filePresent); + assert.isFalse(boardProjectionPresent); + assert.isNull(registryDefinition); + assert.deepEqual(versionRecords, []); + }), +); + +it.effect("workflowRpcHandlers lists board versions and lazy-imports missing history", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-version-rpc__delivery"); + const otherBoardId = BoardId.make("project-version-rpc__other"); + const projectId = "project-version-rpc" as ProjectId; + const workspaceRoot = "/tmp/project-version-rpc-root"; + const workflowFilePath = ".t3/boards/delivery.json"; + const importedDefinition = yield* decodeWorkflowDefinition({ + name: "Imported Delivery", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const savedDefinition = yield* decodeWorkflowDefinition({ + name: "Saved Delivery", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "review", name: "Review", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const importedRaw = `${encodeWorkflowDefinitionJson(importedDefinition)}\n`; + const savedRaw = `${encodeWorkflowDefinitionJson(savedDefinition)}\n`; + const importedHash = sha256Hex(importedRaw); + const savedHash = sha256Hex(savedRaw); + const recorded: WorkflowBoardVersionRecordInput[] = []; + const versions: Array<{ + readonly boardId: BoardId; + readonly versionId: number; + readonly versionHash: string; + readonly contentJson: string; + readonly source: WorkflowBoardVersionSource; + readonly createdAt: string; + }> = []; + let nextVersionId = 1; + + const addVersion = (input: WorkflowBoardVersionRecordInput, createdAt: string) => { + versions.push({ + boardId: input.boardId, + versionId: nextVersionId, + versionHash: input.versionHash, + contentJson: input.contentJson, + source: input.source, + createdAt, + }); + nextVersionId += 1; + }; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: (inputBoardId) => + Effect.succeed( + inputBoardId === boardId + ? { + boardId, + projectId, + name: "Imported Delivery", + workflowFilePath, + workflowVersionHash: importedHash, + maxConcurrentTickets: 3, + } + : null, + ), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.die("unused"), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => Effect.die("unused"), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: { + record: (input) => + Effect.sync(() => { + recorded.push(input); + addVersion(input, "2026-06-08T12:00:00.000Z"); + }), + list: (inputBoardId) => + Effect.succeed( + versions + .filter((version) => version.boardId === inputBoardId) + .toSorted((left, right) => right.versionId - left.versionId) + .map(({ contentJson: _contentJson, boardId: _boardId, ...summary }) => summary), + ), + get: (inputBoardId, versionId) => + Effect.succeed( + versions.find( + (version) => version.boardId === inputBoardId && version.versionId === versionId, + ) ?? null, + ), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: (inputProjectId) => + Effect.sync(() => { + assert.equal(inputProjectId, projectId); + return workspaceRoot; + }), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: (input) => + Effect.sync(() => { + assert.deepEqual(input, { cwd: workspaceRoot, relativePath: workflowFilePath }); + return importedRaw; + }), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const importedVersions = yield* invokeWorkflowHandler< + ReadonlyArray<{ + readonly versionId: number; + readonly versionHash: string; + readonly source: string; + readonly createdAt: string; + readonly isCurrent: boolean; + }> + >(handlers, WORKFLOW_WS_METHODS.listBoardVersions, { boardId }); + + assert.deepEqual(recorded, [ + { + boardId, + versionHash: importedHash, + contentJson: importedRaw, + source: "import", + }, + ]); + assert.deepEqual(importedVersions, [ + { + versionId: 1, + versionHash: importedHash, + source: "import", + createdAt: "2026-06-08T12:00:00.000Z", + isCurrent: true, + }, + ]); + assert.equal("contentJson" in importedVersions[0]!, false); + + addVersion( + { + boardId, + versionHash: savedHash, + contentJson: savedRaw, + source: "save", + }, + "2026-06-08T12:05:00.000Z", + ); + const listedVersions = yield* invokeWorkflowHandler< + ReadonlyArray<{ + readonly versionId: number; + readonly versionHash: string; + readonly source: string; + readonly createdAt: string; + readonly isCurrent: boolean; + }> + >(handlers, WORKFLOW_WS_METHODS.listBoardVersions, { boardId }); + assert.deepEqual(listedVersions, [ + { + versionId: 2, + versionHash: savedHash, + source: "save", + createdAt: "2026-06-08T12:05:00.000Z", + isCurrent: true, + }, + { + versionId: 1, + versionHash: importedHash, + source: "import", + createdAt: "2026-06-08T12:00:00.000Z", + isCurrent: false, + }, + ]); + + const importedVersion = yield* invokeWorkflowHandler<{ + readonly versionId: number; + readonly definition: unknown; + readonly versionHash: string; + readonly source: string; + readonly createdAt: string; + }>(handlers, WORKFLOW_WS_METHODS.getBoardVersion, { boardId, versionId: 1 }); + assert.equal(importedVersion.versionId, 1); + assert.equal( + (importedVersion.definition as { readonly name: string }).name, + "Imported Delivery", + ); + assert.equal(importedVersion.versionHash, importedHash); + assert.equal(importedVersion.source, "import"); + assert.equal(importedVersion.createdAt, "2026-06-08T12:00:00.000Z"); + + const missingVersion = yield* Effect.exit( + invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.getBoardVersion, { + boardId, + versionId: 999, + }), + ); + assert.strictEqual(missingVersion._tag, "Failure"); + + const wrongBoardVersion = yield* Effect.exit( + invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.getBoardVersion, { + boardId: otherBoardId, + versionId: 1, + }), + ); + assert.strictEqual(wrongBoardVersion._tag, "Failure"); + }), +); + +it.effect("workflowRpcHandlers records only one lazy import for concurrent history opens", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-version-rpc__concurrent-import"); + const projectId = "project-version-rpc" as ProjectId; + const workspaceRoot = "/tmp/project-version-rpc-root"; + const workflowFilePath = ".t3/boards/concurrent-import.json"; + const importedDefinition = yield* decodeWorkflowDefinition({ + name: "Concurrent Import", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }); + const importedRaw = `${encodeWorkflowDefinitionJson(importedDefinition)}\n`; + const importedHash = sha256Hex(importedRaw); + const recorded: WorkflowBoardVersionRecordInput[] = []; + const versions: Array<{ + readonly boardId: BoardId; + readonly versionId: number; + readonly versionHash: string; + readonly contentJson: string; + readonly source: WorkflowBoardVersionSource; + readonly createdAt: string; + }> = []; + let nextVersionId = 1; + let initialListCalls = 0; + const initialListsEntered = yield* Deferred.make(); + const saveLocks = yield* makeWorkflowBoardSaveLocks; + + const addVersion = (input: WorkflowBoardVersionRecordInput) => { + versions.push({ + boardId: input.boardId, + versionId: nextVersionId, + versionHash: input.versionHash, + contentJson: input.contentJson, + source: input.source, + createdAt: "2026-06-08T12:00:00.000Z", + }); + nextVersionId += 1; + }; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => + Effect.succeed({ + boardId, + projectId, + name: importedDefinition.name, + workflowFilePath, + workflowVersionHash: importedHash, + maxConcurrentTickets: 3, + }), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.die("unused"), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + saveLocks, + fileLoader: { + lintDefinition: () => Effect.die("unused"), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: { + record: (input) => + Effect.sync(() => { + recorded.push(input); + addVersion(input); + }), + list: (inputBoardId) => + Effect.gen(function* () { + const snapshot = versions + .filter((version) => version.boardId === inputBoardId) + .toSorted((left, right) => right.versionId - left.versionId) + .map(({ contentJson: _contentJson, boardId: _boardId, ...summary }) => summary); + if (initialListCalls < 2) { + initialListCalls += 1; + if (initialListCalls === 2) { + yield* Deferred.succeed(initialListsEntered, undefined); + } else { + yield* Deferred.await(initialListsEntered); + } + } + return snapshot; + }), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(importedRaw), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const listVersions = invokeWorkflowHandler< + ReadonlyArray<{ + readonly source: string; + readonly isCurrent: boolean; + }> + >(handlers, WORKFLOW_WS_METHODS.listBoardVersions, { boardId }); + + const first = yield* listVersions.pipe(Effect.forkChild); + const second = yield* listVersions.pipe(Effect.forkChild); + const results = [yield* Fiber.join(first), yield* Fiber.join(second)]; + + assert.deepEqual(recorded, [ + { + boardId, + versionHash: importedHash, + contentJson: importedRaw, + source: "import", + }, + ]); + assert.deepEqual( + results.map((result) => result.map((version) => version.source)), + [["import"], ["import"]], + ); + }), +); + +it.effect("workflowRpcHandlers serializes createBoard against lazy history import", () => + Effect.gen(function* () { + const projectId = "project-create-import-race" as ProjectId; + const boardId = BoardId.make(`${projectId}__race-board`); + const workspaceRoot = "/tmp/project-create-import-race-root"; + const saveLocks = yield* makeWorkflowBoardSaveLocks; + const createdBoardRegistered = yield* Deferred.make(); + const versions: Array<{ + readonly boardId: BoardId; + readonly versionId: number; + readonly versionHash: string; + readonly contentJson: string; + readonly source: WorkflowBoardVersionSource; + readonly createdAt: string; + }> = []; + let nextVersionId = 1; + let fileContents = ""; + let registryDefinition: WorkflowDefinitionType | null = null; + let boardRow: { + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly name: string; + readonly workflowFilePath: string; + readonly workflowVersionHash: string; + readonly maxConcurrentTickets: number; + } | null = null; + + const versionSummaries = (inputBoardId: BoardId) => + versions + .filter((version) => version.boardId === inputBoardId) + .toSorted((left, right) => right.versionId - left.versionId) + .map(({ contentJson: _contentJson, boardId: _boardId, ...summary }) => summary); + + const recordVersion = (input: WorkflowBoardVersionRecordInput) => { + const newest = versionSummaries(input.boardId)[0]; + if (newest?.versionHash === input.versionHash) { + return; + } + versions.push({ + boardId: input.boardId, + versionId: nextVersionId, + versionHash: input.versionHash, + contentJson: input.contentJson, + source: input.source, + createdAt: "2026-06-08T12:00:00.000Z", + }); + nextVersionId += 1; + }; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: (inputBoardId) => Effect.succeed(inputBoardId === boardId ? boardRow : null), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + saveLocks, + fileLoader: { + lintDefinition: () => Effect.die("unused"), + loadAndRegister: (input) => + Effect.gen(function* () { + registryDefinition = yield* decodeWorkflowDefinitionJson(fileContents).pipe( + Effect.orDie, + ); + boardRow = { + boardId: input.boardId, + projectId: input.projectId, + name: registryDefinition.name, + workflowFilePath: input.relativePath, + workflowVersionHash: sha256Hex(fileContents), + maxConcurrentTickets: 3, + }; + yield* Deferred.succeed(createdBoardRegistered, undefined); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: { + record: (input) => Effect.sync(() => recordVersion(input)), + list: (inputBoardId) => Effect.sync(() => versionSummaries(inputBoardId)), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(fileContents), + writeFile: () => Effect.die("unused"), + createFileExclusive: (input) => + Effect.sync(() => { + fileContents = input.contents; + return { relativePath: input.relativePath }; + }), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const createFiber = yield* invokeWorkflowHandler<{ + readonly boardId: BoardId; + }>(handlers, WORKFLOW_WS_METHODS.createBoard, { + projectId, + name: "Race Board", + agent: { instance: "codex_main", model: "gpt-5.5" }, + }).pipe(Effect.forkChild); + + yield* Deferred.await(createdBoardRegistered); + const listFiber = yield* invokeWorkflowHandler< + ReadonlyArray<{ + readonly source: string; + readonly isCurrent: boolean; + }> + >(handlers, WORKFLOW_WS_METHODS.listBoardVersions, { boardId }).pipe(Effect.forkChild); + + const created = yield* Fiber.join(createFiber); + const listed = yield* Fiber.join(listFiber); + + assert.equal(created.boardId, boardId); + assert.deepEqual( + versions.map((version) => version.source), + ["create"], + ); + assert.deepEqual( + listed.map((version) => ({ source: version.source, isCurrent: version.isCurrent })), + [{ source: "create", isCurrent: true }], + ); + }), +); + +it.effect("workflowRpcHandlers skips lazy import when history appears after an empty read", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-version-rpc__history-populated"); + const projectId = "project-version-rpc" as ProjectId; + const workspaceRoot = "/tmp/project-version-rpc-root"; + const workflowFilePath = ".t3/boards/history-populated.json"; + const importedDefinition = yield* decodeWorkflowDefinition({ + name: "Imported Before Existing Save", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }); + const savedDefinition = yield* decodeWorkflowDefinition({ + name: "Existing Save", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const importedRaw = `${encodeWorkflowDefinitionJson(importedDefinition)}\n`; + const savedRaw = `${encodeWorkflowDefinitionJson(savedDefinition)}\n`; + const importedHash = sha256Hex(importedRaw); + const savedHash = sha256Hex(savedRaw); + const recorded: WorkflowBoardVersionRecordInput[] = []; + const versions: Array<{ + readonly boardId: BoardId; + readonly versionId: number; + readonly versionHash: string; + readonly contentJson: string; + readonly source: WorkflowBoardVersionSource; + readonly createdAt: string; + }> = [ + { + boardId, + versionId: 1, + versionHash: savedHash, + contentJson: savedRaw, + source: "save", + createdAt: "2026-06-08T12:05:00.000Z", + }, + ]; + let nextVersionId = 2; + let listCalls = 0; + const saveLocks = yield* makeWorkflowBoardSaveLocks; + + const addVersion = (input: WorkflowBoardVersionRecordInput) => { + versions.push({ + boardId: input.boardId, + versionId: nextVersionId, + versionHash: input.versionHash, + contentJson: input.contentJson, + source: input.source, + createdAt: "2026-06-08T12:00:00.000Z", + }); + nextVersionId += 1; + }; + + const versionSummaries = (inputBoardId: BoardId) => + versions + .filter((version) => version.boardId === inputBoardId) + .toSorted((left, right) => right.versionId - left.versionId) + .map(({ contentJson: _contentJson, boardId: _boardId, ...summary }) => summary); + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => + Effect.succeed({ + boardId, + projectId, + name: importedDefinition.name, + workflowFilePath, + workflowVersionHash: importedHash, + maxConcurrentTickets: 3, + }), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(importedDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + saveLocks, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: { + record: (input) => + Effect.sync(() => { + recorded.push(input); + addVersion(input); + }), + list: (inputBoardId) => + Effect.sync(() => { + listCalls += 1; + return listCalls === 1 ? [] : versionSummaries(inputBoardId); + }), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(importedRaw), + writeFile: () => Effect.die("unused"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const listedVersions = yield* invokeWorkflowHandler< + ReadonlyArray<{ + readonly source: string; + readonly isCurrent: boolean; + }> + >(handlers, WORKFLOW_WS_METHODS.listBoardVersions, { boardId }); + assert.deepEqual(recorded, []); + assert.deepEqual( + listedVersions.map((version) => ({ + source: version.source, + isCurrent: version.isCurrent, + })), + [{ source: "save", isCurrent: true }], + ); + }), +); + +versionRoundTripLayer("workflowRpcHandlers version history round trip", (it) => { + it.effect("imports, saves, loads, and re-saves a reverted board version", () => + Effect.gen(function* () { + const versionStore = yield* WorkflowBoardVersionStore; + const boardId = BoardId.make("project-version-round-trip__delivery"); + const projectId = "project-version-round-trip" as ProjectId; + const workspaceRoot = "/tmp/project-version-round-trip-root"; + const workflowFilePath = ".t3/boards/delivery.json"; + const importedDefinition = yield* decodeWorkflowDefinition({ + name: "Imported Delivery", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const savedDefinition = yield* decodeWorkflowDefinition({ + name: "Saved Delivery", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "review", name: "Review", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const currentDefinition = yield* decodeWorkflowDefinition({ + name: "Current Delivery", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "review", name: "Review", entry: "auto" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + let fileContents = `${encodeWorkflowDefinitionJson(importedDefinition)}\n`; + let registryDefinition = importedDefinition; + let boardRow = { + boardId, + projectId, + name: importedDefinition.name, + workflowFilePath, + workflowVersionHash: sha256Hex(fileContents), + maxConcurrentTickets: 3, + }; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: (inputBoardId) => Effect.succeed(inputBoardId === boardId ? boardRow : null), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinitionJson(fileContents).pipe( + Effect.mapError( + (cause) => + new WorkflowRpcError({ + message: "round-trip workflow definition decode failed", + cause, + }), + ), + ); + registryDefinition = definition; + boardRow = { + ...boardRow, + name: definition.name, + workflowVersionHash: sha256Hex(fileContents), + }; + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(fileContents), + writeFile: (input) => + Effect.sync(() => { + fileContents = input.contents; + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const importedVersions = yield* invokeWorkflowHandler< + ReadonlyArray<{ + readonly versionId: number; + readonly source: string; + readonly isCurrent: boolean; + }> + >(handlers, WORKFLOW_WS_METHODS.listBoardVersions, { boardId }); + assert.deepEqual( + importedVersions.map((version) => ({ + source: version.source, + isCurrent: version.isCurrent, + })), + [{ source: "import", isCurrent: true }], + ); + + const firstSave = yield* invokeWorkflowHandler< + | { readonly ok: true; readonly versionHash: string } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(savedDefinition), + expectedVersionHash: boardRow.workflowVersionHash, + }); + assert.equal(firstSave.ok, true); + if (firstSave.ok !== true) { + assert.fail("expected first save to succeed"); + } + + const secondSave = yield* invokeWorkflowHandler< + | { readonly ok: true; readonly versionHash: string } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(currentDefinition), + expectedVersionHash: firstSave.versionHash, + }); + assert.equal(secondSave.ok, true); + if (secondSave.ok !== true) { + assert.fail("expected second save to succeed"); + } + + const versionsBeforeRevert = yield* invokeWorkflowHandler< + ReadonlyArray<{ + readonly versionId: number; + readonly versionHash: string; + readonly source: string; + readonly isCurrent: boolean; + }> + >(handlers, WORKFLOW_WS_METHODS.listBoardVersions, { boardId }); + assert.deepEqual( + versionsBeforeRevert.map((version) => ({ + source: version.source, + isCurrent: version.isCurrent, + })), + [ + { source: "save", isCurrent: true }, + { source: "save", isCurrent: false }, + { source: "import", isCurrent: false }, + ], + ); + + const importVersion = versionsBeforeRevert.at(-1); + assert.isDefined(importVersion); + const loadedImport = yield* invokeWorkflowHandler<{ + readonly versionId: number; + readonly definition: WorkflowDefinitionEncoded; + readonly versionHash: string; + readonly source: string; + }>(handlers, WORKFLOW_WS_METHODS.getBoardVersion, { + boardId, + versionId: importVersion.versionId, + }); + assert.equal(loadedImport.source, "import"); + assert.equal(loadedImport.definition.name, "Imported Delivery"); + + const revertSave = yield* invokeWorkflowHandler< + | { readonly ok: true; readonly versionHash: string } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: loadedImport.definition, + expectedVersionHash: secondSave.versionHash, + source: "revert", + }); + assert.equal(revertSave.ok, true); + + const versionsAfterRevert = yield* invokeWorkflowHandler< + ReadonlyArray<{ + readonly versionHash: string; + readonly source: string; + readonly isCurrent: boolean; + }> + >(handlers, WORKFLOW_WS_METHODS.listBoardVersions, { boardId }); + assert.deepEqual( + versionsAfterRevert.map((version) => ({ + versionHash: version.versionHash, + source: version.source, + isCurrent: version.isCurrent, + })), + [ + { + versionHash: loadedImport.versionHash, + source: "revert", + isCurrent: true, + }, + { + versionHash: secondSave.versionHash, + source: "save", + isCurrent: false, + }, + { + versionHash: firstSave.versionHash, + source: "save", + isCurrent: false, + }, + { + versionHash: loadedImport.versionHash, + source: "import", + isCurrent: false, + }, + ], + ); + }), + ); +}); + +it.effect("workflowRpcHandlers rejects lint-invalid board saves without writing", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-editor-rpc__invalid"); + const definition = yield* decodeWorkflowDefinition({ + name: "Invalid", + lanes: [{ key: "queue", name: "Queue", entry: "manual", wipLimit: 0 }], + }); + const definitionEncoded = encodeWorkflowDefinition(definition); + const currentRaw = `${encodeWorkflowDefinitionJson(definition)}\n`; + const currentHash = sha256Hex(currentRaw); + let writeCount = 0; + const lintErrors: ReadonlyArray = [ + { + code: "invalid_wip_limit", + message: "Lane queue wipLimit must be at least 1", + laneKey: "queue", + }, + ]; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => + Effect.succeed({ + boardId, + projectId: "project-editor-rpc", + name: "Invalid", + workflowFilePath: ".t3/boards/invalid.json", + workflowVersionHash: currentHash, + maxConcurrentTickets: 3, + }), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(definition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => Effect.succeed(lintErrors), + loadAndRegister: () => Effect.die("loadAndRegister must not run after lint failure"), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/tmp/editor-rpc-project"), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(currentRaw), + writeFile: () => + Effect.sync(() => { + writeCount += 1; + return { relativePath: ".t3/boards/invalid.json" }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const saved = yield* invokeWorkflowHandler<{ + readonly ok: false; + readonly lintErrors: ReadonlyArray<{ + readonly code: string; + readonly message: string; + readonly laneKey?: string; + }>; + }>(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: definitionEncoded, + expectedVersionHash: currentHash, + }); + + assert.equal(saved.ok, false); + assert.deepEqual(saved.lintErrors, lintErrors); + assert.equal(writeCount, 0); + }), +); + +it.effect("workflowRpcHandlers rejects stale board saves without writing", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-editor-rpc__stale"); + const definition = yield* decodeWorkflowDefinition({ + name: "Stale", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }); + const definitionEncoded = encodeWorkflowDefinition(definition); + const currentRaw = `${encodeWorkflowDefinitionJson(definition)}\n`; + const currentHash = sha256Hex(currentRaw); + const workspaceRoot = "/tmp/editor-rpc-project"; + let writeCount = 0; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => + Effect.succeed({ + boardId, + projectId: "project-editor-rpc", + name: "Stale", + workflowFilePath: ".t3/boards/stale.json", + workflowVersionHash: currentHash, + maxConcurrentTickets: 3, + }), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(definition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => Effect.die("lintDefinition must not run after version conflict"), + loadAndRegister: () => Effect.die("loadAndRegister must not run after version conflict"), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: (input) => + Effect.sync(() => { + assert.deepEqual(input, { + cwd: workspaceRoot, + relativePath: ".t3/boards/stale.json", + }); + return currentRaw; + }), + writeFile: () => + Effect.sync(() => { + writeCount += 1; + return { relativePath: ".t3/boards/stale.json" }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const saved = yield* invokeWorkflowHandler< + | { readonly ok: true; readonly versionHash: string } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + | { readonly ok: false; readonly conflict: true; readonly currentVersionHash: string } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: definitionEncoded, + expectedVersionHash: "hash-stale", + }); + + assert.deepEqual(saved, { + ok: false, + conflict: true, + currentVersionHash: currentHash, + }); + assert.equal(writeCount, 0); + }), +); + +it.effect("workflowRpcHandlers rejects saves when the board file changed on disk", () => + Effect.gen(function* () { + const projectId = "project-editor-rpc" as ProjectId; + const boardId = BoardId.make("project-editor-rpc__external-edit"); + const workspaceRoot = "/tmp/editor-rpc-project"; + const workflowFilePath = ".t3/boards/external-edit.json"; + const originalDefinition = yield* decodeWorkflowDefinition({ + name: "External Edit", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }); + const editedDefinition = yield* decodeWorkflowDefinition({ + name: "External Edit Saved", + lanes: [{ key: "queue", name: "Queue Saved", entry: "manual" }], + }); + const externalDefinition = yield* decodeWorkflowDefinition({ + name: "External Edit Hand Edited", + lanes: [{ key: "queue", name: "Queue Hand Edited", entry: "manual" }], + }); + const originalRaw = `${encodeWorkflowDefinitionJson(originalDefinition)}\n`; + const externalRaw = `${encodeWorkflowDefinitionJson(externalDefinition)}\n`; + const originalHash = sha256Hex(originalRaw); + const externalHash = sha256Hex(externalRaw); + let fileContents = originalRaw; + let writeCount = 0; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => + Effect.succeed({ + boardId, + projectId, + name: "External Edit", + workflowFilePath, + workflowVersionHash: originalHash, + maxConcurrentTickets: 3, + }), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(originalDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: () => Effect.die("loadAndRegister must not run after on-disk conflict"), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: (input) => + Effect.sync(() => { + assert.deepEqual(input, { cwd: workspaceRoot, relativePath: workflowFilePath }); + return fileContents; + }), + writeFile: (input) => + Effect.sync(() => { + writeCount += 1; + fileContents = input.contents; + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const loaded = yield* invokeWorkflowHandler<{ + readonly versionHash: string; + }>(handlers, WORKFLOW_WS_METHODS.getBoardDefinition, { boardId }); + fileContents = externalRaw; + + const saved = yield* invokeWorkflowHandler< + | { readonly ok: true; readonly versionHash: string } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + | { readonly ok: false; readonly conflict: true; readonly currentVersionHash: string } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(editedDefinition), + expectedVersionHash: loaded.versionHash, + }); + + assert.deepEqual(saved, { + ok: false, + conflict: true, + currentVersionHash: externalHash, + }); + assert.equal(writeCount, 0); + assert.equal(fileContents, externalRaw); + }), +); + +it.effect("workflowRpcHandlers serializes same-base board saves so only one succeeds", () => + Effect.gen(function* () { + const projectId = "project-editor-rpc" as ProjectId; + const boardId = BoardId.make("project-editor-rpc__concurrent"); + const workspaceRoot = "/tmp/editor-rpc-project"; + const workflowFilePath = ".t3/boards/concurrent.json"; + const baseDefinition = yield* decodeWorkflowDefinition({ + name: "Concurrent", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }); + const firstDefinition = yield* decodeWorkflowDefinition({ + name: "Concurrent First", + lanes: [{ key: "queue", name: "Queue First", entry: "manual" }], + }); + const secondDefinition = yield* decodeWorkflowDefinition({ + name: "Concurrent Second", + lanes: [{ key: "queue", name: "Queue Second", entry: "manual" }], + }); + const baseRaw = `${encodeWorkflowDefinitionJson(baseDefinition)}\n`; + const baseHash = sha256Hex(baseRaw); + let fileContents = baseRaw; + let registryDefinition = baseDefinition; + let boardRow = { + boardId, + projectId, + name: baseDefinition.name, + workflowFilePath, + workflowVersionHash: baseHash, + maxConcurrentTickets: 3, + }; + let writeCount = 0; + const firstWriteEntered = yield* Deferred.make(); + const saveLocks = yield* makeWorkflowBoardSaveLocks; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => Effect.succeed(boardRow), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + saveLocks, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => + Effect.gen(function* () { + registryDefinition = yield* decodeWorkflowDefinitionJson(fileContents).pipe( + Effect.orDie, + ); + boardRow = { + ...boardRow, + name: registryDefinition.name, + workflowVersionHash: sha256Hex(fileContents), + }; + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(fileContents), + writeFile: (input) => + Effect.gen(function* () { + writeCount += 1; + if (writeCount === 1) { + yield* Deferred.succeed(firstWriteEntered, undefined); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + } + fileContents = input.contents; + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const save = (definition: WorkflowDefinitionType) => + invokeWorkflowHandler< + | { readonly ok: true; readonly versionHash: string } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + | { readonly ok: false; readonly conflict: true; readonly currentVersionHash: string } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(definition), + expectedVersionHash: baseHash, + }); + + const first = yield* save(firstDefinition).pipe(Effect.forkChild); + yield* Deferred.await(firstWriteEntered); + const second = yield* save(secondDefinition).pipe(Effect.forkChild); + + const results = [yield* Fiber.join(first), yield* Fiber.join(second)]; + assert.equal(results.filter((result) => result.ok === true).length, 1); + const conflict = results.find((result) => result.ok === false && "conflict" in result); + assert.deepEqual(conflict, { + ok: false, + conflict: true, + currentVersionHash: sha256Hex(fileContents), + }); + assert.equal(writeCount, 1); + }), +); + +it.effect("workflowRpcHandlers serializes deleteBoard with an in-flight save", () => + Effect.gen(function* () { + const projectId = "project-editor-rpc" as ProjectId; + const boardId = BoardId.make("project-editor-rpc__delete-save-race"); + const workspaceRoot = "/tmp/editor-rpc-project"; + const workflowFilePath = ".t3/boards/delete-save-race.json"; + const baseDefinition = yield* decodeWorkflowDefinition({ + name: "Delete Save Race", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }); + const savedDefinition = yield* decodeWorkflowDefinition({ + name: "Delete Save Race Saved", + lanes: [{ key: "queue", name: "Queue Saved", entry: "manual" }], + }); + const baseRaw = `${encodeWorkflowDefinitionJson(baseDefinition)}\n`; + const baseHash = sha256Hex(baseRaw); + let fileContents = baseRaw; + let registryDefinition: WorkflowDefinitionType | null = baseDefinition; + let boardRow: { + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly name: string; + readonly workflowFilePath: string; + readonly workflowVersionHash: string; + readonly maxConcurrentTickets: number; + } | null = { + boardId, + projectId, + name: baseDefinition.name, + workflowFilePath, + workflowVersionHash: baseHash, + maxConcurrentTickets: 3, + }; + const versions: WorkflowBoardVersionRecordInput[] = []; + const saveWriteEntered = yield* Deferred.make(); + const saveLocks = yield* makeWorkflowBoardSaveLocks; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: (inputBoardId) => Effect.succeed(inputBoardId === boardId ? boardRow : null), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: (inputBoardId) => + Effect.sync(() => { + if (inputBoardId === boardId) { + boardRow = null; + } + }), + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: (inputBoardId) => + Effect.sync(() => { + if (inputBoardId === boardId) { + registryDefinition = null; + } + }), + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + saveLocks, + fileLoader: { + lintDefinition: () => Effect.succeed([]), + loadAndRegister: (input) => + Effect.gen(function* () { + registryDefinition = yield* decodeWorkflowDefinitionJson(fileContents).pipe( + Effect.orDie, + ); + boardRow = { + boardId: input.boardId, + projectId: input.projectId, + name: registryDefinition.name, + workflowFilePath: input.relativePath, + workflowVersionHash: sha256Hex(fileContents), + maxConcurrentTickets: 3, + }; + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: { + record: (input) => + Effect.sync(() => { + versions.push(input); + }), + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: (inputBoardId) => + Effect.sync(() => { + for (let index = versions.length - 1; index >= 0; index -= 1) { + if (versions[index]?.boardId === inputBoardId) { + versions.splice(index, 1); + } + } + }), + }, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(fileContents), + writeFile: (input) => + Effect.gen(function* () { + fileContents = input.contents; + yield* Deferred.succeed(saveWriteEntered, undefined); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: (input) => + Effect.sync(() => { + assert.deepEqual(input, { cwd: workspaceRoot, relativePath: workflowFilePath }); + fileContents = ""; + }), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const saveFiber = yield* invokeWorkflowHandler<{ + readonly ok: true; + readonly versionHash: string; + }>(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(savedDefinition), + expectedVersionHash: baseHash, + }).pipe(Effect.forkChild); + + yield* Deferred.await(saveWriteEntered); + const deleteFiber = yield* invokeWorkflowHandler( + handlers, + WORKFLOW_WS_METHODS.deleteBoard, + { boardId }, + ).pipe(Effect.forkChild); + + const saved = yield* Fiber.join(saveFiber); + yield* Fiber.join(deleteFiber); + + assert.equal(saved.ok, true); + assert.equal(boardRow, null); + assert.equal(registryDefinition, null); + assert.deepEqual(versions, []); + }), +); + +it.effect("workflowRpcHandlers rejects unsafe instruction paths without writing", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-editor-rpc__unsafe-instruction"); + const definition = yield* decodeWorkflowDefinition({ + name: "Unsafe Instruction", + lanes: [ + { + key: "run", + name: "Run", + entry: "auto", + pipeline: [ + { + key: "agent", + type: "agent", + agent: { instance: "codex_main", model: "gpt-5.5" }, + instruction: { file: "../escape.md" }, + }, + ], + }, + ], + }); + const definitionEncoded = encodeWorkflowDefinition(definition); + const currentRaw = `${encodeWorkflowDefinitionJson(definition)}\n`; + const currentHash = sha256Hex(currentRaw); + let writeCount = 0; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => + Effect.succeed({ + boardId, + projectId: "project-editor-rpc", + name: "Unsafe Instruction", + workflowFilePath: ".t3/boards/unsafe-instruction.json", + workflowVersionHash: currentHash, + maxConcurrentTickets: 3, + }), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(definition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: (input) => + Effect.succeed( + lintWorkflowDefinition(input.definition, { + providerInstanceExists: () => true, + instructionFileExists: () => true, + }), + ), + loadAndRegister: () => Effect.die("loadAndRegister must not run after lint failure"), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/tmp/editor-rpc-project"), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.succeed(currentRaw), + writeFile: () => + Effect.sync(() => { + writeCount += 1; + return { relativePath: ".t3/boards/unsafe-instruction.json" }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const saved = yield* invokeWorkflowHandler<{ + readonly ok: false; + readonly lintErrors: ReadonlyArray<{ readonly code: string; readonly message: string }>; + }>(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: definitionEncoded, + expectedVersionHash: currentHash, + }); + + assert.equal(saved.ok, false); + assert.deepEqual( + saved.lintErrors.map((error) => error.code), + ["unsafe_instruction_path"], + ); + assert.equal(writeCount, 0); + }), +); + +it.effect("workflowRpcHandlers rejects board saves whose derived path is not a board file", () => + Effect.gen(function* () { + const boardId = BoardId.make("project-editor-rpc__unsafe"); + const definition = yield* decodeWorkflowDefinition({ + name: "Unsafe", + lanes: [{ key: "queue", name: "Queue", entry: "manual" }], + }); + const definitionEncoded = encodeWorkflowDefinition(definition); + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => + Effect.succeed({ + boardId, + projectId: "project-editor-rpc", + name: "Unsafe", + workflowFilePath: ".t3/boards/../unsafe.json", + workflowVersionHash: "hash-before", + maxConcurrentTickets: 3, + }), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(definition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: () => Effect.die("lintDefinition must not run for unsafe path"), + loadAndRegister: () => Effect.die("unused"), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed("/tmp/editor-rpc-project"), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: () => Effect.die("readFileString must not run for unsafe path"), + writeFile: () => Effect.die("writeFile must not run for unsafe path"), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const result = yield* Effect.exit( + invokeWorkflowHandler(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: definitionEncoded, + expectedVersionHash: "hash-before", + }), + ); + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.isTrue(result.cause.toString().includes("not a writable workflow board file")); + } + }), +); + +it.effect( + "workflowRpcHandlers round-trips saved board definitions and preserves invalid files", + () => + Effect.gen(function* () { + const projectId = "project-editor-roundtrip" as ProjectId; + const boardId = BoardId.make("project-editor-roundtrip__delivery"); + const workspaceRoot = "/tmp/editor-roundtrip-project"; + const workflowFilePath = ".t3/boards/delivery.json"; + const initialDefinition = yield* decodeWorkflowDefinition({ + name: "Round Trip", + lanes: [ + { key: "queue", name: "Queue", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + let fileContents = `${encodeWorkflowDefinitionJson(initialDefinition)}\n`; + const initialHash = sha256Hex(fileContents); + let registryDefinition = initialDefinition; + let boardRow = { + boardId, + projectId, + name: registryDefinition.name, + workflowFilePath, + workflowVersionHash: initialHash, + maxConcurrentTickets: 3, + }; + + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + runLane: () => Effect.void, + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => Effect.void, + answerTicketStep: () => Effect.void, + postTicketMessage: () => Effect.void, + cancelStep: () => Effect.void, + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines: () => Effect.void, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => Effect.void, + }, + readModel: { + getBoard: () => Effect.succeed(boardRow), + listTickets: () => Effect.succeed([]), + getTicketDetail: () => Effect.succeed(null), + registerBoard: () => Effect.void, + deleteBoard: () => Effect.void, + deleteBoardTicketState: () => Effect.void, + deleteTicketState: () => Effect.void, + listBoardsForProject: () => Effect.succeed([]), + countAdmittedInLane: () => Effect.succeed(0), + oldestQueuedForLane: () => Effect.succeed(null), + listTicketMessages: () => Effect.succeed([]), + listStepRunsForPipeline: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listTicketDiscussion: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + }, + boardRegistry: { + register: () => Effect.die("unused"), + unregister: () => Effect.void, + getDefinition: () => Effect.succeed(registryDefinition), + listDefinitions: () => Effect.succeed([]), + getLane: () => Effect.succeed(null), + }, + ticketDiff: { + getTicketDiff: () => Effect.die("unused"), + }, + ticketWorktrees: { + resolveForTicket: () => Effect.die("unused"), + }, + boardEvents: { + publish: () => Effect.void, + stream: () => Stream.empty, + }, + fileLoader: { + lintDefinition: (input) => + Effect.sync(() => + input.definition.lanes.some( + (lane) => lane.wipLimit !== undefined && lane.wipLimit < 1, + ) + ? [ + { + code: "invalid_wip_limit" as const, + message: "wipLimit must be at least 1", + laneKey: "queue", + }, + ] + : [], + ), + loadAndRegister: (input) => + Effect.gen(function* () { + registryDefinition = yield* decodeWorkflowDefinitionJson(fileContents).pipe( + Effect.orDie, + ); + boardRow = { + ...boardRow, + name: registryDefinition.name, + workflowVersionHash: sha256Hex(fileContents), + }; + return input.boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + versionStore: noopVersionStore, + boardDiscovery: { + discover: () => Effect.succeed([]), + list: () => Effect.succeed([]), + }, + projectWorkspaceResolver: { + resolve: () => Effect.succeed(workspaceRoot), + }, + workspaceFileSystem: { + listFiles: () => Effect.succeed([]), + readFileString: (input) => + Effect.sync(() => { + assert.deepEqual(input, { cwd: workspaceRoot, relativePath: workflowFilePath }); + return fileContents; + }), + writeFile: (input) => + Effect.sync(() => { + assert.equal(input.cwd, workspaceRoot); + assert.equal(input.relativePath, workflowFilePath); + fileContents = input.contents; + return { relativePath: input.relativePath }; + }), + createFileExclusive: () => Effect.die("unused"), + deleteFile: () => Effect.die("unused"), + }, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const loadedBefore = yield* invokeWorkflowHandler<{ + readonly definition: { readonly name: string }; + readonly versionHash: string; + }>(handlers, WORKFLOW_WS_METHODS.getBoardDefinition, { boardId }); + assert.equal(loadedBefore.definition.name, "Round Trip"); + assert.equal(loadedBefore.versionHash, initialHash); + + const editedDefinition = yield* decodeWorkflowDefinition({ + name: "Round Trip Edited", + lanes: [ + { key: "queue", name: "Queue Updated", entry: "manual", wipLimit: 2 }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const saved = yield* invokeWorkflowHandler< + | { + readonly ok: true; + readonly definition: { readonly name: string }; + readonly versionHash: string; + } + | { readonly ok: false; readonly lintErrors: readonly unknown[] } + >(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(editedDefinition), + expectedVersionHash: initialHash, + }); + assert.equal(saved.ok, true); + if (saved.ok !== true) { + assert.fail("expected successful save"); + } + assert.equal(saved.versionHash, sha256Hex(fileContents)); + + const loadedAfter = yield* invokeWorkflowHandler<{ + readonly definition: { + readonly name: string; + readonly lanes: ReadonlyArray<{ readonly name: string }>; + }; + readonly versionHash: string; + }>(handlers, WORKFLOW_WS_METHODS.getBoardDefinition, { boardId }); + assert.equal(loadedAfter.definition.name, "Round Trip Edited"); + assert.equal(loadedAfter.definition.lanes[0]?.name, "Queue Updated"); + assert.equal(loadedAfter.versionHash, saved.versionHash); + + const fileContentsAfterValidSave = fileContents; + const invalidDefinition = yield* decodeWorkflowDefinition({ + name: "Round Trip Invalid", + lanes: [ + { key: "queue", name: "Queue", entry: "manual", wipLimit: 0 }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + const rejected = yield* invokeWorkflowHandler<{ + readonly ok: false; + readonly lintErrors: ReadonlyArray<{ readonly code: string }>; + }>(handlers, WORKFLOW_WS_METHODS.saveBoardDefinition, { + boardId, + definition: encodeWorkflowDefinition(invalidDefinition), + expectedVersionHash: saved.versionHash, + }); + assert.equal(rejected.ok, false); + assert.equal(rejected.lintErrors[0]?.code, "invalid_wip_limit"); + assert.equal(fileContents, fileContentsAfterValidSave); + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts new file mode 100644 index 00000000000..df6b1d5ad7d --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts @@ -0,0 +1,1292 @@ +import type { + AgentSelection, + BoardListEntry, + BoardSnapshot, + BoardTicketView, + WorkflowIntakeResult, + EnvironmentAuthorizationError, + ProjectId, + StepRunId, + StepRunStatus, + TicketAttachment, + TicketId, + TicketStatus, + WorkflowBoardVersionSummary, + WorkflowCreateBoardInput as WorkflowCreateBoardInputType, + WorkflowGetBoardDefinitionResult, + WorkflowGetBoardVersionResult, + WorkflowLintError, + WorkflowRenameBoardInput as WorkflowRenameBoardInputType, + WorkflowSaveBoardDefinitionInput, + WorkflowSaveBoardDefinitionResult, + WorkflowStepRunView, + WorkflowTicketDetailView, + WorkflowDefinition as WorkflowDefinitionType, + WorkflowDefinitionEncoded, + WorkflowDryRunScenario, +} from "@t3tools/contracts"; +import { + BoardId, + LaneKey, + StepKey, + WORKFLOW_WS_METHODS, + WorkflowCreateBoardInput, + WorkflowDefinition, + WorkflowRenameBoardInput, + WorkflowRpcError, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import type { WorkspaceFileSystemShape } from "../../workspace/Services/WorkspaceFileSystem.ts"; +import { slugifyBoardName, uniqueBoardSlug } from "../boardSlug.ts"; +import { defaultBoardDefinition } from "../defaultBoard.ts"; +import type { BoardDiscoveryShape } from "../Services/BoardDiscovery.ts"; +import type { BoardRegistryShape } from "../Services/BoardRegistry.ts"; +import type { ProjectScriptTrustShape } from "../Services/ProjectScriptTrust.ts"; +import type { ProjectWorkspaceResolverShape } from "../Services/ProjectWorkspaceResolver.ts"; +import type { WorkflowBoardEventsShape } from "../Services/WorkflowBoardEvents.ts"; +import type { WorkflowBoardSaveLocksShape } from "../Services/WorkflowBoardSaveLocks.ts"; +import type { + WorkflowBoardVersionSource, + WorkflowBoardVersionSummaryRow, + WorkflowBoardVersionStoreShape, +} from "../Services/WorkflowBoardVersionStore.ts"; +import type { WorkflowEngineShape } from "../Services/WorkflowEngine.ts"; +import type { WorkflowEventStoreShape } from "../Services/WorkflowEventStore.ts"; +import type { WorkflowFileLoaderShape } from "../Services/WorkflowFileLoader.ts"; +import type { + BoardRow, + StepRunRow, + TicketRow, + WorkflowReadModelShape, +} from "../Services/WorkflowReadModel.ts"; +import type { TicketDiffQueryShape } from "../Services/TicketDiffQuery.ts"; +import type { WorkflowIntakeShape } from "../Services/WorkflowIntake.ts"; +import type { PredicateEvaluatorShape } from "../Services/PredicateEvaluator.ts"; +import type { WorkflowWebhookShape } from "../Services/WorkflowWebhook.ts"; +import type { WorkflowThreadJanitorShape } from "../Services/WorkflowThreadJanitor.ts"; +import type { WorkflowWorktreeJanitorShape } from "../Services/WorkflowWorktreeJanitor.ts"; +import { deleteWorkflowBoardOwnedState } from "../boardDeletion.ts"; +import { simulateBoardRoute } from "../dryRun.ts"; +import { sha256Hex } from "../workflowVersionHash.ts"; +import { encodeWorkflowDefinitionJson, type LintError } from "../workflowFile.ts"; + +export interface TicketWorktreeResolverShape { + readonly resolveForTicket: ( + ticketId: TicketId, + ) => Effect.Effect<{ readonly cwd: string; readonly baseRef: string }, WorkflowRpcError>; +} + +interface WorkflowCreateTicketInput { + readonly boardId: BoardId; + readonly title: string; + readonly description?: string | undefined; + readonly initialLane: LaneKey; + readonly dependsOn?: ReadonlyArray | undefined; + readonly tokenBudget?: number | undefined; +} + +interface WorkflowEditTicketInput { + readonly ticketId: TicketId; + readonly title?: string | undefined; + readonly description?: string | undefined; + readonly dependsOn?: ReadonlyArray | undefined; + readonly tokenBudget?: number | null | undefined; +} + +interface WorkflowAnswerTicketStepInput { + readonly stepRunId: StepRunId; + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray | undefined; +} + +interface WorkflowDeleteBoardInput { + readonly boardId: BoardId; +} + +type WorkflowCreateBoardHandlerInput = WorkflowCreateBoardInputType; +type WorkflowRenameBoardHandlerInput = WorkflowRenameBoardInputType; + +interface WorkflowGetBoardDefinitionInput { + readonly boardId: BoardId; +} + +interface WorkflowGetBoardVersionInput { + readonly boardId: BoardId; + readonly versionId: number; +} + +interface WorkflowRpcHandlerDeps { + readonly engine: WorkflowEngineShape; + readonly eventStore?: Pick; + readonly readModel: WorkflowReadModelShape; + readonly boardRegistry: BoardRegistryShape; + readonly boardDiscovery: BoardDiscoveryShape; + readonly projectWorkspaceResolver: ProjectWorkspaceResolverShape; + readonly workspaceFileSystem: WorkspaceFileSystemShape; + readonly ticketDiff: TicketDiffQueryShape; + readonly ticketWorktrees: TicketWorktreeResolverShape; + readonly boardEvents: WorkflowBoardEventsShape; + readonly saveLocks?: WorkflowBoardSaveLocksShape; + readonly versionStore: WorkflowBoardVersionStoreShape; + readonly worktreeJanitor?: Pick; + readonly threadJanitor?: Pick< + WorkflowThreadJanitorShape, + "collectBoardThreads" | "deleteThreads" + >; + readonly intake?: WorkflowIntakeShape; + readonly webhook?: Pick; + readonly predicates?: PredicateEvaluatorShape; + readonly fileLoader: WorkflowFileLoaderShape; + readonly projectScriptTrust: ProjectScriptTrustShape; + readonly observeRpcEffect: ( + method: string, + effect: Effect.Effect, + traceAttributes?: Readonly>, + ) => Effect.Effect; + readonly observeRpcStreamEffect: ( + method: string, + effect: Effect.Effect, EffectError, EffectContext>, + traceAttributes?: Readonly>, + ) => Stream.Stream< + A, + StreamError | EffectError | EnvironmentAuthorizationError, + StreamContext | EffectContext + >; +} + +const MAX_TICKET_ARTIFACTS = 20; +const MAX_TICKET_ARTIFACT_CHARS = 64_000; +const MAX_DRY_RUN_DEFINITION_CHARS = 256_000; +const MAX_DRY_RUN_LANES = 200; +const MAX_DRY_RUN_PER_LANE = 100; + +const toBoardTicketView = (ticket: TicketRow): BoardTicketView => ({ + ticketId: ticket.ticketId as TicketId, + boardId: ticket.boardId as BoardId, + title: ticket.title, + ...(ticket.description === null ? {} : { description: ticket.description }), + currentLaneKey: ticket.currentLaneKey as LaneKey, + status: ticket.status as TicketStatus, + ...(ticket.queuedAt === null ? {} : { queuedAt: ticket.queuedAt }), + ...(ticket.dependsOn === undefined || ticket.dependsOn.length === 0 + ? {} + : { dependsOn: ticket.dependsOn as ReadonlyArray }), + ...(ticket.unresolvedDependencyCount === undefined || ticket.unresolvedDependencyCount === 0 + ? {} + : { unresolvedDependencyCount: ticket.unresolvedDependencyCount }), + ...(typeof ticket.tokenBudget === "number" ? { tokenBudget: ticket.tokenBudget } : {}), + ...(ticket.updatedAt === undefined ? {} : { updatedAt: ticket.updatedAt }), + ...(typeof ticket.totalTokens === "number" && ticket.totalTokens > 0 + ? { totalTokens: ticket.totalTokens } + : {}), + ...(typeof ticket.totalDurationMs === "number" && ticket.totalDurationMs > 0 + ? { totalDurationMs: ticket.totalDurationMs } + : {}), +}); + +const toStepUsageView = (step: StepRunRow) => { + if ( + step.inputTokens === null && + step.cachedInputTokens === null && + step.outputTokens === null && + step.totalTokens === null + ) { + return undefined; + } + return { + ...(step.inputTokens === null ? {} : { inputTokens: step.inputTokens }), + ...(step.cachedInputTokens === null ? {} : { cachedInputTokens: step.cachedInputTokens }), + ...(step.outputTokens === null ? {} : { outputTokens: step.outputTokens }), + ...(step.totalTokens === null ? {} : { totalTokens: step.totalTokens }), + }; +}; + +const toStepRunView = (step: StepRunRow): WorkflowStepRunView => ({ + stepRunId: step.stepRunId as never, + stepKey: step.stepKey as never, + stepType: step.stepType as "agent" | "approval", + ...(step.attempt === null || step.attempt === 1 ? {} : { attempt: step.attempt }), + status: step.status as StepRunStatus, + waitingReason: step.waitingReason, + blockedReason: step.blockedReason, + providerResponseKind: step.providerResponseKind, + scriptThreadId: step.scriptThreadId as never, + terminalId: step.terminalId, + scriptStatus: step.scriptStatus as never, + exitCode: step.exitCode, + signal: step.signal, + ...(step.output === null ? {} : { output: step.output }), + ...(step.startedAt === null ? {} : { startedAt: step.startedAt as never }), + ...(step.finishedAt === null ? {} : { finishedAt: step.finishedAt as never }), + ...(toStepUsageView(step) === undefined ? {} : { usage: toStepUsageView(step) }), + ...(step.providerThreadId === null ? {} : { providerThreadId: step.providerThreadId as never }), +}); + +const workflowRpcError = (message: string, cause?: unknown) => + new WorkflowRpcError({ + message, + ...(cause === undefined ? {} : { cause }), + }); + +const decodeWorkflowDefinition = Schema.decodeUnknownEffect(WorkflowDefinition); +const decodeWorkflowCreateBoardInput = Schema.decodeUnknownEffect(WorkflowCreateBoardInput); +const decodeWorkflowRenameBoardInput = Schema.decodeUnknownEffect(WorkflowRenameBoardInput); +const decodeWorkflowDefinitionJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(WorkflowDefinition), +); +const encodeWorkflowDefinition = Schema.encodeSync(WorkflowDefinition); +const WORKFLOW_BOARD_FILE_PATH_PATTERN = /^\.t3\/boards\/[A-Za-z0-9_-]+\.json$/; + +const toWorkflowRpcError = (message: string) => (cause: unknown) => + workflowRpcError(message, cause); + +const toContractLintError = (error: LintError): WorkflowLintError => ({ + code: error.code, + message: error.message, + ...(error.laneKey === undefined ? {} : { laneKey: LaneKey.make(error.laneKey) }), + ...(error.stepKey === undefined ? {} : { stepKey: StepKey.make(error.stepKey) }), + ...(error.transitionIndex === undefined ? {} : { transitionIndex: error.transitionIndex }), +}); + +const workflowDefinitionContentJson = (definition: WorkflowDefinitionType): string => + `${encodeWorkflowDefinitionJson(definition)}\n`; + +const workflowDefinitionVersionHash = (definition: WorkflowDefinitionType): string => + sha256Hex(workflowDefinitionContentJson(definition)); + +const recordBoardVersionBestEffort = ( + deps: Pick, + input: { + readonly boardId: BoardId; + readonly versionHash: string; + readonly contentJson: string; + readonly source: WorkflowBoardVersionSource; + }, +): Effect.Effect => + deps.versionStore.record(input).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to record workflow board version", { + boardId: input.boardId, + source: input.source, + cause: Cause.pretty(cause), + }), + ), + ); + +const recordBoardVersionRequired = ( + deps: Pick, + input: { + readonly boardId: BoardId; + readonly versionHash: string; + readonly contentJson: string; + readonly source: WorkflowBoardVersionSource; + }, +): Effect.Effect => + deps.versionStore + .record(input) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to record workflow board version"))); + +const boardSnapshot = ( + deps: Pick, + boardId: BoardId, +): Effect.Effect => + Effect.gen(function* () { + const board = yield* deps.readModel + .getBoard(boardId) + .pipe(Effect.mapError((cause) => workflowRpcError("Failed to load workflow board", cause))); + if (!board) { + return yield* workflowRpcError(`Workflow board ${boardId} was not found`); + } + + const definition = yield* deps.boardRegistry.getDefinition(boardId); + if (!definition) { + return yield* workflowRpcError(`Workflow board definition ${boardId} was not found`); + } + + const tickets = yield* deps.readModel + .listTickets(boardId) + .pipe(Effect.mapError((cause) => workflowRpcError("Failed to load workflow tickets", cause))); + + return { + projectId: board.projectId as ProjectId, + board: { + boardId, + name: board.name, + lanes: definition.lanes.map((lane) => ({ + key: lane.key, + name: lane.name, + entry: lane.entry, + pipelineStepCount: lane.pipeline?.length ?? 0, + ...(lane.wipLimit === undefined ? {} : { wipLimit: lane.wipLimit }), + ...(lane.terminal === undefined ? {} : { terminal: lane.terminal }), + ...(lane.actions === undefined || lane.actions.length === 0 + ? {} + : { actions: lane.actions }), + })), + }, + tickets: tickets.map(toBoardTicketView), + } satisfies BoardSnapshot; + }); + +const ticketDetail = ( + deps: Pick, + ticketId: TicketId, +): Effect.Effect => + Effect.gen(function* () { + const detail = yield* deps.readModel + .getTicketDetail(ticketId) + .pipe( + Effect.mapError((cause) => + workflowRpcError("Failed to load workflow ticket detail", cause), + ), + ); + if (!detail) { + return yield* workflowRpcError(`Workflow ticket ${ticketId} was not found`); + } + const routeDecisions = yield* deps.readModel + .listTicketRouteDecisions(ticketId) + .pipe( + Effect.mapError((cause) => + workflowRpcError("Failed to load workflow ticket route history", cause), + ), + ); + + return { + routeHistory: routeDecisions.map((decision) => ({ + occurredAt: decision.occurredAt as never, + ...(decision.fromLane === null ? {} : { fromLane: decision.fromLane as never }), + toLane: decision.toLane as never, + source: decision.source, + ...(decision.matchedTransitionIndex === null + ? {} + : { matchedTransitionIndex: decision.matchedTransitionIndex }), + ...(decision.eventName === null ? {} : { eventName: decision.eventName }), + ...(decision.pipelineResult === null ? {} : { pipelineResult: decision.pipelineResult }), + ...(decision.laneRunCount === null ? {} : { laneRunCount: decision.laneRunCount }), + ...(decision.steps === null + ? {} + : { + steps: Object.fromEntries( + Object.entries(decision.steps).map(([stepKey, step]) => [ + stepKey, + { + status: step.status, + ...(step.exitCode === null ? {} : { exitCode: step.exitCode }), + ...(step.verdict === null ? {} : { verdict: step.verdict }), + }, + ]), + ), + }), + })), + ticket: toBoardTicketView(detail.ticket), + steps: detail.steps.map(toStepRunView), + messages: detail.messages.map((message) => ({ + messageId: message.messageId, + ticketId: message.ticketId, + ...(message.stepRunId === null ? {} : { stepRunId: message.stepRunId }), + author: message.author, + body: message.body, + attachments: [...message.attachments], + createdAt: message.createdAt, + })), + } satisfies WorkflowTicketDetailView; + }); + +const slugFromBoardEntry = (entry: BoardListEntry): string | null => { + const fileName = entry.filePath.split("/").at(-1); + return fileName?.endsWith(".json") ? fileName.slice(0, -".json".length) : null; +}; + +const createBoard = ( + deps: Pick< + WorkflowRpcHandlerDeps, + | "boardDiscovery" + | "projectWorkspaceResolver" + | "workspaceFileSystem" + | "fileLoader" + | "boardRegistry" + | "readModel" + | "saveLocks" + | "versionStore" + >, + input: WorkflowCreateBoardHandlerInput, +): Effect.Effect< + { readonly boardId: BoardId; readonly snapshot: BoardSnapshot }, + WorkflowRpcError +> => + decodeWorkflowCreateBoardInput(input).pipe( + Effect.mapError(toWorkflowRpcError("workflow board create input decode failed")), + Effect.flatMap((decoded) => + Effect.gen(function* () { + const workspaceRoot = yield* deps.projectWorkspaceResolver + .resolve(decoded.projectId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to resolve workflow project root"))); + const existingEntries = yield* deps.boardDiscovery.discover(decoded.projectId); + const existingSlugs = new Set( + existingEntries.flatMap((entry) => { + const slug = slugFromBoardEntry(entry); + return slug === null ? [] : [slug]; + }), + ); + const slug = uniqueBoardSlug(slugifyBoardName(decoded.name), existingSlugs); + const boardId = BoardId.make(`${decoded.projectId}__${slug}`); + const relativePath = `.t3/boards/${slug}.json`; + const definition = defaultBoardDefinition({ name: decoded.name, agent: decoded.agent }); + const contentJson = workflowDefinitionContentJson(definition); + + return yield* (deps.saveLocks?.withSaveLock ?? ((_boardId, effect) => effect))( + boardId, + Effect.gen(function* () { + yield* deps.workspaceFileSystem + .createFileExclusive({ + projectRoot: workspaceRoot, + relativePath, + contents: contentJson, + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to create workflow board file"))); + yield* deps.fileLoader + .loadAndRegister({ + boardId, + projectId: decoded.projectId, + workspaceRoot, + relativePath, + }) + .pipe( + Effect.mapError(toWorkflowRpcError("Failed to register created workflow board")), + ); + + const createdBoard = yield* deps.readModel + .getBoard(boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load created workflow board"))); + if (!createdBoard) { + return yield* workflowRpcError( + `Workflow board ${boardId} was not found after create`, + ); + } + yield* recordBoardVersionBestEffort(deps, { + boardId, + versionHash: createdBoard.workflowVersionHash, + contentJson, + source: "create", + }); + + const snapshot = yield* boardSnapshot(deps, boardId); + return { boardId, snapshot }; + }), + ); + }), + ), + ); + +const deleteBoard = ( + deps: Pick< + WorkflowRpcHandlerDeps, + | "readModel" + | "engine" + | "eventStore" + | "boardRegistry" + | "versionStore" + | "saveLocks" + | "projectWorkspaceResolver" + | "workspaceFileSystem" + | "worktreeJanitor" + | "threadJanitor" + | "webhook" + >, + input: WorkflowDeleteBoardInput, +): Effect.Effect => + (deps.saveLocks?.withSaveLock ?? ((_boardId, effect) => effect))( + input.boardId, + Effect.gen(function* () { + const board = yield* deps.readModel + .getBoard(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load workflow board"))); + + if (board) { + if (!WORKFLOW_BOARD_FILE_PATH_PATTERN.test(board.workflowFilePath)) { + return yield* workflowRpcError( + `Workflow board ${input.boardId} is not a deletable workflow board file`, + ); + } + + const workspaceRoot = yield* deps.projectWorkspaceResolver + .resolve(board.projectId as ProjectId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to resolve workflow project root"))); + + yield* deps.workspaceFileSystem + .deleteFile({ + cwd: workspaceRoot, + relativePath: board.workflowFilePath, + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to delete workflow board file"))); + } + + yield* deleteWorkflowBoardOwnedState( + { + boardRegistry: deps.boardRegistry, + engine: deps.engine, + eventStore: deps.eventStore ?? { deleteForBoard: () => Effect.void }, + readModel: deps.readModel, + versionStore: deps.versionStore, + ...(deps.worktreeJanitor === undefined ? {} : { worktreeJanitor: deps.worktreeJanitor }), + ...(deps.threadJanitor === undefined ? {} : { threadJanitor: deps.threadJanitor }), + ...(deps.webhook === undefined ? {} : { webhook: deps.webhook }), + }, + input.boardId, + ).pipe(Effect.mapError(toWorkflowRpcError("Failed to delete workflow board state"))); + }), + ); + +const getBoardDefinition = ( + deps: Pick, + input: WorkflowGetBoardDefinitionInput, +): Effect.Effect => + Effect.gen(function* () { + const definition = yield* deps.boardRegistry.getDefinition(input.boardId); + if (!definition) { + return yield* workflowRpcError(`Workflow board definition ${input.boardId} was not found`); + } + + const board = yield* deps.readModel + .getBoard(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load workflow board"))); + if (!board) { + return yield* workflowRpcError(`Workflow board ${input.boardId} was not found`); + } + + return { + definition: encodeWorkflowDefinition(definition), + versionHash: board.workflowVersionHash, + }; + }); + +const toBoardVersionSummary = ( + version: WorkflowBoardVersionSummaryRow, + index: number, +): WorkflowBoardVersionSummary => ({ + versionId: version.versionId, + versionHash: version.versionHash, + source: version.source, + createdAt: version.createdAt, + isCurrent: index === 0, +}); + +const backfillImportedBoardVersion = ( + deps: Pick< + WorkflowRpcHandlerDeps, + "readModel" | "projectWorkspaceResolver" | "workspaceFileSystem" | "versionStore" + >, + boardId: BoardId, +): Effect.Effect => + Effect.gen(function* () { + const board = yield* deps.readModel + .getBoard(boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load workflow board"))); + if (!board) { + return yield* workflowRpcError(`Workflow board ${boardId} was not found`); + } + + const projectId = board.projectId as ProjectId; + const workspaceRoot = yield* deps.projectWorkspaceResolver + .resolve(projectId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to resolve workflow project root"))); + const contentJson = yield* deps.workspaceFileSystem + .readFileString({ + cwd: workspaceRoot, + relativePath: board.workflowFilePath, + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to read workflow board file"))); + const versionHash = sha256Hex(contentJson); + if (versionHash !== board.workflowVersionHash) { + yield* Effect.logWarning("Skipping workflow board version import for stale projection", { + boardId, + projectedVersionHash: board.workflowVersionHash, + fileVersionHash: versionHash, + }); + return; + } + + yield* deps.versionStore + .record({ + boardId, + versionHash, + contentJson, + source: "import", + }) + .pipe( + Effect.mapError(toWorkflowRpcError("Failed to record imported workflow board version")), + ); + }); + +const listBoardVersions = ( + deps: Pick< + WorkflowRpcHandlerDeps, + "readModel" | "projectWorkspaceResolver" | "workspaceFileSystem" | "versionStore" | "saveLocks" + >, + input: WorkflowGetBoardDefinitionInput, +): Effect.Effect, WorkflowRpcError> => + Effect.gen(function* () { + const existing = yield* deps.versionStore + .list(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list workflow board versions"))); + if (existing.length > 0) { + return existing.map(toBoardVersionSummary); + } + + yield* (deps.saveLocks?.withSaveLock ?? ((_boardId, effect) => effect))( + input.boardId, + Effect.gen(function* () { + const lockedExisting = yield* deps.versionStore + .list(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list workflow board versions"))); + if (lockedExisting.length > 0) { + return; + } + yield* backfillImportedBoardVersion(deps, input.boardId); + }), + ); + const imported = yield* deps.versionStore + .list(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list workflow board versions"))); + return imported.map(toBoardVersionSummary); + }); + +const getBoardVersion = ( + deps: Pick, + input: WorkflowGetBoardVersionInput, +): Effect.Effect => + Effect.gen(function* () { + const version = yield* deps.versionStore + .get(input.boardId, input.versionId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load workflow board version"))); + if (!version) { + return yield* workflowRpcError( + `Workflow board version ${input.versionId} was not found for board ${input.boardId}`, + ); + } + + const definition = yield* decodeWorkflowDefinitionJson(version.contentJson).pipe( + Effect.mapError(toWorkflowRpcError("workflow board version decode failed")), + ); + return { + versionId: version.versionId, + definition: encodeWorkflowDefinition(definition), + versionHash: version.versionHash, + source: version.source, + createdAt: version.createdAt, + }; + }); + +interface WritableWorkflowBoardFile { + readonly board: BoardRow; + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly currentRaw: string; +} + +interface PersistedWorkflowBoardDefinition { + readonly _tag: "persisted"; + readonly definition: WorkflowDefinitionEncoded; + readonly versionHash: string; + readonly contentJson: string; +} + +interface WorkflowBoardDefinitionLintFailure { + readonly _tag: "lintErrors"; + readonly lintErrors: ReadonlyArray; +} + +type PersistWorkflowBoardDefinitionResult = + | PersistedWorkflowBoardDefinition + | WorkflowBoardDefinitionLintFailure; + +const loadWritableWorkflowBoardFile = ( + deps: Pick< + WorkflowRpcHandlerDeps, + "readModel" | "projectWorkspaceResolver" | "workspaceFileSystem" + >, + boardId: BoardId, +): Effect.Effect => + Effect.gen(function* () { + const board = yield* deps.readModel + .getBoard(boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load workflow board"))); + if (!board) { + return yield* workflowRpcError(`Workflow board ${boardId} was not found`); + } + + if (!WORKFLOW_BOARD_FILE_PATH_PATTERN.test(board.workflowFilePath)) { + return yield* workflowRpcError( + `Workflow board ${boardId} is not a writable workflow board file`, + ); + } + + const projectId = board.projectId as ProjectId; + const workspaceRoot = yield* deps.projectWorkspaceResolver + .resolve(projectId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to resolve workflow project root"))); + const currentRaw = yield* deps.workspaceFileSystem + .readFileString({ + cwd: workspaceRoot, + relativePath: board.workflowFilePath, + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to read workflow board file"))); + + return { + board, + projectId, + workspaceRoot, + currentRaw, + }; + }); + +const persistWorkflowBoardDefinition = ( + deps: Pick< + WorkflowRpcHandlerDeps, + "readModel" | "fileLoader" | "workspaceFileSystem" | "versionStore" + >, + input: { + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly relativePath: string; + readonly definition: WorkflowDefinitionType; + readonly source: WorkflowBoardVersionSource; + readonly notFoundAfterWriteMessage: string; + readonly versionRecording?: "best-effort" | "required"; + }, +): Effect.Effect => + Effect.gen(function* () { + const lintErrors = yield* deps.fileLoader + .lintDefinition({ + definition: input.definition, + projectId: input.projectId, + workspaceRoot: input.workspaceRoot, + }) + .pipe(Effect.mapError(toWorkflowRpcError("workflow lint failed"))); + if (lintErrors.length > 0) { + return { _tag: "lintErrors", lintErrors: lintErrors.map(toContractLintError) }; + } + + const contentJson = workflowDefinitionContentJson(input.definition); + yield* deps.workspaceFileSystem + .writeFile({ + cwd: input.workspaceRoot, + relativePath: input.relativePath, + contents: contentJson, + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to write workflow board file"))); + + yield* deps.fileLoader + .loadAndRegister({ + boardId: input.boardId, + projectId: input.projectId, + workspaceRoot: input.workspaceRoot, + relativePath: input.relativePath, + }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to register saved workflow board"))); + + const updatedBoard = yield* deps.readModel + .getBoard(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load saved workflow board"))); + if (!updatedBoard) { + return yield* workflowRpcError(input.notFoundAfterWriteMessage); + } + const versionRecordInput = { + boardId: input.boardId, + versionHash: updatedBoard.workflowVersionHash, + contentJson, + source: input.source, + }; + if (input.versionRecording === "required") { + yield* recordBoardVersionRequired(deps, versionRecordInput); + } else { + yield* recordBoardVersionBestEffort(deps, versionRecordInput); + } + + return { + _tag: "persisted", + definition: encodeWorkflowDefinition(input.definition), + versionHash: updatedBoard.workflowVersionHash, + contentJson, + }; + }); + +const saveBoardDefinition = ( + deps: Pick< + WorkflowRpcHandlerDeps, + | "readModel" + | "boardRegistry" + | "projectWorkspaceResolver" + | "fileLoader" + | "workspaceFileSystem" + | "saveLocks" + | "versionStore" + >, + input: WorkflowSaveBoardDefinitionInput, +): Effect.Effect => + (deps.saveLocks?.withSaveLock ?? ((_boardId, effect) => effect))( + input.boardId, + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition(input.definition).pipe( + Effect.mapError(toWorkflowRpcError("workflow definition decode failed")), + ); + const boardFile = yield* loadWritableWorkflowBoardFile(deps, input.boardId); + const currentVersionHash = sha256Hex(boardFile.currentRaw); + if (currentVersionHash !== input.expectedVersionHash) { + return { + ok: false, + conflict: true, + currentVersionHash, + }; + } + + const persisted = yield* persistWorkflowBoardDefinition(deps, { + boardId: input.boardId, + projectId: boardFile.projectId, + workspaceRoot: boardFile.workspaceRoot, + relativePath: boardFile.board.workflowFilePath, + definition, + source: input.source ?? "save", + notFoundAfterWriteMessage: `Workflow board ${input.boardId} was not found after save`, + }); + if (persisted._tag === "lintErrors") { + return { ok: false, lintErrors: persisted.lintErrors }; + } + + const snapshot = yield* boardSnapshot(deps, input.boardId); + return { + ok: true, + definition: persisted.definition, + versionHash: persisted.versionHash, + snapshot, + }; + }), + ); + +const renameBoard = ( + deps: Pick< + WorkflowRpcHandlerDeps, + | "readModel" + | "boardRegistry" + | "projectWorkspaceResolver" + | "fileLoader" + | "workspaceFileSystem" + | "saveLocks" + | "versionStore" + >, + input: WorkflowRenameBoardHandlerInput, +): Effect.Effect => + decodeWorkflowRenameBoardInput(input).pipe( + Effect.mapError(toWorkflowRpcError("workflow board rename input decode failed")), + Effect.flatMap((decoded) => + (deps.saveLocks?.withSaveLock ?? ((_boardId, effect) => effect))( + decoded.boardId, + Effect.gen(function* () { + const boardFile = yield* loadWritableWorkflowBoardFile(deps, decoded.boardId); + const currentDefinition = yield* decodeWorkflowDefinitionJson(boardFile.currentRaw).pipe( + Effect.mapError(toWorkflowRpcError("workflow board file decode failed")), + ); + if (currentDefinition.name === decoded.name) { + const fileVersionHash = sha256Hex(boardFile.currentRaw); + const registeredDefinition = yield* deps.boardRegistry.getDefinition(decoded.boardId); + const registeredDefinitionHash = + registeredDefinition === null + ? null + : workflowDefinitionVersionHash(registeredDefinition); + const currentDefinitionHash = workflowDefinitionVersionHash(currentDefinition); + const versions = yield* deps.versionStore + .list(decoded.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list workflow board versions"))); + const projectionIsCurrent = boardFile.board.workflowVersionHash === fileVersionHash; + const registryIsCurrent = registeredDefinitionHash === currentDefinitionHash; + const historyIsCurrent = versions[0]?.versionHash === fileVersionHash; + if (projectionIsCurrent && registryIsCurrent && historyIsCurrent) { + return; + } + + if (!projectionIsCurrent || !registryIsCurrent) { + yield* deps.fileLoader + .loadAndRegister({ + boardId: decoded.boardId, + projectId: boardFile.projectId, + workspaceRoot: boardFile.workspaceRoot, + relativePath: boardFile.board.workflowFilePath, + }) + .pipe( + Effect.mapError(toWorkflowRpcError("Failed to register saved workflow board")), + ); + + const updatedBoard = yield* deps.readModel + .getBoard(decoded.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load saved workflow board"))); + if (!updatedBoard) { + return yield* workflowRpcError( + `Workflow board ${decoded.boardId} was not found after rename`, + ); + } + } + + if (!historyIsCurrent) { + yield* recordBoardVersionRequired(deps, { + boardId: decoded.boardId, + versionHash: fileVersionHash, + contentJson: boardFile.currentRaw, + source: "rename", + }); + } + return; + } + + const persisted = yield* persistWorkflowBoardDefinition(deps, { + boardId: decoded.boardId, + projectId: boardFile.projectId, + workspaceRoot: boardFile.workspaceRoot, + relativePath: boardFile.board.workflowFilePath, + definition: { ...currentDefinition, name: decoded.name }, + source: "rename", + notFoundAfterWriteMessage: `Workflow board ${decoded.boardId} was not found after rename`, + versionRecording: "required", + }); + if (persisted._tag === "lintErrors") { + return yield* workflowRpcError( + `Workflow lint failed: ${persisted.lintErrors.map((error) => error.code).join(", ")}`, + ); + } + }), + ), + ), + ); + +export const workflowRpcHandlers = (deps: WorkflowRpcHandlerDeps) => ({ + [WORKFLOW_WS_METHODS.listBoards]: (input: { readonly projectId: ProjectId }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.listBoards, + deps.boardDiscovery.discover(input.projectId), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.createBoard]: (input: WorkflowCreateBoardHandlerInput) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.createBoard, createBoard(deps, input), { + "rpc.aggregate": "workflow", + }), + [WORKFLOW_WS_METHODS.deleteBoard]: (input: WorkflowDeleteBoardInput) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.deleteBoard, deleteBoard(deps, input), { + "rpc.aggregate": "workflow", + }), + [WORKFLOW_WS_METHODS.renameBoard]: (input: WorkflowRenameBoardHandlerInput) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.renameBoard, renameBoard(deps, input), { + "rpc.aggregate": "workflow", + }), + [WORKFLOW_WS_METHODS.getBoard]: (input: { readonly boardId: BoardId }) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.getBoard, boardSnapshot(deps, input.boardId), { + "rpc.aggregate": "workflow", + }), + [WORKFLOW_WS_METHODS.getBoardDefinition]: (input: WorkflowGetBoardDefinitionInput) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.getBoardDefinition, getBoardDefinition(deps, input), { + "rpc.aggregate": "workflow", + }), + [WORKFLOW_WS_METHODS.saveBoardDefinition]: (input: WorkflowSaveBoardDefinitionInput) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.saveBoardDefinition, + saveBoardDefinition(deps, input), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.listBoardVersions]: (input: WorkflowGetBoardDefinitionInput) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.listBoardVersions, listBoardVersions(deps, input), { + "rpc.aggregate": "workflow", + }), + [WORKFLOW_WS_METHODS.getBoardVersion]: (input: WorkflowGetBoardVersionInput) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.getBoardVersion, getBoardVersion(deps, input), { + "rpc.aggregate": "workflow", + }), + [WORKFLOW_WS_METHODS.subscribeBoard]: (input: { readonly boardId: BoardId }) => + deps.observeRpcStreamEffect( + WORKFLOW_WS_METHODS.subscribeBoard, + boardSnapshot(deps, input.boardId).pipe( + Effect.map((snapshot) => + Stream.concat( + Stream.make({ kind: "snapshot" as const, snapshot }), + deps.boardEvents + .stream(input.boardId) + .pipe(Stream.map((ticket) => ({ kind: "ticket" as const, ticket }))), + ), + ), + ), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.createTicket]: (input: WorkflowCreateTicketInput) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.createTicket, + deps.engine + .createTicket({ + boardId: input.boardId, + title: input.title, + initialLane: input.initialLane, + ...(input.description === undefined ? {} : { description: input.description }), + ...(input.dependsOn === undefined ? {} : { dependsOn: input.dependsOn }), + ...(input.tokenBudget === undefined ? {} : { tokenBudget: input.tokenBudget }), + }) + .pipe( + Effect.mapError(toWorkflowRpcError("Failed to create workflow ticket")), + Effect.map((ticketId) => ({ ticketId })), + ), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.editTicket]: (input: WorkflowEditTicketInput) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.editTicket, + deps.engine + .editTicket(input) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to edit workflow ticket"))), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.moveTicket]: (input: { + readonly ticketId: TicketId; + readonly toLane: LaneKey; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.moveTicket, + deps.engine + .moveTicket(input.ticketId, input.toLane) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to move workflow ticket"))), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.runLane]: (input: { readonly ticketId: TicketId }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.runLane, + deps.engine + .runLane(input.ticketId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to run workflow lane"))), + { + "rpc.aggregate": "workflow", + }, + ), + [WORKFLOW_WS_METHODS.resolveApproval]: (input: { + readonly stepRunId: StepRunId; + readonly approved: boolean; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.resolveApproval, + deps.engine + .resolveApproval(input.stepRunId, input.approved) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to resolve workflow approval"))), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.answerTicketStep]: (input: WorkflowAnswerTicketStepInput) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.answerTicketStep, + deps.engine + .answerTicketStep(input) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to answer workflow ticket step"))), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.postTicketMessage]: (input: { + readonly ticketId: TicketId; + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray | undefined; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.postTicketMessage, + deps.engine + .postTicketMessage(input) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to post workflow ticket message"))), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.setProjectScriptTrust]: (input: { + readonly projectId: ProjectId; + readonly trusted: boolean; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.setProjectScriptTrust, + deps.projectScriptTrust + .setTrusted(input.projectId, input.trusted) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to update project script trust"))), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.cancelStep]: (input: { readonly stepRunId: StepRunId }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.cancelStep, + deps.engine + .cancelStep(input.stepRunId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to cancel workflow step"))), + { "rpc.aggregate": "workflow" }, + ), + [WORKFLOW_WS_METHODS.getTicketDetail]: (input: { readonly ticketId: TicketId }) => + deps.observeRpcEffect(WORKFLOW_WS_METHODS.getTicketDetail, ticketDetail(deps, input.ticketId), { + "rpc.aggregate": "workflow", + }), + [WORKFLOW_WS_METHODS.getTicketDiff]: (input: { readonly ticketId: TicketId }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.getTicketDiff, + deps.ticketWorktrees + .resolveForTicket(input.ticketId) + .pipe( + Effect.flatMap(({ cwd, baseRef }) => + deps.ticketDiff + .getTicketDiff(input.ticketId, cwd, baseRef) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load workflow ticket diff"))), + ), + ), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.listTicketArtifacts]: (input: { readonly ticketId: TicketId }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.listTicketArtifacts, + Effect.gen(function* () { + const worktree = yield* deps.ticketWorktrees.resolveForTicket(input.ticketId); + const scratchDir = `.t3/ticket/${input.ticketId}`; + const names = yield* deps.workspaceFileSystem + .listFiles({ cwd: worktree.cwd, relativePath: scratchDir }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list ticket artifacts"))); + const artifacts: Array<{ + readonly name: string; + readonly content: string; + readonly truncated?: boolean; + }> = []; + for (const name of names.slice(0, MAX_TICKET_ARTIFACTS)) { + const content = yield* deps.workspaceFileSystem + .readFileString({ cwd: worktree.cwd, relativePath: `${scratchDir}/${name}` }) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to read ticket artifact"))); + artifacts.push({ + name, + content: content.slice(0, MAX_TICKET_ARTIFACT_CHARS), + ...(content.length > MAX_TICKET_ARTIFACT_CHARS ? { truncated: true } : {}), + }); + } + return { artifacts }; + }), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.getBoardDigest]: (input: { + readonly boardId: BoardId; + readonly windowHours?: number | undefined; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.getBoardDigest, + Effect.gen(function* () { + const windowHours = + input.windowHours === undefined || !Number.isFinite(input.windowHours) + ? 24 + : Math.min(24 * 7, Math.max(1, Math.floor(input.windowHours))); + const digest = yield* deps.readModel + .getBoardDigest(input.boardId, windowHours) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to compute board digest"))); + return { + windowHours: digest.windowHours, + createdCount: digest.createdCount, + shippedCount: digest.shippedCount, + totalTokens: digest.totalTokens, + totalDurationMs: digest.totalDurationMs, + needsAttention: digest.needsAttention.map((row) => ({ + ticketId: row.ticketId as TicketId, + title: row.title, + status: row.status, + laneKey: row.laneKey as LaneKey, + sinceMs: Math.max(0, Math.floor(row.sinceMs)), + })), + }; + }), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.dryRunBoard]: (input: { + readonly definition: WorkflowDefinitionEncoded; + readonly startLane: LaneKey; + readonly scenario: WorkflowDryRunScenario; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.dryRunBoard, + Effect.gen(function* () { + const predicates = deps.predicates; + if (predicates === undefined) { + return yield* workflowRpcError("Dry run is not available on this server"); + } + // Read-scoped callers send arbitrary definitions — bound the work + // before decoding so a huge payload cannot burn CPU/memory. + if ( + // @effect-diagnostics-next-line preferSchemaOverJson:off — pure size probe, not parsing + JSON.stringify(input.definition).length > MAX_DRY_RUN_DEFINITION_CHARS || + input.definition.lanes.length > MAX_DRY_RUN_LANES || + input.definition.lanes.some( + (lane) => + (lane.pipeline?.length ?? 0) > MAX_DRY_RUN_PER_LANE || + (lane.transitions?.length ?? 0) > MAX_DRY_RUN_PER_LANE || + (lane.onEvent?.length ?? 0) > MAX_DRY_RUN_PER_LANE, + ) + ) { + return yield* workflowRpcError("Workflow definition is too large to dry-run"); + } + const definition = yield* Schema.decodeUnknownEffect(WorkflowDefinition)( + input.definition, + ).pipe(Effect.mapError(toWorkflowRpcError("Workflow definition is invalid"))); + if ( + !definition.lanes.some((lane) => (lane.key as string) === (input.startLane as string)) + ) { + return yield* workflowRpcError(`Start lane "${input.startLane}" was not found`); + } + return yield* simulateBoardRoute({ + definition, + startLane: input.startLane, + scenario: input.scenario, + evaluator: predicates, + }); + }), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.getWebhookConfig]: (input: { + readonly boardId: BoardId; + readonly rotate?: boolean | undefined; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.getWebhookConfig, + Effect.gen(function* () { + const webhook = deps.webhook; + if (webhook === undefined) { + return yield* workflowRpcError("Webhooks are not available on this server"); + } + const board = yield* deps.readModel + .getBoard(input.boardId) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load workflow board"))); + if (board === null) { + return yield* workflowRpcError(`Workflow board ${input.boardId} was not found`); + } + const config = yield* webhook + .getConfig(input.boardId, input.rotate === true) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to load webhook config"))); + return { + path: config.path, + hasToken: config.hasToken, + ...(config.tokenPrefix === undefined ? {} : { tokenPrefix: config.tokenPrefix }), + ...(config.token === undefined ? {} : { token: config.token }), + }; + }), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.intakeTickets]: (input: { + readonly boardId: BoardId; + readonly braindump: string; + readonly agent: AgentSelection; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.intakeTickets, + Effect.gen(function* () { + const intake = deps.intake; + if (intake === undefined) { + return yield* workflowRpcError("Ticket intake is not available on this server"); + } + const proposals = yield* intake + .proposeTickets(input) + .pipe(Effect.mapError(toWorkflowRpcError("Failed to propose tickets from braindump"))); + return { proposals: [...proposals] } satisfies WorkflowIntakeResult; + }), + { "rpc.aggregate": "workflow" }, + ), +}); diff --git a/apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts b/apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts new file mode 100644 index 00000000000..c4b22da5705 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts @@ -0,0 +1,1088 @@ +import { assert, it } from "@effect/vitest"; +import type { TerminalEvent } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { ProviderTurnPort } from "../Services/ProviderDispatchOutbox.ts"; +import { ProjectScriptTrust } from "../Services/ProjectScriptTrust.ts"; +import { SetupTerminalPort } from "../Services/SetupRunService.ts"; +import { TicketCheckpointService } from "../Services/TicketCheckpointService.ts"; +import { MergeGitPort } from "../Services/TicketMergeService.ts"; +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { WorktreePort } from "../Services/WorktreePort.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; +import { WorkflowRecovery } from "../Services/WorkflowRecovery.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { MockAcpProvider, MockAcpProviderLive } from "./MockAcpProvider.ts"; +import { WorkflowRuntimeCoreLive } from "../WorkflowRuntimeLive.ts"; + +const definition = { + name: "runtime-wf", + lanes: [ + { + key: "code", + name: "Code", + entry: "auto", + pipeline: [ + { + key: "code-step", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "Write the code", + }, + ], + on: { success: "review", failure: "code" }, + }, + { + key: "review", + name: "Review", + entry: "auto", + pipeline: [ + { + key: "review-step", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "Review the code", + }, + ], + on: { success: "done", failure: "code" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const smartRoutingDefinition = { + name: "smart-routing-runtime-wf", + lanes: [ + { + key: "impl", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "tests", + type: "script", + run: "pnpm test", + allowFailure: true, + }, + { + key: "review", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "Review the test result", + captureOutput: true, + }, + ], + transitions: [ + { + when: { + and: [ + { "!=": [{ var: "steps.tests.exitCode" }, 0] }, + { "==": [{ var: "steps.review.output.verdict" }, "block"] }, + ], + }, + to: "needs", + }, + ], + on: { success: "done" }, + }, + { key: "needs", name: "Needs Attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const wipDrainDefinition = { + name: "wip-runtime-wf", + lanes: [ + { + key: "build", + name: "Build", + entry: "auto", + wipLimit: 1, + pipeline: [ + { + key: "build-step", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "Build the ticket", + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const terminalManagerLayer = (scriptExitCode: number) => + Layer.effect( + TerminalManager, + Effect.gen(function* () { + const listeners = yield* Ref.make< + ReadonlyArray<(event: TerminalEvent) => Effect.Effect> + >([]); + + return TerminalManager.of({ + open: (input) => + Effect.succeed({ + threadId: input.threadId, + terminalId: input.terminalId, + cwd: input.cwd, + status: "running", + } as never), + attachStream: () => Effect.die("unused terminal.attachStream"), + attachHistoryStream: () => Effect.die("unused terminal.attachHistoryStream"), + write: (input) => + Ref.get(listeners).pipe( + Effect.flatMap((current) => + Effect.forEach( + current, + (listener) => + listener({ + type: "exited", + threadId: input.threadId, + terminalId: input.terminalId, + exitCode: scriptExitCode, + exitSignal: null, + } as never), + { discard: true }, + ), + ), + ), + resize: () => Effect.void, + clear: () => Effect.void, + restart: () => Effect.die("unused terminal.restart"), + close: () => Effect.void, + subscribe: (listener) => + Ref.update(listeners, (current) => [...current, listener as never]).pipe( + Effect.as(() => undefined), + ), + subscribeMetadata: () => Effect.succeed(() => undefined), + }); + }), + ); + +const makeRuntimeLayer = (scriptExitCode: number) => + WorkflowRuntimeCoreLive.pipe( + Layer.provideMerge(MockAcpProviderLive), + Layer.provideMerge(terminalManagerLayer(scriptExitCode)), + Layer.provideMerge( + Layer.succeed(SetupTerminalPort, { + launch: () => Effect.succeed({ terminalId: null }), + awaitExit: () => Effect.succeed({ exitCode: 0 }), + }), + ), + Layer.provideMerge( + Layer.effect( + WorktreePort, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return WorktreePort.of({ + ensureWorktree: (ticketId) => + Effect.gen(function* () { + const worktreePath = yield* fileSystem + .makeTempDirectory({ + prefix: `t3-runtime-${ticketId}-`, + }) + .pipe( + Effect.mapError( + (cause) => + new WorkflowEventStoreError({ + message: "test worktree tempdir failed", + cause, + }), + ), + ); + return { + repoRoot: worktreePath, + worktreeRef: `wt-${ticketId}`, + path: worktreePath, + }; + }), + }); + }), + ), + ), + Layer.provideMerge( + Layer.succeed(MergeGitPort, { + run: () => Effect.succeed({ exitCode: 0, stdout: "", stderr: "" }), + }), + ), + Layer.provideMerge( + Layer.succeed(TicketCheckpointService, { + hasBaseline: () => Effect.succeed(false), + captureBaseline: (ticketId) => Effect.succeed(`refs/t3/tickets/${ticketId}/base` as string), + captureStep: (ticketId, stepRunId, _cwd, kind) => + Effect.succeed(`refs/t3/tickets/${ticketId}/steps/${stepRunId}/${kind}` as string), + }), + ), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(NodeServices.layer), + ); + +const runtimeLayer = it.layer(makeRuntimeLayer(0)); +const smartRoutingLayer = it.layer(makeRuntimeLayer(1)); + +const advanceRuntime = Effect.gen(function* () { + yield* TestClock.adjust("500 millis"); + yield* Effect.yieldNow; +}); + +const waitFor = (predicate: Effect.Effect, label: string): Effect.Effect => + Effect.gen(function* () { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (yield* predicate) { + return; + } + yield* advanceRuntime; + } + assert.fail(`Timed out waiting for ${label}`); + }); + +const waitForDetail = ( + read: WorkflowReadModel["Service"], + ticketId: string, + predicate: (detail: TicketDetail | null) => boolean, + label: string, +) => + Effect.gen(function* () { + for (let attempt = 0; attempt < 20; attempt += 1) { + const detail = yield* read.getTicketDetail(ticketId as never); + if (predicate(detail)) { + return detail; + } + yield* advanceRuntime; + } + assert.fail(`Timed out waiting for ${label}`); + }); + +const waitForDispatchForTicket = (ticketId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + for (let attempt = 0; attempt < 20; attempt += 1) { + const rows = yield* sql<{ readonly threadId: string; readonly turnId: string | null }>` + SELECT thread_id AS "threadId", turn_id AS "turnId" + FROM workflow_dispatch_outbox + WHERE ticket_id = ${ticketId} + AND turn_id IS NOT NULL + ORDER BY created_at DESC, dispatch_id DESC + LIMIT 1 + `; + const row = rows[0]; + if (row?.turnId) { + return { threadId: row.threadId, turnId: row.turnId }; + } + yield* advanceRuntime; + } + assert.fail(`Timed out waiting for dispatch for ${ticketId}`); + }); + +const seedAssistantOutput = (input: { + readonly threadId: string; + readonly turnId: string; + readonly messageId: string; + readonly text: string; +}) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + attachments_json, + is_streaming, + created_at, + updated_at + ) + VALUES ( + ${input.messageId}, + ${input.threadId}, + ${input.turnId}, + 'assistant', + ${input.text}, + NULL, + 0, + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + ${input.threadId}, + ${input.turnId}, + NULL, + NULL, + NULL, + ${input.messageId}, + 'completed', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:01.000Z', + NULL, + NULL, + NULL, + '[]' + ) + ON CONFLICT (thread_id, turn_id) + DO UPDATE SET + assistant_message_id = excluded.assistant_message_id, + state = excluded.state, + completed_at = excluded.completed_at + `; + }); + +const registerSmartRoutingBoard = (input: { + readonly boardId: string; + readonly projectId: string; +}) => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const trust = yield* ProjectScriptTrust; + + yield* registry.register(input.boardId as never, smartRoutingDefinition); + yield* read.registerBoard({ + boardId: input.boardId as never, + projectId: input.projectId as never, + name: "Smart routing runtime", + workflowFilePath: ".t3/boards/smart-routing.json", + workflowVersionHash: input.boardId, + maxConcurrentTickets: 3, + }); + yield* trust.setTrusted(input.projectId as never, true); + }); + +const registerWipRuntimeBoard = (input: { readonly boardId: string; readonly projectId: string }) => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + + yield* registry.register(input.boardId as never, wipDrainDefinition); + yield* read.registerBoard({ + boardId: input.boardId as never, + projectId: input.projectId as never, + name: "WIP runtime", + workflowFilePath: ".t3/boards/wip-runtime.json", + workflowVersionHash: input.boardId, + maxConcurrentTickets: 3, + }); + }); + +const assertBuildOccupancy = ( + read: WorkflowReadModel["Service"], + boardId: string, + expected: number, +) => + Effect.gen(function* () { + const admitted = yield* read.countAdmittedInLane(boardId as never, "build" as never); + assert.equal(admitted, expected); + assert.isAtMost(admitted, 1); + }); + +runtimeLayer("WorkflowRuntimeCoreLive", (it) => { + it.effect("runs two real agent steps through the durable runtime", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const mock = yield* MockAcpProvider; + + yield* registry.register("board-runtime" as never, definition); + const ticketId = yield* engine.createTicket({ + boardId: "board-runtime" as never, + title: "Ship runtime", + initialLane: "code" as never, + }); + + yield* waitFor(mock.startedCount.pipe(Effect.map((count) => count === 1)), "first turn"); + yield* mock.completeAllRunning(); + yield* waitForDetail( + read, + ticketId as string, + (detail) => detail?.ticket.currentLaneKey === "review", + "review lane", + ); + + yield* waitFor(mock.startedCount.pipe(Effect.map((count) => count === 2)), "second turn"); + yield* mock.completeAllRunning(); + const done = yield* waitForDetail( + read, + ticketId as string, + (detail) => detail?.ticket.currentLaneKey === "done", + "done lane", + ); + + assert.equal(done?.steps.filter((step) => step.status === "completed").length, 2); + }), + ); + + it.effect("recovers an in-flight dispatch without starting a duplicate provider turn", () => + Effect.gen(function* () { + const recovery = yield* WorkflowRecovery; + const mock = yield* MockAcpProvider; + const provider = yield* ProviderTurnPort; + const sql = yield* SqlClient.SqlClient; + const baselineStarts = yield* mock.startedCount; + + yield* provider.ensureTurnStarted({ + dispatchId: "dispatch-restart" as never, + ticketId: "ticket-restart" as never, + stepRunId: "step-run-restart" as never, + threadId: "thread-restart" as never, + providerInstance: "codex", + model: "gpt-5.5", + instruction: "recover the turn", + worktreePath: "/tmp/wt-restart", + }); + assert.equal(yield* mock.startedCount, baselineStarts + 1); + + yield* sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + 'board-restart', + 'project-restart', + 'Restart Board', + '.t3/boards/restart.json', + 'hash-restart', + 3 + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-restart', + 'board-restart', + 'Recover restart', + 'impl', + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES ( + 'dispatch-restart', + 'ticket-restart', + 'step-run-restart', + 'thread-restart', + 'codex', + 'gpt-5.5', + 'recover the turn', + '/tmp/wt-restart', + 'pending', + '2026-06-07T00:00:00.000Z' + ) + `; + + const fiber = yield* Effect.forkChild(recovery.recover()); + yield* Effect.yieldNow; + yield* mock.completeAllRunning(); + yield* advanceRuntime; + yield* Fiber.join(fiber); + + yield* recovery.recover(); + + assert.equal(yield* mock.startedCount, baselineStarts + 1); + const rows = yield* sql<{ readonly status: string }>` + SELECT status FROM workflow_dispatch_outbox WHERE dispatch_id = 'dispatch-restart' + `; + assert.equal(rows[0]?.status, "confirmed"); + }), + ); + + it.effect("enforces WIP limit and drains queued auto-lane tickets FIFO", () => + Effect.gen(function* () { + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const mock = yield* MockAcpProvider; + const baselineStarts = yield* mock.startedCount; + + yield* registerWipRuntimeBoard({ + boardId: "board-wip-live", + projectId: "project-wip-live", + }); + const firstTicketId = yield* engine.createTicket({ + boardId: "board-wip-live" as never, + title: "First WIP ticket", + initialLane: "build" as never, + }); + const secondTicketId = yield* engine.createTicket({ + boardId: "board-wip-live" as never, + title: "Second WIP ticket", + initialLane: "build" as never, + }); + const thirdTicketId = yield* engine.createTicket({ + boardId: "board-wip-live" as never, + title: "Third WIP ticket", + initialLane: "build" as never, + }); + + yield* waitFor( + mock.startedCount.pipe(Effect.map((count) => count === baselineStarts + 1)), + "first WIP ticket start", + ); + yield* assertBuildOccupancy(read, "board-wip-live", 1); + const firstQueuedState = yield* waitForDetail( + read, + secondTicketId as string, + (detail) => detail?.ticket.status === "queued" && detail.ticket.queuedAt !== null, + "second ticket queued", + ); + const thirdQueuedState = yield* waitForDetail( + read, + thirdTicketId as string, + (detail) => detail?.ticket.status === "queued" && detail.ticket.queuedAt !== null, + "third ticket queued", + ); + assert.equal(firstQueuedState?.ticket.currentLaneEntryToken, null); + assert.equal(thirdQueuedState?.ticket.currentLaneEntryToken, null); + + yield* mock.completeAllRunning(); + yield* waitForDetail( + read, + firstTicketId as string, + (detail) => detail?.ticket.currentLaneKey === "done", + "first ticket drained", + ); + const secondAdmitted = yield* waitForDetail( + read, + secondTicketId as string, + (detail) => + detail?.ticket.currentLaneKey === "build" && + detail.ticket.currentLaneEntryToken !== null && + detail.ticket.queuedAt === null, + "second ticket FIFO admit", + ); + const thirdStillQueued = yield* waitForDetail( + read, + thirdTicketId as string, + (detail) => detail?.ticket.status === "queued" && detail.ticket.queuedAt !== null, + "third ticket still queued after first drain", + ); + assert.isNotNull(secondAdmitted?.ticket.currentLaneEntryToken); + assert.equal(thirdStillQueued?.ticket.currentLaneEntryToken, null); + yield* waitFor( + mock.startedCount.pipe(Effect.map((count) => count === baselineStarts + 2)), + "second WIP ticket start", + ); + yield* assertBuildOccupancy(read, "board-wip-live", 1); + + yield* mock.completeAllRunning(); + yield* waitForDetail( + read, + secondTicketId as string, + (detail) => detail?.ticket.currentLaneKey === "done", + "second ticket drained", + ); + const thirdAdmitted = yield* waitForDetail( + read, + thirdTicketId as string, + (detail) => + detail?.ticket.currentLaneKey === "build" && + detail.ticket.currentLaneEntryToken !== null && + detail.ticket.queuedAt === null, + "third ticket FIFO admit", + ); + assert.isNotNull(thirdAdmitted?.ticket.currentLaneEntryToken); + yield* waitFor( + mock.startedCount.pipe(Effect.map((count) => count === baselineStarts + 3)), + "third WIP ticket start", + ); + yield* assertBuildOccupancy(read, "board-wip-live", 1); + + yield* mock.completeAllRunning(); + yield* waitForDetail( + read, + thirdTicketId as string, + (detail) => detail?.ticket.currentLaneKey === "done", + "third ticket drained", + ); + yield* assertBuildOccupancy(read, "board-wip-live", 0); + }), + ); + + it.effect("recovers stranded WIP admission and drains queued tickets FIFO", () => + Effect.gen(function* () { + const recovery = yield* WorkflowRecovery; + const read = yield* WorkflowReadModel; + const committer = yield* WorkflowEventCommitter; + const mock = yield* MockAcpProvider; + const baselineStarts = yield* mock.startedCount; + + yield* registerWipRuntimeBoard({ + boardId: "board-wip-recovered-runtime", + projectId: "project-wip-recovered-runtime", + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-wip-recovered-first-created" as never, + ticketId: "ticket-wip-recovered-first" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "board-wip-recovered-runtime" as never, + title: "Recovered first WIP ticket", + laneKey: "build" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-wip-recovered-first-admitted" as never, + ticketId: "ticket-wip-recovered-first" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "build" as never, + laneEntryToken: "tok-wip-recovered-first" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-wip-recovered-second-created" as never, + ticketId: "ticket-wip-recovered-second" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + boardId: "board-wip-recovered-runtime" as never, + title: "Recovered second WIP ticket", + laneKey: "build" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketQueued", + eventId: "evt-wip-recovered-second-queued" as never, + ticketId: "ticket-wip-recovered-second" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { lane: "build" as never }, + } as never); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-wip-recovered-third-created" as never, + ticketId: "ticket-wip-recovered-third" as never, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + boardId: "board-wip-recovered-runtime" as never, + title: "Recovered third WIP ticket", + laneKey: "build" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketQueued", + eventId: "evt-wip-recovered-third-queued" as never, + ticketId: "ticket-wip-recovered-third" as never, + occurredAt: "2026-06-07T00:00:05.000Z" as never, + payload: { lane: "build" as never }, + } as never); + + yield* recovery.recover(); + yield* waitFor( + mock.startedCount.pipe(Effect.map((count) => count === baselineStarts + 1)), + "stranded recovered WIP ticket start", + ); + yield* assertBuildOccupancy(read, "board-wip-recovered-runtime", 1); + + yield* recovery.recover(); + assert.equal(yield* mock.startedCount, baselineStarts + 1); + + yield* mock.completeAllRunning(); + yield* waitForDetail( + read, + "ticket-wip-recovered-first", + (detail) => detail?.ticket.currentLaneKey === "done", + "recovered first ticket drained", + ); + yield* waitForDetail( + read, + "ticket-wip-recovered-second", + (detail) => + detail?.ticket.currentLaneKey === "build" && + detail.ticket.currentLaneEntryToken !== null && + detail.ticket.queuedAt === null, + "recovered second ticket FIFO admit", + ); + yield* waitForDetail( + read, + "ticket-wip-recovered-third", + (detail) => detail?.ticket.status === "queued" && detail.ticket.queuedAt !== null, + "recovered third ticket still queued", + ); + yield* waitFor( + mock.startedCount.pipe(Effect.map((count) => count === baselineStarts + 2)), + "recovered second ticket start", + ); + yield* assertBuildOccupancy(read, "board-wip-recovered-runtime", 1); + + yield* mock.completeAllRunning(); + yield* waitForDetail( + read, + "ticket-wip-recovered-second", + (detail) => detail?.ticket.currentLaneKey === "done", + "recovered second ticket drained", + ); + yield* waitForDetail( + read, + "ticket-wip-recovered-third", + (detail) => + detail?.ticket.currentLaneKey === "build" && + detail.ticket.currentLaneEntryToken !== null && + detail.ticket.queuedAt === null, + "recovered third ticket FIFO admit", + ); + yield* waitFor( + mock.startedCount.pipe(Effect.map((count) => count === baselineStarts + 3)), + "recovered third ticket start", + ); + yield* assertBuildOccupancy(read, "board-wip-recovered-runtime", 1); + + yield* mock.completeAllRunning(); + yield* waitForDetail( + read, + "ticket-wip-recovered-third", + (detail) => detail?.ticket.currentLaneKey === "done", + "recovered third ticket drained", + ); + yield* assertBuildOccupancy(read, "board-wip-recovered-runtime", 0); + }), + ); +}); + +smartRoutingLayer("WorkflowRuntime smart routing integration", (it) => { + it.effect("branches live on script exit code and captured agent verdict", () => + Effect.gen(function* () { + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const mock = yield* MockAcpProvider; + const store = yield* WorkflowEventStore; + const baselineStarts = yield* mock.startedCount; + + yield* registerSmartRoutingBoard({ + boardId: "board-smart-live", + projectId: "project-smart-live", + }); + const ticketId = yield* engine.createTicket({ + boardId: "board-smart-live" as never, + title: "Smart live route", + initialLane: "impl" as never, + }); + + const afterScript = yield* waitForDetail( + read, + ticketId as string, + (detail) => + detail?.steps.some((step) => step.stepKey === "tests" && step.status !== "running") === + true, + "script terminal step", + ); + assert.equal( + afterScript?.steps.find((step) => step.stepKey === "tests")?.status, + "completed", + ); + yield* waitFor( + mock.startedCount.pipe(Effect.map((count) => count >= baselineStarts + 1)), + "review turn", + ); + const dispatch = yield* waitForDispatchForTicket(ticketId as string); + yield* seedAssistantOutput({ + ...dispatch, + messageId: "assistant-smart-live", + text: 'Review complete.\n```json\n{"verdict":"block"}\n```', + }); + yield* mock.completeAllRunning(); + + const detail = yield* waitForDetail( + read, + ticketId as string, + (detail) => detail?.ticket.currentLaneKey === "needs", + "needs lane", + ); + assert.equal(detail?.steps.find((step) => step.stepKey === "tests")?.exitCode, 1); + assert.deepEqual(detail?.steps.find((step) => step.stepKey === "review")?.output, { + verdict: "block", + }); + + const events = yield* Stream.runCollect(store.readByTicket(ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const audit = events.find((event) => event.type === "TicketRouteDecided"); + assert.equal(audit?.type, "TicketRouteDecided"); + if (audit?.type !== "TicketRouteDecided") { + assert.fail("expected TicketRouteDecided"); + } + assert.equal(audit.payload.source, "lane_transition"); + assert.equal(audit.payload.toLane, "needs"); + }), + ); + + it.effect("branches recovered on script exit code and recovered agent output", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const committer = yield* WorkflowEventCommitter; + const store = yield* WorkflowEventStore; + const provider = yield* ProviderTurnPort; + const mock = yield* MockAcpProvider; + const recovery = yield* WorkflowRecovery; + const sql = yield* SqlClient.SqlClient; + + yield* registerSmartRoutingBoard({ + boardId: "board-smart-recovered", + projectId: "project-smart-recovered", + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-smart-recovered-ticket" as never, + ticketId: "ticket-smart-recovered" as never, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId: "board-smart-recovered" as never, + title: "Smart recovered route", + laneKey: "impl" as never, + }, + } as never); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-smart-recovered-move" as never, + ticketId: "ticket-smart-recovered" as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "impl" as never, + laneEntryToken: "tok-smart-recovered" as never, + reason: "initial", + }, + } as never); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-smart-recovered-pipeline" as never, + ticketId: "ticket-smart-recovered" as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-smart-recovered" as never, + laneKey: "impl" as never, + laneEntryToken: "tok-smart-recovered" as never, + }, + } as never); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-smart-recovered-tests" as never, + ticketId: "ticket-smart-recovered" as never, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-smart-recovered" as never, + stepRunId: "step-smart-tests" as never, + stepKey: "tests" as never, + stepType: "script", + }, + } as never); + yield* committer.commit({ + type: "ScriptStepStarted", + eventId: "evt-smart-recovered-script-started" as never, + ticketId: "ticket-smart-recovered" as never, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + scriptRunId: "script-smart-recovered" as never, + stepRunId: "step-smart-tests" as never, + scriptThreadId: "workflow-script:script-smart-recovered" as never, + terminalId: "script-smart-recovered", + }, + } as never); + yield* committer.commit({ + type: "ScriptStepExited", + eventId: "evt-smart-recovered-script-exited" as never, + ticketId: "ticket-smart-recovered" as never, + occurredAt: "2026-06-07T00:00:05.000Z" as never, + payload: { + scriptRunId: "script-smart-recovered" as never, + exitCode: 1, + signal: null, + outcome: "exited", + }, + } as never); + yield* committer.commit({ + type: "StepCompleted", + eventId: "evt-smart-recovered-tests-completed" as never, + ticketId: "ticket-smart-recovered" as never, + occurredAt: "2026-06-07T00:00:06.000Z" as never, + payload: { stepRunId: "step-smart-tests" as never }, + } as never); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-smart-recovered-review" as never, + ticketId: "ticket-smart-recovered" as never, + occurredAt: "2026-06-07T00:00:07.000Z" as never, + payload: { + pipelineRunId: "pipeline-smart-recovered" as never, + stepRunId: "step-smart-review" as never, + stepKey: "review" as never, + stepType: "agent", + }, + } as never); + + const { turnId } = yield* provider.ensureTurnStarted({ + dispatchId: "dispatch-smart-recovered" as never, + ticketId: "ticket-smart-recovered" as never, + stepRunId: "step-smart-review" as never, + threadId: "thread-smart-recovered" as never, + providerInstance: "codex", + model: "gpt-5.5", + instruction: "Review the test result", + worktreePath: "/tmp/wt-smart-recovered", + }); + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + turn_id, + created_at, + started_at + ) + VALUES ( + 'dispatch-smart-recovered', + 'ticket-smart-recovered', + 'step-smart-review', + 'thread-smart-recovered', + 'codex', + 'gpt-5.5', + 'Review the test result', + '/tmp/wt-smart-recovered', + 'started', + ${turnId}, + '2026-06-07T00:00:08.000Z', + '2026-06-07T00:00:08.000Z' + ) + `; + yield* seedAssistantOutput({ + threadId: "thread-smart-recovered", + turnId: turnId as string, + messageId: "assistant-smart-recovered", + text: 'Recovered review.\n```json\n{"verdict":"block"}\n```', + }); + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + turn_id, + created_at, + started_at, + confirmed_at + ) + VALUES ( + 'dispatch-smart-recovered-newer', + 'ticket-smart-recovered', + 'step-smart-review', + 'thread-smart-recovered', + 'codex', + 'gpt-5.5', + 'Newer unrelated dispatch', + '/tmp/wt-smart-recovered', + 'confirmed', + 'turn-smart-recovered-newer', + '2026-06-07T00:00:09.000Z', + '2026-06-07T00:00:09.000Z', + '2026-06-07T00:00:10.000Z' + ) + `; + yield* seedAssistantOutput({ + threadId: "thread-smart-recovered", + turnId: "turn-smart-recovered-newer", + messageId: "assistant-smart-recovered-newer", + text: 'Newer unrelated review.\n```json\n{"verdict":"pass"}\n```', + }); + yield* mock.completeAllRunning(); + yield* recovery.recover(); + + const detail = yield* waitForDetail( + read, + "ticket-smart-recovered", + (detail) => detail?.ticket.currentLaneKey === "needs", + "recovered needs lane", + ); + assert.equal(detail?.steps.find((step) => step.stepKey === "tests")?.exitCode, 1); + assert.deepEqual(detail?.steps.find((step) => step.stepKey === "review")?.output, { + verdict: "block", + }); + + const events = yield* Stream.runCollect( + store.readByTicket("ticket-smart-recovered" as never), + ).pipe(Effect.map((chunk) => Array.from(chunk))); + const audit = events.find((event) => event.type === "TicketRouteDecided"); + assert.equal(audit?.type, "TicketRouteDecided"); + if (audit?.type !== "TicketRouteDecided") { + assert.fail("expected TicketRouteDecided"); + } + assert.equal(audit.payload.source, "lane_transition"); + assert.equal(audit.payload.toLane, "needs"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowRuntime.realpath.test.ts b/apps/server/src/workflow/Layers/WorkflowRuntime.realpath.test.ts new file mode 100644 index 00000000000..13ec2527a35 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRuntime.realpath.test.ts @@ -0,0 +1,1452 @@ +// @effect-diagnostics globalTimers:off +// @effect-diagnostics nodeBuiltinImport:off +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { describe } from "vitest"; +import { BoardId, LaneKey, ProjectId, TicketId, TurnId, type VcsError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as TestClock from "effect/testing/TestClock"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; +import * as GitManager from "../../git/GitManager.ts"; +import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; +import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { ServerConfig } from "../../config.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { ProviderTurnPort, type DispatchRequest } from "../Services/ProviderDispatchOutbox.ts"; +import { + ProviderResponsePort, + type ProviderResponseInput, +} from "../Services/ProviderResponsePort.ts"; +import { SetupTerminalPort } from "../Services/SetupRunService.ts"; +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { TicketDiffQuery } from "../Services/TicketDiffQuery.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowReadModel, type TicketDetail } from "../Services/WorkflowReadModel.ts"; +import { WorkflowRecovery } from "../Services/WorkflowRecovery.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { TicketCheckpointServiceLive } from "./TicketCheckpointService.ts"; +import { TicketDiffQueryLive, WorktreeDiffPortLive } from "./TicketDiffQuery.ts"; +import { WorktreePortLive } from "./RealStepExecutor.ts"; +import { TurnProjectionPortLive } from "./TurnStateReader.ts"; +import { WorkflowRuntimeCoreLive } from "../WorkflowRuntimeLive.ts"; +import { MergeGitPortLive } from "./TicketMergeService.ts"; +import { ticketBaseRef } from "../ticketRefs.ts"; +import { WorktreePort } from "../Services/WorktreePort.ts"; + +interface ProviderCall { + readonly threadId: string; + readonly instruction: string; + readonly turnId: string; + readonly worktreePath: string; +} + +interface RealPathProviderDoubleShape { + readonly calls: Effect.Effect>; + readonly completeThread: (threadId: string) => Effect.Effect; + readonly reset: Effect.Effect; + readonly responses: Effect.Effect>; +} + +class RealPathProviderDouble extends Context.Service< + RealPathProviderDouble, + RealPathProviderDoubleShape +>()("t3/workflow/Layers/WorkflowRuntime.realpath.test/RealPathProviderDouble") {} + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-workflow-realpath-test-", +}); +const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); +const GitVcsDriverTestLayer = GitVcsDriver.layer.pipe( + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), +); +const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe( + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), +); +const GitWorkflowServiceTestLayer = GitWorkflowService.layer.pipe( + Layer.provideMerge(VcsDriverTestLayer), + Layer.provideMerge(GitVcsDriverTestLayer), + Layer.provide(Layer.mock(GitManager.GitManager)({})), +); + +const toProviderDoubleError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const RealPathProviderDoubleLive = Layer.unwrap( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const sql = yield* SqlClient.SqlClient; + const calls = yield* Ref.make>([]); + const responses = yield* Ref.make>([]); + const heldAfterAnswerThreads = yield* Ref.make>(new Set()); + const turnCounters = yield* Ref.make>(new Map()); + + const appendInstruction = (request: DispatchRequest) => + Effect.gen(function* () { + const outputPath = path.join(request.worktreePath, "workflow-output.txt"); + const existing = yield* fileSystem + .exists(outputPath) + .pipe( + Effect.flatMap((exists) => + exists ? fileSystem.readFileString(outputPath) : Effect.succeed(""), + ), + ); + yield* fileSystem.writeFileString(outputPath, `${existing}${request.instruction}\n`); + }); + + const upsertTurnState = (input: { + readonly threadId: string; + readonly turnId: string; + readonly state: "running" | "completed" | "interrupted"; + }) => + sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + ${input.threadId}, + ${input.turnId}, + NULL, + NULL, + NULL, + NULL, + ${input.state}, + '2026-06-07T00:01:00.000Z', + '2026-06-07T00:01:00.000Z', + ${input.state === "running" ? null : "2026-06-07T00:01:01.000Z"}, + NULL, + NULL, + NULL, + '[]' + ) + ON CONFLICT (thread_id, turn_id) + DO UPDATE SET + state = excluded.state, + completed_at = excluded.completed_at + `.pipe(Effect.mapError(toProviderDoubleError("provider double turn state failed"))); + + const nextTurnId = (threadId: string) => + Ref.modify(turnCounters, (current) => { + const nextValue = (current.get(threadId) ?? 0) + 1; + const next = new Map(current); + next.set(threadId, nextValue); + return [`turn-${threadId}-${nextValue}`, next] as const; + }); + + const activeProjectedTurn = (threadId: string) => + sql<{ readonly turnId: string; readonly state: string }>` + SELECT turn_id AS "turnId", state + FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + ORDER BY requested_at ASC, turn_id ASC + `.pipe( + Effect.map( + (rows) => + rows.findLast((row) => row.state === "pending" || row.state === "running") ?? null, + ), + Effect.mapError(toProviderDoubleError("provider double active turn lookup failed")), + ); + + const insertUserInputRequest = (threadId: string, turnId: string) => + sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES ( + ${`activity-user-input-requested-${threadId}`}, + ${threadId}, + ${turnId}, + 'approval', + 'user-input.requested', + 'Question for workflow', + ${JSON.stringify({ + requestId: `request-${threadId}`, + questions: [ + { + id: `question-${threadId}`, + question: "Question for workflow", + }, + ], + })}, + 1, + '2026-06-07T00:00:00.000Z' + ) + `.pipe(Effect.mapError(toProviderDoubleError("provider double user input failed"))); + + const insertUserInputResolved = (input: ProviderResponseInput) => + sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES ( + ${`activity-user-input-resolved-${input.threadId}`}, + ${input.threadId}, + NULL, + 'approval', + 'user-input.resolved', + 'Question answered', + ${JSON.stringify({ requestId: input.requestId, approved: input.approved })}, + 2, + '2026-06-07T00:00:01.000Z' + ) + `.pipe(Effect.mapError(toProviderDoubleError("provider double user input failed"))); + + const completeThread = (threadId: string) => + Effect.gen(function* () { + const active = yield* activeProjectedTurn(threadId); + if (active === null) { + return; + } + yield* upsertTurnState({ threadId, turnId: active.turnId, state: "completed" }); + }); + + const providerTurnPort = ProviderTurnPort.of({ + ensureTurnStarted: (request) => + Effect.gen(function* () { + const threadKey = request.threadId as string; + const activeTurn = yield* activeProjectedTurn(threadKey); + if (activeTurn !== null) { + return { turnId: TurnId.make(activeTurn.turnId) }; + } + + const turnIdString = yield* nextTurnId(threadKey); + const turnId = TurnId.make(turnIdString); + yield* Ref.update(calls, (current) => [ + ...current, + { + threadId: threadKey, + instruction: request.instruction, + turnId: turnIdString, + worktreePath: request.worktreePath, + }, + ]); + yield* upsertTurnState({ threadId: threadKey, turnId: turnIdString, state: "running" }); + yield* appendInstruction(request); + if (request.instruction.includes("ASK_PROVIDER_QUESTION")) { + yield* insertUserInputRequest(threadKey, turnIdString); + if (request.instruction.includes("DELAY_AFTER_ANSWER")) { + yield* Ref.update(heldAfterAnswerThreads, (current) => { + const next = new Set(current); + next.add(threadKey); + return next; + }); + } + return { turnId }; + } + yield* upsertTurnState({ threadId: threadKey, turnId: turnIdString, state: "completed" }); + return { turnId }; + }).pipe(Effect.mapError(toProviderDoubleError("provider double turn failed"))), + }); + + const providerResponsePort = ProviderResponsePort.of({ + respond: (input) => + Effect.gen(function* () { + yield* Ref.update(responses, (current) => [...current, input]); + yield* insertUserInputResolved(input); + const threadId = input.threadId as string; + const heldThreads = yield* Ref.get(heldAfterAnswerThreads); + if (!heldThreads.has(threadId)) { + yield* completeThread(threadId); + } + }).pipe(Effect.mapError(toProviderDoubleError("provider double response failed"))), + }); + + const tracker = RealPathProviderDouble.of({ + calls: Ref.get(calls), + completeThread, + reset: Effect.all( + [ + Ref.set(calls, []), + Ref.set(responses, []), + Ref.set(heldAfterAnswerThreads, new Set()), + Ref.set(turnCounters, new Map()), + ], + { discard: true }, + ), + responses: Ref.get(responses), + }); + + return Layer.mergeAll( + Layer.succeed(ProviderTurnPort, providerTurnPort), + Layer.succeed(ProviderResponsePort, providerResponsePort), + Layer.succeed(RealPathProviderDouble, tracker), + ); + }), +); + +const TestLayer = Layer.mergeAll(WorkflowRuntimeCoreLive, TicketDiffQueryLive).pipe( + Layer.provideMerge(RealPathProviderDoubleLive), + Layer.provideMerge( + Layer.succeed(TerminalManager, { + open: () => Effect.die("unused terminal.open"), + attachStream: () => Effect.die("unused terminal.attachStream"), + attachHistoryStream: () => Effect.die("unused terminal.attachHistoryStream"), + write: () => Effect.die("unused terminal.write"), + resize: () => Effect.die("unused terminal.resize"), + clear: () => Effect.die("unused terminal.clear"), + restart: () => Effect.die("unused terminal.restart"), + close: () => Effect.void, + subscribe: () => Effect.succeed(() => undefined), + subscribeMetadata: () => Effect.succeed(() => undefined), + }), + ), + Layer.provideMerge(TurnProjectionPortLive), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(WorktreePortLive), + Layer.provideMerge(TicketCheckpointServiceLive), + Layer.provideMerge(CheckpointStoreLive), + Layer.provideMerge(WorktreeDiffPortLive), + Layer.provideMerge( + Layer.succeed(SetupTerminalPort, { + launch: () => Effect.succeed({ terminalId: null }), + awaitExit: () => Effect.succeed({ exitCode: 0 }), + }), + ), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(GitWorkflowServiceTestLayer), + Layer.provideMerge(MergeGitPortLive), + Layer.provideMerge(GitVcsDriverTestLayer), + Layer.provideMerge(VcsDriverTestLayer), + Layer.provideMerge(VcsProcessTestLayer), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), +); + +const makeTmpDir = ( + prefix = "workflow-realpath-", +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); + +const writeTextFile = ( + filePath: string, + contents: string, +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(filePath, contents); + }); + +const makeDirectory = ( + directoryPath: string, +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(directoryPath, { recursive: true }); + }); + +const git = ( + cwd: string, + args: ReadonlyArray, +): Effect.Effect => + Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const result = yield* process.run({ + operation: "WorkflowRuntime.realpath.git", + command: "git", + cwd, + args, + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +const initRepoWithCommit = ( + cwd: string, +): Effect.Effect< + void, + VcsError | PlatformError.PlatformError, + VcsProcess.VcsProcess | FileSystem.FileSystem +> => + Effect.gen(function* () { + yield* git(cwd, ["init"]); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(path.join(cwd, "README.md"), "# workflow repo\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial commit"]); + }); + +const withProcessCwd = ( + cwd: string, + effect: Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const previous = process.cwd(); + yield* Effect.sync(() => process.chdir(cwd)); + return yield* effect.pipe(Effect.ensuring(Effect.sync(() => process.chdir(previous)))); + }); + +const waitForDetail = ( + read: WorkflowReadModel["Service"], + ticketId: TicketId, + predicate: (detail: TicketDetail | null) => boolean, + label: string, +) => + Effect.gen(function* () { + for (let attempt = 0; attempt < 80; attempt += 1) { + const detail = yield* read.getTicketDetail(ticketId); + if (predicate(detail)) { + return detail; + } + yield* TestClock.adjust("50 millis"); + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25))); + yield* Effect.yieldNow; + } + assert.fail(`Timed out waiting for ${label}`); + }); + +const seedProject = (projectId: ProjectId, repoRoot: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + ${projectId}, + 'Workflow project', + ${repoRoot}, + NULL, + '[]', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z', + NULL + ) + `; + }); + +const registerBoardProjection = (input: { + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly name: string; + readonly repoRoot: string; +}) => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + yield* read.registerBoard({ + boardId: input.boardId, + projectId: input.projectId, + name: input.name, + workflowFilePath: path.join(input.repoRoot, ".t3", "boards", "delivery.json"), + workflowVersionHash: "test", + maxConcurrentTickets: 1, + }); + }); + +describe.sequential("Workflow runtime real path", () => { + it.effect("runs a two-step agent pipeline in one project worktree with accumulated diff", () => + Effect.gen(function* () { + const targetRepo = yield* makeTmpDir("workflow-target-repo-"); + const wrongRepo = yield* makeTmpDir("workflow-wrong-cwd-"); + yield* initRepoWithCommit(targetRepo); + yield* initRepoWithCommit(wrongRepo); + yield* makeDirectory(path.join(targetRepo, "prompts")); + yield* writeTextFile(path.join(targetRepo, "prompts", "step-one.md"), "first file prompt"); + + const boardId = BoardId.make("board-realpath"); + const projectId = ProjectId.make("project-realpath"); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const provider = yield* RealPathProviderDouble; + + yield* provider.reset; + yield* seedProject(projectId, targetRepo); + yield* registry.register(boardId, { + name: "Real path board", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: { file: "prompts/step-one.md" }, + }, + { + key: "review", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "second inline prompt", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* registerBoardProjection({ + boardId, + projectId, + name: "Real path board", + repoRoot: targetRepo, + }); + + const { ticketId, done } = yield* withProcessCwd( + wrongRepo, + Effect.gen(function* () { + const ticketId = yield* engine.createTicket({ + boardId, + title: "Ship a real worktree ticket", + initialLane: LaneKey.make("implement"), + }); + const done = yield* waitForDetail( + read, + ticketId, + (detail) => + detail?.ticket.currentLaneKey === "done" || + detail?.ticket.currentLaneKey === "needs_attention", + "terminal lane", + ); + return { ticketId, done }; + }), + ); + const calls = yield* provider.calls; + + assert.equal(done?.ticket.currentLaneKey, "done"); + assert.equal(calls.length, 2); + assert.equal(calls[0]?.worktreePath, calls[1]?.worktreePath); + assert.isTrue((calls[0]?.worktreePath ?? "").includes(path.basename(targetRepo))); + assert.equal(calls[0]?.instruction, "first file prompt"); + assert.equal(calls[1]?.instruction, "second inline prompt"); + assert.match( + yield* git(targetRepo, ["branch", "--list", "workflow/ticket-1"]), + /workflow\/ticket-1/, + ); + assert.equal(yield* git(wrongRepo, ["branch", "--list", "workflow/ticket-1"]), ""); + + const ticketDiff = yield* TicketDiffQuery; + const diff = yield* ticketDiff.getTicketDiff( + ticketId, + calls[0]?.worktreePath ?? "", + ticketBaseRef(ticketId), + ); + assert.include(diff.patch, "+first file prompt"); + assert.include(diff.patch, "+second inline prompt"); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("surfaces a real provider question as waiting_on_user and resumes the pipeline", () => + Effect.gen(function* () { + const repo = yield* makeTmpDir("workflow-question-repo-"); + yield* initRepoWithCommit(repo); + + const boardId = BoardId.make("board-question"); + const projectId = ProjectId.make("project-question"); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const provider = yield* RealPathProviderDouble; + + yield* provider.reset; + yield* seedProject(projectId, repo); + yield* registry.register(boardId, { + name: "Question board", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "ask", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "ASK_PROVIDER_QUESTION", + }, + { + key: "continue", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "continue after answer", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* registerBoardProjection({ + boardId, + projectId, + name: "Question board", + repoRoot: repo, + }); + + const ticketId = yield* withProcessCwd( + repo, + engine.createTicket({ + boardId, + title: "Question ticket", + initialLane: LaneKey.make("implement"), + }), + ); + const waiting = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.status === "waiting_on_user", + "provider question", + ); + if (waiting === null) { + assert.fail("Expected provider question detail"); + } + const awaitingStep = waiting.steps.find((step) => step.status === "awaiting_user"); + assert.isDefined(awaitingStep); + + yield* engine.answerTicketStep({ + stepRunId: awaitingStep?.stepRunId as never, + text: "Continue after answer.", + }); + const done = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.currentLaneKey === "done", + "question pipeline completion", + ); + if (done === null) { + assert.fail("Expected completed question detail"); + } + const calls = yield* provider.calls; + const responses = yield* provider.responses; + + assert.equal(done.ticket.currentLaneKey, "done"); + assert.equal(calls.length, 2); + assert.deepEqual( + responses.map((response) => response.responseKind), + ["user-input"], + ); + assert.deepEqual( + responses.map((response) => response.text), + ["Continue after answer."], + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("restarts a dead autonomous agent turn and continues the recovered pipeline", () => + Effect.gen(function* () { + const repo = yield* makeTmpDir("workflow-autonomous-restart-repo-"); + yield* initRepoWithCommit(repo); + + const boardId = BoardId.make("board-autonomous-restart"); + const projectId = ProjectId.make("project-autonomous-restart"); + const ticketId = TicketId.make("ticket-autonomous-restart"); + const pipelineRunId = "pipeline-autonomous-restart" as never; + const stepRunId = "step-autonomous-restart" as never; + const threadId = "thread-autonomous-restart" as never; + const registry = yield* BoardRegistry; + const read = yield* WorkflowReadModel; + const recovery = yield* WorkflowRecovery; + const committer = yield* WorkflowEventCommitter; + const provider = yield* RealPathProviderDouble; + const worktrees = yield* WorktreePort; + const sql = yield* SqlClient.SqlClient; + + yield* provider.reset; + yield* seedProject(projectId, repo); + yield* registry.register(boardId, { + name: "Autonomous restart board", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "first", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "recover interrupted autonomous step", + }, + { + key: "second", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "continue after recovered step", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* registerBoardProjection({ + boardId, + projectId, + name: "Autonomous restart board", + repoRoot: repo, + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-autonomous-ticket-created" as never, + ticketId, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId, + title: "Autonomous restart ticket", + laneKey: LaneKey.make("implement"), + }, + }); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-autonomous-ticket-moved" as never, + ticketId, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: LaneKey.make("implement"), + laneEntryToken: "token-autonomous-restart" as never, + reason: "initial", + }, + }); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-autonomous-pipeline-started" as never, + ticketId, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId, + laneKey: LaneKey.make("implement"), + laneEntryToken: "token-autonomous-restart" as never, + }, + }); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-autonomous-step-started" as never, + ticketId, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId, + stepRunId, + stepKey: "first" as never, + stepType: "agent", + }, + }); + + const worktree = yield* worktrees.ensureWorktree(ticketId); + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + ${threadId}, + 'turn-autonomous-dead', + NULL, + NULL, + NULL, + NULL, + 'running', + '2026-06-07T00:00:03.000Z', + '2026-06-07T00:00:03.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + turn_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at, + started_at + ) + VALUES ( + 'dispatch-autonomous-restart', + ${ticketId}, + ${stepRunId}, + ${threadId}, + 'turn-autonomous-dead', + 'codex', + 'gpt-5.5', + 'recover interrupted autonomous step', + ${worktree.path}, + 'started', + '2026-06-07T00:00:03.000Z', + '2026-06-07T00:00:03.000Z' + ) + `; + + yield* recovery.recover(); + const done = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.currentLaneKey === "done", + "autonomous restart completion", + ); + if (done === null) { + assert.fail("Expected completed autonomous restart detail"); + } + const calls = yield* provider.calls; + + assert.equal(done.ticket.currentLaneKey, "done"); + assert.deepEqual( + calls.map((call) => call.instruction), + ["recover interrupted autonomous step", "continue after recovered step"], + ); + assert.isAbove(new Set(calls.map((call) => call.turnId)).size, 1); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect( + "does not start the next provider-question step before the answered turn completes", + () => + Effect.gen(function* () { + const repo = yield* makeTmpDir("workflow-question-race-repo-"); + yield* initRepoWithCommit(repo); + + const boardId = BoardId.make("board-question-race"); + const projectId = ProjectId.make("project-question-race"); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const provider = yield* RealPathProviderDouble; + + yield* provider.reset; + yield* seedProject(projectId, repo); + yield* registry.register(boardId, { + name: "Question race board", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "ask", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "ASK_PROVIDER_QUESTION DELAY_AFTER_ANSWER", + }, + { + key: "after-answer", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "must wait for answered turn terminal", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* registerBoardProjection({ + boardId, + projectId, + name: "Question race board", + repoRoot: repo, + }); + + const ticketId = yield* engine.createTicket({ + boardId, + title: "Question race ticket", + initialLane: LaneKey.make("implement"), + }); + const waiting = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.status === "waiting_on_user", + "delayed provider question", + ); + const awaitingStep = waiting?.steps.find((step) => step.status === "awaiting_user"); + assert.isDefined(awaitingStep); + + const firstCalls = yield* provider.calls; + const questionThreadId = firstCalls[0]?.threadId; + assert.isDefined(questionThreadId); + + const resolveFiber = yield* Effect.forkChild( + engine.answerTicketStep({ + stepRunId: awaitingStep?.stepRunId as never, + text: "Continue after delayed answer.", + }), + ); + yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.status !== "waiting_on_user", + "question answer projection", + ); + yield* TestClock.adjust("250 millis"); + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25))); + const callsBeforeTerminal = yield* provider.calls; + assert.deepEqual( + callsBeforeTerminal.map((call) => call.instruction), + ["ASK_PROVIDER_QUESTION DELAY_AFTER_ANSWER"], + ); + + yield* provider.completeThread(questionThreadId); + yield* Fiber.join(resolveFiber); + const done = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.currentLaneKey === "done", + "question race completion", + ); + assert.equal(done?.ticket.currentLaneKey, "done"); + const callsAfterTerminal = yield* provider.calls; + assert.equal(callsAfterTerminal.length, 2); + assert.equal( + callsAfterTerminal[0]?.instruction, + "ASK_PROVIDER_QUESTION DELAY_AFTER_ANSWER", + ); + // The question/answer dialogue becomes ticket messages, so the next + // step's instruction carries the appended discussion transcript. + assert.match( + callsAfterTerminal[1]?.instruction ?? "", + /^must wait for answered turn terminal\n\n## Ticket discussion\n\n/, + ); + assert.include(callsAfterTerminal[1]?.instruction ?? "", "Continue after delayed answer."); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect( + "recovery returns promptly for a non-terminal dispatch whose provider session is gone", + () => + Effect.gen(function* () { + const repo = yield* makeTmpDir("workflow-recovery-repo-"); + yield* initRepoWithCommit(repo); + const sql = yield* SqlClient.SqlClient; + const recovery = yield* WorkflowRecovery; + const provider = yield* RealPathProviderDouble; + + yield* provider.reset; + yield* sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + 'board-nonterminal', + 'project-nonterminal', + 'Nonterminal Board', + '.t3/boards/nonterminal.json', + 'hash-nonterminal', + 3 + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-nonterminal', + 'board-nonterminal', + 'Recover nonterminal dispatch', + 'impl', + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at, + started_at + ) + VALUES ( + 'dispatch-nonterminal', + 'ticket-nonterminal', + 'step-nonterminal', + 'thread-nonterminal', + 'codex', + 'gpt-5.5', + 'recover without hanging', + ${repo}, + 'started', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z' + ) + `; + + const fiber = yield* Effect.forkChild(recovery.recover()); + let completed = false; + for (let attempt = 0; attempt < 20; attempt += 1) { + const exit = yield* Effect.sync(() => fiber.pollUnsafe()); + if (exit !== undefined) { + completed = true; + break; + } + yield* TestClock.adjust("100 millis"); + yield* Effect.yieldNow; + } + if (!completed) { + yield* Fiber.interrupt(fiber); + assert.fail("Timed out waiting for workflow recovery to return"); + } + yield* Fiber.join(fiber); + const calls = yield* provider.calls; + + assert.deepEqual( + calls.map((call) => call.threadId), + ["thread-nonterminal"], + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("resumes an approval step across restart and continues the pipeline", () => + Effect.gen(function* () { + const repo = yield* makeTmpDir("workflow-approval-restart-repo-"); + yield* initRepoWithCommit(repo); + + const boardId = BoardId.make("board-approval-restart"); + const projectId = ProjectId.make("project-approval-restart"); + const ticketId = TicketId.make("ticket-approval-restart"); + const approvalStepRunId = "step-approval-restart" as never; + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const recovery = yield* WorkflowRecovery; + const committer = yield* WorkflowEventCommitter; + const provider = yield* RealPathProviderDouble; + + yield* provider.reset; + yield* seedProject(projectId, repo); + yield* registry.register(boardId, { + name: "Approval restart board", + lanes: [ + { + key: "review", + name: "Review", + entry: "auto", + pipeline: [ + { + key: "approve", + type: "approval", + prompt: "Approve continuing?", + }, + { + key: "after-approval", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "continue after durable approval", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* registerBoardProjection({ + boardId, + projectId, + name: "Approval restart board", + repoRoot: repo, + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-approval-ticket-created" as never, + ticketId, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId, + title: "Approval restart ticket", + laneKey: LaneKey.make("review"), + }, + }); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-approval-ticket-moved" as never, + ticketId, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: LaneKey.make("review"), + laneEntryToken: "token-approval-restart" as never, + reason: "initial", + }, + }); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-approval-pipeline-started" as never, + ticketId, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-approval-restart" as never, + laneKey: LaneKey.make("review"), + laneEntryToken: "token-approval-restart" as never, + }, + }); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-approval-step-started" as never, + ticketId, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-approval-restart" as never, + stepRunId: approvalStepRunId, + stepKey: "approve" as never, + stepType: "approval", + }, + }); + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "evt-approval-awaiting-user" as never, + ticketId, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + stepRunId: approvalStepRunId, + waitingReason: "Approve continuing?", + }, + }); + + yield* recovery.recover(); + yield* engine.resolveApproval(approvalStepRunId, true); + const done = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.currentLaneKey === "done", + "approval restart completion", + ); + if (done === null) { + assert.fail("Expected completed approval restart detail"); + } + const calls = yield* provider.calls; + + assert.equal(done.ticket.currentLaneKey, "done"); + assert.equal(calls.length, 1); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("resumes a provider question across restart and continues the pipeline", () => + Effect.gen(function* () { + const repo = yield* makeTmpDir("workflow-provider-restart-repo-"); + yield* initRepoWithCommit(repo); + + const boardId = BoardId.make("board-provider-restart"); + const projectId = ProjectId.make("project-provider-restart"); + const ticketId = TicketId.make("ticket-provider-restart"); + const stepRunId = "step-provider-restart" as never; + const threadId = "thread-provider-restart" as never; + const requestId = "request-provider-restart" as never; + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const recovery = yield* WorkflowRecovery; + const committer = yield* WorkflowEventCommitter; + const provider = yield* RealPathProviderDouble; + const sql = yield* SqlClient.SqlClient; + + yield* provider.reset; + yield* seedProject(projectId, repo); + yield* registry.register(boardId, { + name: "Provider restart board", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [ + { + key: "ask", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "ASK_PROVIDER_QUESTION", + }, + { + key: "continue", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "continue after durable provider question", + }, + ], + on: { success: "done", failure: "needs_attention" }, + }, + { key: "needs_attention", name: "Needs attention", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* registerBoardProjection({ + boardId, + projectId, + name: "Provider restart board", + repoRoot: repo, + }); + yield* committer.commit({ + type: "TicketCreated", + eventId: "evt-provider-ticket-created" as never, + ticketId, + occurredAt: "2026-06-07T00:00:00.000Z" as never, + payload: { + boardId, + title: "Provider restart ticket", + laneKey: LaneKey.make("implement"), + }, + }); + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "evt-provider-ticket-moved" as never, + ticketId, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: LaneKey.make("implement"), + laneEntryToken: "token-provider-restart" as never, + reason: "initial", + }, + }); + yield* committer.commit({ + type: "PipelineStarted", + eventId: "evt-provider-pipeline-started" as never, + ticketId, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + pipelineRunId: "pipeline-provider-restart" as never, + laneKey: LaneKey.make("implement"), + laneEntryToken: "token-provider-restart" as never, + }, + }); + yield* committer.commit({ + type: "StepStarted", + eventId: "evt-provider-step-started" as never, + ticketId, + occurredAt: "2026-06-07T00:00:03.000Z" as never, + payload: { + pipelineRunId: "pipeline-provider-restart" as never, + stepRunId, + stepKey: "ask" as never, + stepType: "agent", + }, + }); + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "evt-provider-awaiting-user" as never, + ticketId, + occurredAt: "2026-06-07T00:00:04.000Z" as never, + payload: { + stepRunId, + waitingReason: "Provider is waiting for user input", + providerThreadId: threadId, + providerRequestId: requestId, + providerResponseKind: "user-input", + }, + }); + yield* sql` + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + VALUES ( + ${threadId}, + 'turn-provider-restart', + NULL, + NULL, + NULL, + NULL, + 'running', + '2026-06-07T00:00:04.000Z', + '2026-06-07T00:00:04.000Z', + NULL, + NULL, + NULL, + NULL, + '[]' + ) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at, + started_at + ) + VALUES ( + 'dispatch-provider-restart', + ${ticketId}, + ${stepRunId}, + ${threadId}, + 'codex', + 'gpt-5.5', + 'ASK_PROVIDER_QUESTION', + ${repo}, + 'started', + '2026-06-07T00:00:04.000Z', + '2026-06-07T00:00:04.000Z' + ) + `; + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + VALUES ( + 'activity-provider-restart-user-input', + ${threadId}, + 'turn-provider-restart', + 'approval', + 'user-input.requested', + 'Provider restart question', + ${`{"requestId":"${requestId}"}`}, + 1, + '2026-06-07T00:00:04.000Z' + ) + `; + + yield* recovery.recover(); + yield* engine.answerTicketStep({ + stepRunId, + text: "Continue after restart.", + }); + const done = yield* waitForDetail( + read, + ticketId, + (detail) => detail?.ticket.currentLaneKey === "done", + "provider restart completion", + ); + if (done === null) { + assert.fail("Expected completed provider restart detail"); + } + const calls = yield* provider.calls; + const responses = yield* provider.responses; + + assert.equal(done.ticket.currentLaneKey, "done"); + assert.equal(calls.length, 1); + assert.deepEqual( + responses.map((response) => response.requestId), + [requestId], + ); + assert.deepEqual( + responses.map((response) => response.responseKind), + ["user-input"], + ); + assert.deepEqual( + responses.map((response) => response.text), + ["Continue after restart."], + ); + }).pipe(Effect.provide(TestLayer)), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowTerminalRetentionSweeper.test.ts b/apps/server/src/workflow/Layers/WorkflowTerminalRetentionSweeper.test.ts new file mode 100644 index 00000000000..0c200c59f2b --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowTerminalRetentionSweeper.test.ts @@ -0,0 +1,557 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import type { WorkflowBoardVersionStoreShape } from "../Services/WorkflowBoardVersionStore.ts"; +import { WorkflowEngine, type WorkflowEngineShape } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { WorkflowTerminalRetentionSweeper } from "../Services/WorkflowTerminalRetentionSweeper.ts"; +import { deleteWorkflowBoardOwnedState } from "../boardDeletion.ts"; +import { BoardRegistryLive } from "./BoardRegistry.ts"; +import { WorkflowBoardSaveLocksLive } from "./WorkflowBoardSaveLocks.ts"; +import { WorkflowEventStoreLive } from "./WorkflowEventStore.ts"; +import { WorkflowReadModelLive } from "./WorkflowReadModel.ts"; +import { makeWorkflowTerminalRetentionSweeperLive } from "./WorkflowTerminalRetentionSweeper.ts"; + +const unsupported = () => Effect.die("unsupported workflow engine call") as never; +type TestSaveLocksLayer = Layer.Layer; + +const makeEngineLayer = ( + cancelTicketPipelines: WorkflowEngineShape["cancelTicketPipelines"] = () => Effect.void, +) => + Layer.succeed(WorkflowEngine, { + createTicket: () => unsupported(), + editTicket: () => unsupported(), + moveTicket: () => unsupported(), + runLane: () => unsupported(), + ingestExternalEvent: () => Effect.succeed({ outcome: "noop" as const }), + resolveApproval: () => unsupported(), + answerTicketStep: () => unsupported(), + postTicketMessage: () => unsupported(), + cancelStep: () => unsupported(), + cancelBoardPipelines: () => Effect.void, + cancelTicketPipelines, + recoverBoardWip: () => Effect.void, + completeRecoveredStep: () => unsupported(), + } satisfies WorkflowEngineShape); + +const makeSaveLocksLayer = ( + beforeSaveLock: (sql: SqlClient.SqlClient) => Effect.Effect, +) => + Layer.effect( + WorkflowBoardSaveLocks, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return { + withSaveLock: (_boardId, effect) => + Effect.gen(function* () { + yield* beforeSaveLock(sql).pipe(Effect.orDie); + return yield* effect; + }), + } satisfies WorkflowBoardSaveLocks["Service"]; + }), + ); + +const makeLayer = ({ + cancelTicketPipelines, + maxDeletesPerSweep, + saveLocksLayer = WorkflowBoardSaveLocksLive as TestSaveLocksLayer, +}: { + readonly cancelTicketPipelines?: WorkflowEngineShape["cancelTicketPipelines"]; + readonly maxDeletesPerSweep?: number; + readonly saveLocksLayer?: TestSaveLocksLayer; +} = {}) => + makeWorkflowTerminalRetentionSweeperLive({ + sweepIntervalMs: 60_000, + ...(maxDeletesPerSweep === undefined ? {} : { maxDeletesPerSweep }), + nowMs: Effect.succeed(Date.parse("2026-06-08T00:00:00.000Z")), + }).pipe( + Layer.provideMerge(makeEngineLayer(cancelTicketPipelines)), + Layer.provideMerge(saveLocksLayer), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowEventStoreLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + +const registerRetentionBoardFor = (boardId: string) => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register(boardId as never, { + name: "retention sweep", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "done", + name: "Done", + entry: "manual", + terminal: true, + retention: "1 day", + }, + { key: "archive", name: "Archive", entry: "manual", terminal: true }, + ], + }); + }); + +const registerRetentionBoard = registerRetentionBoardFor("board-retention-sweep"); + +const seedTicket = (input: { + readonly boardId?: string; + readonly ticketId: string; + readonly lane: string; + readonly status?: string; + readonly terminalAt: string | null; +}) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const store = yield* WorkflowEventStore; + const now = "2026-06-08T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + terminal_at, + created_at, + updated_at + ) + VALUES ( + ${input.ticketId}, + ${input.boardId ?? "board-retention-sweep"}, + ${input.ticketId}, + ${input.lane}, + ${input.status ?? "done"}, + ${input.terminalAt}, + ${now}, + ${now} + ) + `; + yield* sql` + INSERT INTO projection_pipeline_run ( + pipeline_run_id, + ticket_id, + lane_key, + lane_entry_token, + status, + started_at + ) + VALUES (${`pipeline-${input.ticketId}`}, ${input.ticketId}, ${input.lane}, ${`token-${input.ticketId}`}, 'completed', ${now}) + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + status, + started_at + ) + VALUES (${`step-${input.ticketId}`}, ${`pipeline-${input.ticketId}`}, ${input.ticketId}, 'cleanup', 'script', 'completed', ${now}) + `; + yield* sql` + INSERT INTO workflow_script_run ( + script_run_id, + step_run_id, + ticket_id, + script_thread_id, + terminal_id, + status, + started_at + ) + VALUES (${`script-${input.ticketId}`}, ${`step-${input.ticketId}`}, ${input.ticketId}, ${`thread-${input.ticketId}`}, ${`terminal-${input.ticketId}`}, 'completed', ${now}) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES (${`dispatch-${input.ticketId}`}, ${input.ticketId}, ${`step-${input.ticketId}`}, ${`thread-${input.ticketId}`}, 'codex', 'gpt-5.5', 'cleanup', ${`/tmp/${input.ticketId}`}, 'completed', ${now}) + `; + yield* sql` + INSERT INTO workflow_setup_run ( + setup_run_id, + ticket_id, + worktree_ref, + status, + started_at + ) + VALUES (${`setup-${input.ticketId}`}, ${input.ticketId}, ${`worktree-${input.ticketId}`}, 'completed', ${now}) + `; + yield* sql` + INSERT INTO projection_ticket_message ( + message_id, + ticket_id, + step_run_id, + author, + body, + attachments_json, + created_at + ) + VALUES (${`message-${input.ticketId}`}, ${input.ticketId}, ${`step-${input.ticketId}`}, 'user', 'cleanup', '[]', ${now}) + `; + yield* store.append({ + type: "TicketCreated", + eventId: `event-${input.ticketId}` as never, + ticketId: input.ticketId as never, + occurredAt: now as never, + payload: { + boardId: (input.boardId ?? "board-retention-sweep") as never, + title: input.ticketId as never, + laneKey: input.lane as never, + }, + }); + }); + +const ticketOwnedRowCount = (ticketId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_ticket WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM projection_pipeline_run WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM projection_step_run WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_script_run WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_dispatch_outbox WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_setup_run WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM projection_ticket_message WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_events WHERE ticket_id = ${ticketId} + `; + return rows.reduce((total, row) => total + row.count, 0); + }); + +const remainingTicketCountForBoard = (boardId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM projection_ticket + WHERE board_id = ${boardId} + `; + return rows[0]?.count ?? 0; + }); + +it.effect("deletes expired terminal tickets and keeps fresh or no-retention terminal tickets", () => + Effect.gen(function* () { + const sweeper = yield* WorkflowTerminalRetentionSweeper; + + yield* registerRetentionBoard; + yield* seedTicket({ + ticketId: "ticket-expired", + lane: "done", + terminalAt: "2026-06-06T00:00:00.000Z", + }); + yield* seedTicket({ + ticketId: "ticket-fresh", + lane: "done", + terminalAt: "2026-06-07T12:00:00.000Z", + }); + yield* seedTicket({ + ticketId: "ticket-no-retention", + lane: "archive", + terminalAt: "2026-06-01T00:00:00.000Z", + }); + + const result = yield* sweeper.sweep(); + + assert.equal(result.deletedCount, 1); + assert.equal(yield* ticketOwnedRowCount("ticket-expired"), 0); + assert.equal(yield* ticketOwnedRowCount("ticket-fresh"), 8); + assert.equal(yield* ticketOwnedRowCount("ticket-no-retention"), 8); + }).pipe(Effect.provide(makeLayer())), +); + +it.effect("skips expired terminal tickets while their workflow status is active", () => + Effect.gen(function* () { + const sweeper = yield* WorkflowTerminalRetentionSweeper; + const activeStatuses = ["running", "waiting_on_user", "blocked", "queued"] as const; + + yield* registerRetentionBoard; + for (const status of activeStatuses) { + yield* seedTicket({ + ticketId: `ticket-active-${status}`, + lane: "done", + status, + terminalAt: "2026-06-06T00:00:00.000Z", + }); + } + + const result = yield* sweeper.sweep(); + + assert.equal(result.candidateCount, 0); + assert.equal(result.deletedCount, 0); + assert.equal(result.failedCount, 0); + for (const status of activeStatuses) { + assert.equal(yield* ticketOwnedRowCount(`ticket-active-${status}`), 8); + } + }).pipe(Effect.provide(makeLayer())), +); + +it.effect("deletes expired terminal tickets after their workflow status is settled", () => + Effect.gen(function* () { + const sweeper = yield* WorkflowTerminalRetentionSweeper; + const settledStatuses = ["idle", "done", "failed"] as const; + + yield* registerRetentionBoard; + for (const status of settledStatuses) { + yield* seedTicket({ + ticketId: `ticket-settled-${status}`, + lane: "done", + status, + terminalAt: "2026-06-06T00:00:00.000Z", + }); + } + + const result = yield* sweeper.sweep(); + + assert.equal(result.candidateCount, 3); + assert.equal(result.deletedCount, 3); + assert.equal(result.failedCount, 0); + for (const status of settledStatuses) { + assert.equal(yield* ticketOwnedRowCount(`ticket-settled-${status}`), 0); + } + }).pipe(Effect.provide(makeLayer())), +); + +it.effect( + "keeps tickets exactly at the retention boundary and deletes strictly older tickets", + () => + Effect.gen(function* () { + const sweeper = yield* WorkflowTerminalRetentionSweeper; + + yield* registerRetentionBoard; + yield* seedTicket({ + ticketId: "ticket-boundary", + lane: "done", + terminalAt: "2026-06-07T00:00:00.000Z", + }); + yield* seedTicket({ + ticketId: "ticket-one-ms-expired", + lane: "done", + terminalAt: "2026-06-06T23:59:59.999Z", + }); + + const result = yield* sweeper.sweep(); + + assert.equal(result.candidateCount, 1); + assert.equal(result.deletedCount, 1); + assert.equal(yield* ticketOwnedRowCount("ticket-boundary"), 8); + assert.equal(yield* ticketOwnedRowCount("ticket-one-ms-expired"), 0); + }).pipe(Effect.provide(makeLayer())), +); + +it.effect("skips a selected ticket that moves out of the terminal lane before delete lock", () => { + let movedCandidate = false; + + return Effect.gen(function* () { + const sweeper = yield* WorkflowTerminalRetentionSweeper; + const sql = yield* SqlClient.SqlClient; + + yield* registerRetentionBoard; + yield* seedTicket({ + ticketId: "ticket-stale-candidate", + lane: "done", + terminalAt: "2026-06-06T00:00:00.000Z", + }); + + const result = yield* sweeper.sweep(); + const rows = yield* sql<{ readonly lane: string; readonly terminalAt: string | null }>` + SELECT current_lane_key AS lane, terminal_at AS "terminalAt" + FROM projection_ticket + WHERE ticket_id = 'ticket-stale-candidate' + `; + + assert.equal(result.candidateCount, 1); + assert.equal(result.deletedCount, 0); + assert.equal(result.failedCount, 0); + assert.equal(yield* ticketOwnedRowCount("ticket-stale-candidate"), 8); + assert.deepEqual(rows, [{ lane: "backlog", terminalAt: null }]); + }).pipe( + Effect.provide( + makeLayer({ + saveLocksLayer: makeSaveLocksLayer((sql) => + movedCandidate + ? Effect.void + : Effect.gen(function* () { + movedCandidate = true; + yield* sql` + UPDATE projection_ticket + SET current_lane_key = 'backlog', + terminal_at = NULL, + updated_at = '2026-06-08T00:00:00.000Z' + WHERE ticket_id = 'ticket-stale-candidate' + `; + }), + ), + }), + ), + ); +}); + +it.effect("caps expired ticket deletes per sweep and continues on the next sweep", () => + Effect.gen(function* () { + const sweeper = yield* WorkflowTerminalRetentionSweeper; + + yield* registerRetentionBoard; + for (let index = 0; index < 101; index += 1) { + yield* seedTicket({ + ticketId: `ticket-batch-${String(index).padStart(3, "0")}`, + lane: "done", + terminalAt: "2026-06-06T00:00:00.000Z", + }); + } + + const first = yield* sweeper.sweep(); + + assert.equal(first.candidateCount, 100); + assert.equal(first.deletedCount, 100); + assert.equal(first.failedCount, 0); + assert.equal(yield* ticketOwnedRowCount("ticket-batch-000"), 0); + assert.equal(yield* ticketOwnedRowCount("ticket-batch-100"), 8); + + const second = yield* sweeper.sweep(); + + assert.equal(second.candidateCount, 1); + assert.equal(second.deletedCount, 1); + assert.equal(second.failedCount, 0); + assert.equal(yield* ticketOwnedRowCount("ticket-batch-100"), 0); + }).pipe(Effect.provide(makeLayer())), +); + +it.effect("round-robins capped sweeps across boards with expired backlogs", () => + Effect.gen(function* () { + const sweeper = yield* WorkflowTerminalRetentionSweeper; + const firstBoard = "board-retention-round-robin-a"; + const secondBoard = "board-retention-round-robin-b"; + + yield* registerRetentionBoardFor(firstBoard); + yield* registerRetentionBoardFor(secondBoard); + for (let index = 0; index < 4; index += 1) { + yield* seedTicket({ + boardId: firstBoard, + ticketId: `ticket-round-robin-a-${index}`, + lane: "done", + terminalAt: "2026-06-06T00:00:00.000Z", + }); + yield* seedTicket({ + boardId: secondBoard, + ticketId: `ticket-round-robin-b-${index}`, + lane: "done", + terminalAt: "2026-06-06T00:00:00.000Z", + }); + } + + const first = yield* sweeper.sweep(); + const second = yield* sweeper.sweep(); + + assert.equal(first.deletedCount, 2); + assert.equal(second.deletedCount, 2); + assert.equal(yield* remainingTicketCountForBoard(firstBoard), 2); + assert.equal(yield* remainingTicketCountForBoard(secondBoard), 2); + }).pipe(Effect.provide(makeLayer({ maxDeletesPerSweep: 2 }))), +); + +it.effect("continues deleting later expired tickets after one ticket cleanup fails", () => { + const failedTickets: string[] = []; + + return Effect.gen(function* () { + const sweeper = yield* WorkflowTerminalRetentionSweeper; + + yield* registerRetentionBoard; + yield* seedTicket({ + ticketId: "ticket-fails", + lane: "done", + terminalAt: "2026-06-06T00:00:00.000Z", + }); + yield* seedTicket({ + ticketId: "ticket-after-failure", + lane: "done", + terminalAt: "2026-06-06T00:00:00.000Z", + }); + + const result = yield* sweeper.sweep(); + + assert.equal(result.deletedCount, 1); + assert.equal(result.failedCount, 1); + assert.deepEqual(failedTickets, ["ticket-fails"]); + assert.equal(yield* ticketOwnedRowCount("ticket-fails"), 8); + assert.equal(yield* ticketOwnedRowCount("ticket-after-failure"), 0); + }).pipe( + Effect.provide( + makeLayer({ + cancelTicketPipelines: (ticketId) => + ticketId === "ticket-fails" + ? Effect.sync(() => { + failedTickets.push(ticketId as string); + }).pipe( + Effect.andThen( + Effect.fail(new WorkflowEventStoreError({ message: "cancel failed" })), + ), + ) + : Effect.void, + }), + ), + ); +}); + +it.effect("serializes with a concurrent board delete without leaving ticket-owned rows", () => + Effect.gen(function* () { + const sweeper = yield* WorkflowTerminalRetentionSweeper; + const saveLocks = yield* WorkflowBoardSaveLocks; + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const eventStore = yield* WorkflowEventStore; + const readModel = yield* WorkflowReadModel; + + yield* registerRetentionBoard; + yield* seedTicket({ + ticketId: "ticket-race", + lane: "done", + terminalAt: "2026-06-06T00:00:00.000Z", + }); + + const deleteFiber = yield* Effect.forkChild( + saveLocks.withSaveLock( + "board-retention-sweep" as never, + deleteWorkflowBoardOwnedState( + { + boardRegistry: registry, + engine, + eventStore, + readModel, + versionStore: { + deleteForBoard: () => Effect.void, + } satisfies Pick, + }, + "board-retention-sweep" as never, + ), + ), + ); + const sweepFiber = yield* Effect.forkChild(sweeper.sweep()); + + yield* Fiber.join(deleteFiber); + yield* Fiber.join(sweepFiber); + + assert.equal(yield* ticketOwnedRowCount("ticket-race"), 0); + }).pipe(Effect.timeout("1 second"), Effect.provide(makeLayer())), +); diff --git a/apps/server/src/workflow/Layers/WorkflowTerminalRetentionSweeper.ts b/apps/server/src/workflow/Layers/WorkflowTerminalRetentionSweeper.ts new file mode 100644 index 00000000000..9607dd5c861 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowTerminalRetentionSweeper.ts @@ -0,0 +1,346 @@ +import type { BoardId, LaneKey, TicketId } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schedule from "effect/Schedule"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { + WorkflowTerminalRetentionSweeper, + type WorkflowTerminalRetentionSweepResult, + type WorkflowTerminalRetentionSweeperShape, +} from "../Services/WorkflowTerminalRetentionSweeper.ts"; +import { WorkflowThreadJanitor } from "../Services/WorkflowThreadJanitor.ts"; +import { WorkflowWorktreeJanitor } from "../Services/WorkflowWorktreeJanitor.ts"; +import { deleteWorkflowBoardTicketOwnedStateWhen } from "../boardDeletion.ts"; + +const DEFAULT_SWEEP_INTERVAL_MS = 15 * 60 * 1000; +const DEFAULT_MAX_DELETES_PER_SWEEP = 100; +const isSettledTerminalTicketStatus = (status: string) => + status === "idle" || status === "done" || status === "failed"; + +export interface WorkflowTerminalRetentionSweeperLiveOptions { + readonly sweepIntervalMs?: number; + readonly maxDeletesPerSweep?: number; + readonly nowMs?: Effect.Effect; +} + +interface ExpiredTicketRow { + readonly ticketId: TicketId; + readonly terminalAt: string; +} + +interface CurrentTicketRetentionRow { + readonly currentLaneKey: LaneKey; + readonly status: string; + readonly terminalAt: string | null; +} + +interface RetentionLaneTarget { + readonly boardId: BoardId; + readonly laneKey: LaneKey; + readonly retentionMs: number; +} + +const makeWorkflowTerminalRetentionSweeper = ( + options?: WorkflowTerminalRetentionSweeperLiveOptions, +) => + Effect.gen(function* () { + const boardRegistry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const eventStore = yield* WorkflowEventStore; + const readModel = yield* WorkflowReadModel; + const saveLocks = yield* WorkflowBoardSaveLocks; + const sql = yield* SqlClient.SqlClient; + const worktreeJanitor = Context.getOption( + (yield* Effect.context()) as Context.Context, + WorkflowWorktreeJanitor, + ); + const threadJanitor = Context.getOption( + (yield* Effect.context()) as Context.Context, + WorkflowThreadJanitor, + ); + + const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS); + const maxDeletesPerSweep = Math.max( + 1, + Math.floor(options?.maxDeletesPerSweep ?? DEFAULT_MAX_DELETES_PER_SWEEP), + ); + const nowMs = options?.nowMs ?? Clock.currentTimeMillis; + const listDefinitions = boardRegistry.listDefinitions; + const cancelTicketPipelines = engine.cancelTicketPipelines; + const deleteTicketState = readModel.deleteTicketState; + let nextSweepCursorKey: string | null = null; + + const retentionTargetKey = (target: Pick) => + `${target.boardId as string}::${target.laneKey as string}`; + + const cursorAfter = ( + targets: ReadonlyArray, + target: RetentionLaneTarget, + ) => { + if (targets.length === 0) { + return null; + } + const currentIndex = targets.findIndex( + (candidate) => retentionTargetKey(candidate) === retentionTargetKey(target), + ); + if (currentIndex < 0) { + return retentionTargetKey(targets[0]!); + } + return retentionTargetKey(targets[(currentIndex + 1) % targets.length]!); + }; + + const rotateTargets = (targets: ReadonlyArray) => { + if (nextSweepCursorKey === null) { + return targets; + } + const startIndex = targets.findIndex( + (target) => retentionTargetKey(target) === nextSweepCursorKey, + ); + if (startIndex <= 0) { + return targets; + } + return [...targets.slice(startIndex), ...targets.slice(0, startIndex)]; + }; + + const expiredTicketsForLane = ( + boardId: BoardId, + laneKey: LaneKey, + cutoffIso: string, + limit: number, + ) => + sql` + SELECT + ticket_id AS "ticketId", + terminal_at AS "terminalAt" + FROM projection_ticket + WHERE board_id = ${boardId} + AND current_lane_key = ${laneKey} + AND terminal_at IS NOT NULL + AND terminal_at < ${cutoffIso} + AND status IN ('idle', 'done', 'failed') + ORDER BY terminal_at ASC, ticket_id ASC + LIMIT ${limit} + `; + + const isStillExpiredTerminalTicket = (boardId: BoardId, ticketId: TicketId) => + Effect.gen(function* () { + const rows = yield* sql` + SELECT + current_lane_key AS "currentLaneKey", + status, + terminal_at AS "terminalAt" + FROM projection_ticket + WHERE board_id = ${boardId} + AND ticket_id = ${ticketId} + `; + const ticket = rows[0]; + if (!ticket?.terminalAt) { + return false; + } + if (!isSettledTerminalTicketStatus(ticket.status)) { + return false; + } + + const lane = yield* boardRegistry.getLane(boardId, ticket.currentLaneKey); + if (lane?.terminal !== true || lane.retention === undefined) { + return false; + } + + const retentionMs = Duration.toMillis(lane.retention); + if (retentionMs <= 0) { + return false; + } + + const now = yield* nowMs; + const cutoffIso = DateTime.formatIso(DateTime.makeUnsafe(now - retentionMs)); + return ticket.terminalAt < cutoffIso; + }); + + const sweep: WorkflowTerminalRetentionSweeperShape["sweep"] = () => + Effect.gen(function* () { + const boards = yield* listDefinitions(); + const retentionTargets = boards.flatMap((board) => + board.definition.lanes.flatMap((lane) => + lane.terminal !== true || lane.retention === undefined + ? [] + : [ + { + boardId: board.boardId, + laneKey: lane.key, + retentionMs: Duration.toMillis(lane.retention), + } satisfies RetentionLaneTarget, + ], + ), + ); + const orderedRetentionTargets = rotateTargets(retentionTargets); + const now = yield* nowMs; + const result = { + candidateCount: 0, + deletedCount: 0, + failedCount: 0, + } satisfies WorkflowTerminalRetentionSweepResult; + let candidateCount = result.candidateCount; + let deletedCount = result.deletedCount; + let failedCount = result.failedCount; + let remainingDeleteBudget = maxDeletesPerSweep; + let moreRemaining = false; + + const hasMoreExpiredTickets = Effect.gen(function* () { + for (const target of retentionTargets) { + const cutoffIso = DateTime.formatIso(DateTime.makeUnsafe(now - target.retentionMs)); + const tickets = yield* expiredTicketsForLane( + target.boardId, + target.laneKey, + cutoffIso, + 1, + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.terminal-retention.more-query-failed", { + boardId: target.boardId, + laneKey: target.laneKey, + cause, + }).pipe(Effect.as([] as ReadonlyArray)), + ), + ); + if (tickets.length > 0) { + return true; + } + } + return false; + }); + + targets: for (const target of orderedRetentionTargets) { + if (remainingDeleteBudget <= 0) { + break; + } + + const cutoffIso = DateTime.formatIso(DateTime.makeUnsafe(now - target.retentionMs)); + const tickets = yield* expiredTicketsForLane( + target.boardId, + target.laneKey, + cutoffIso, + remainingDeleteBudget + 1, + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.terminal-retention.ticket-query-failed", { + boardId: target.boardId, + laneKey: target.laneKey, + cause, + }).pipe(Effect.as([] as ReadonlyArray)), + ), + ); + const ticketsToProcess = tickets.slice(0, remainingDeleteBudget); + moreRemaining = moreRemaining || tickets.length > ticketsToProcess.length; + + for (const ticket of ticketsToProcess) { + candidateCount += 1; + const outcome = yield* deleteWorkflowBoardTicketOwnedStateWhen( + { + saveLocks, + engine: { cancelTicketPipelines }, + eventStore, + readModel: { deleteTicketState }, + sql, + ...(Option.isSome(worktreeJanitor) + ? { worktreeJanitor: worktreeJanitor.value } + : {}), + ...(Option.isSome(threadJanitor) ? { threadJanitor: threadJanitor.value } : {}), + }, + target.boardId, + ticket.ticketId, + isStillExpiredTerminalTicket(target.boardId, ticket.ticketId), + ).pipe( + Effect.tap((deleted) => + deleted + ? Effect.logInfo("workflow.terminal-retention.ticket-deleted", { + boardId: target.boardId, + laneKey: target.laneKey, + ticketId: ticket.ticketId, + terminalAt: ticket.terminalAt, + retentionMs: target.retentionMs, + }) + : Effect.logInfo("workflow.terminal-retention.ticket-skip-stale", { + boardId: target.boardId, + laneKey: target.laneKey, + ticketId: ticket.ticketId, + terminalAt: ticket.terminalAt, + }), + ), + Effect.map((deleted): "deleted" | "skipped" => (deleted ? "deleted" : "skipped")), + Effect.catchCause((cause) => + Effect.logWarning("workflow.terminal-retention.ticket-delete-failed", { + boardId: target.boardId, + laneKey: target.laneKey, + ticketId: ticket.ticketId, + cause, + }).pipe(Effect.as("failed" as const)), + ), + ); + + if (outcome === "deleted") { + deletedCount += 1; + } else if (outcome === "failed") { + failedCount += 1; + } + remainingDeleteBudget -= 1; + if (remainingDeleteBudget <= 0) { + nextSweepCursorKey = cursorAfter(retentionTargets, target); + break targets; + } + } + } + + if (remainingDeleteBudget <= 0 && !moreRemaining) { + moreRemaining = yield* hasMoreExpiredTickets; + } + + if (candidateCount > 0 || moreRemaining) { + yield* Effect.logInfo("workflow.terminal-retention.sweep-complete", { + candidateCount, + deletedCount, + failedCount, + maxDeletesPerSweep, + moreRemaining, + }); + } + + return { candidateCount, deletedCount, failedCount }; + }); + + const start: WorkflowTerminalRetentionSweeperShape["start"] = () => + Effect.gen(function* () { + yield* Effect.forkScoped( + sweep().pipe( + Effect.catchDefect((defect: unknown) => + Effect.logWarning("workflow.terminal-retention.sweep-defect", { + defect, + }), + ), + Effect.repeat(Schedule.spaced(Duration.millis(sweepIntervalMs))), + ), + ); + + yield* Effect.logInfo("workflow.terminal-retention.started", { + sweepIntervalMs, + }); + }); + + return { sweep, start } satisfies WorkflowTerminalRetentionSweeperShape; + }); + +export const makeWorkflowTerminalRetentionSweeperLive = ( + options?: WorkflowTerminalRetentionSweeperLiveOptions, +) => Layer.effect(WorkflowTerminalRetentionSweeper, makeWorkflowTerminalRetentionSweeper(options)); + +export const WorkflowTerminalRetentionSweeperLive = makeWorkflowTerminalRetentionSweeperLive(); diff --git a/apps/server/src/workflow/Layers/WorkflowThreadJanitor.ts b/apps/server/src/workflow/Layers/WorkflowThreadJanitor.ts new file mode 100644 index 00000000000..e2e0d8e376a --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowThreadJanitor.ts @@ -0,0 +1,67 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorkflowThreadJanitor, + type WorkflowThreadJanitorShape, +} from "../Services/WorkflowThreadJanitor.ts"; + +const toJanitorError = (cause: unknown) => + new WorkflowEventStoreError({ message: "workflow thread janitor failed", cause }); + +const wrap = (effect: Effect.Effect) => + effect.pipe(Effect.mapError(toJanitorError)); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const orchestration = yield* Effect.serviceOption(OrchestrationEngineService); + + const collectBoardThreads: WorkflowThreadJanitorShape["collectBoardThreads"] = (boardId) => + wrap(sql<{ readonly threadId: string }>` + SELECT DISTINCT thread_id AS "threadId" + FROM workflow_dispatch_outbox + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `).pipe(Effect.map((rows) => rows.map((row) => row.threadId))); + + const collectTicketThreads: WorkflowThreadJanitorShape["collectTicketThreads"] = (ticketId) => + wrap(sql<{ readonly threadId: string }>` + SELECT DISTINCT thread_id AS "threadId" + FROM workflow_dispatch_outbox + WHERE ticket_id = ${ticketId} + `).pipe(Effect.map((rows) => rows.map((row) => row.threadId))); + + const deleteThreads: WorkflowThreadJanitorShape["deleteThreads"] = (threadIds) => + Effect.gen(function* () { + if (Option.isNone(orchestration) || threadIds.length === 0) { + return; + } + for (const threadId of threadIds) { + // Best-effort per thread: a thread that never materialized (or was + // already deleted) must not abort cleanup of the rest. + yield* orchestration.value + .dispatch({ + type: "thread.delete", + commandId: `workflow-thread-delete-${threadId}` as never, + threadId: threadId as never, + }) + .pipe(Effect.catch(() => Effect.void)); + } + }); + + return { + collectBoardThreads, + collectTicketThreads, + deleteThreads, + } satisfies WorkflowThreadJanitorShape; +}); + +export const WorkflowThreadJanitorLive = Layer.effect(WorkflowThreadJanitor, make); diff --git a/apps/server/src/workflow/Layers/WorkflowWebhook.test.ts b/apps/server/src/workflow/Layers/WorkflowWebhook.test.ts new file mode 100644 index 00000000000..3d6447c5ca8 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowWebhook.test.ts @@ -0,0 +1,117 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { sanitizeExternalEventPayload } from "../externalEvent.ts"; +import { WorkflowWebhook } from "../Services/WorkflowWebhook.ts"; +import { WorkflowWebhookLive } from "./WorkflowWebhook.ts"; + +const layer = it.layer( + WorkflowWebhookLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowWebhook", (it) => { + it.effect("issues a token once, reveals it only on create/rotate, and verifies it", () => + Effect.gen(function* () { + const webhook = yield* WorkflowWebhook; + + const created = yield* webhook.getConfig("board-hook" as never, false); + assert.equal(created.hasToken, true); + assert.isString(created.token); + assert.equal(created.token?.length, 64); + assert.equal(created.tokenPrefix, created.token?.slice(0, 8)); + assert.equal(created.path, "/hooks/workflow/board-hook"); + + // Subsequent reads never reveal the secret again. + const read = yield* webhook.getConfig("board-hook" as never, false); + assert.equal(read.hasToken, true); + assert.equal(read.token, undefined); + assert.equal(read.tokenPrefix, created.tokenPrefix); + + assert.isTrue(yield* webhook.verifyToken("board-hook" as never, created.token ?? "")); + assert.isFalse(yield* webhook.verifyToken("board-hook" as never, "wrong")); + assert.isFalse(yield* webhook.verifyToken("board-unknown" as never, created.token ?? "")); + + // Rotation invalidates the old token. + const rotated = yield* webhook.getConfig("board-hook" as never, true); + assert.isString(rotated.token); + assert.notEqual(rotated.token, created.token); + assert.isFalse(yield* webhook.verifyToken("board-hook" as never, created.token ?? "")); + assert.isTrue(yield* webhook.verifyToken("board-hook" as never, rotated.token ?? "")); + }), + ); + + it.effect("dedupes deliveries per board", () => + Effect.gen(function* () { + const webhook = yield* WorkflowWebhook; + + assert.isFalse(yield* webhook.recordDelivery("board-a" as never, "delivery-1")); + assert.isTrue(yield* webhook.recordDelivery("board-a" as never, "delivery-1")); + // Different board, same delivery id: independent. + assert.isFalse(yield* webhook.recordDelivery("board-b" as never, "delivery-1")); + }), + ); + + it.effect("deleteForBoard revokes the token and forgets deliveries", () => + Effect.gen(function* () { + const webhook = yield* WorkflowWebhook; + + const created = yield* webhook.getConfig("board-gone" as never, false); + assert.isFalse(yield* webhook.recordDelivery("board-gone" as never, "delivery-1")); + + yield* webhook.deleteForBoard("board-gone" as never); + + // A recreated board with the same id must not inherit the old token. + assert.isFalse(yield* webhook.verifyToken("board-gone" as never, created.token ?? "")); + assert.isFalse(yield* webhook.recordDelivery("board-gone" as never, "delivery-1")); + }), + ); +}); + +describe("sanitizeExternalEventPayload", () => { + it("bounds depth, breadth, and string length while keeping valid JSON", () => { + const deep: Record = { level: 0 }; + let cursor = deep; + for (let depth = 1; depth < 10; depth += 1) { + const next: Record = { level: depth }; + cursor["child"] = next; + cursor = next; + } + const sanitized = sanitizeExternalEventPayload({ + deep, + long: "x".repeat(5_000), + many: Object.fromEntries(Array.from({ length: 200 }, (_, index) => [`k${index}`, index])), + list: Array.from({ length: 300 }, (_, index) => index), + fn: () => "never", + }) as Record; + + assert.equal((sanitized["long"] as string).length, 2_000); + assert.isAtMost(Object.keys(sanitized["many"] as object).length, 100); + assert.equal((sanitized["list"] as unknown[]).length, 100); + assert.isUndefined(sanitized["fn"]); + // Depth capped — walking 6 levels in ends before level 9. + let walker = sanitized["deep"] as Record | undefined; + let levels = 0; + while (walker !== undefined && typeof walker === "object" && "child" in walker) { + walker = walker["child"] as Record | undefined; + levels += 1; + } + assert.isAtMost(levels, 6); + // Round-trips as JSON. + assert.doesNotThrow(() => JSON.stringify(sanitized)); + }); + + it("drops prototype-polluting keys", () => { + const sanitized = sanitizeExternalEventPayload( + JSON.parse('{"__proto__":{"admin":true},"constructor":1,"prototype":2,"ok":3}'), + ) as Record; + assert.deepEqual(sanitized, { ok: 3 }); + assert.isUndefined((sanitized as { admin?: unknown }).admin); + assert.isUndefined(Object.getPrototypeOf(sanitized)?.admin); + }); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowWebhook.ts b/apps/server/src/workflow/Layers/WorkflowWebhook.ts new file mode 100644 index 00000000000..3673b676dae --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowWebhook.ts @@ -0,0 +1,100 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowWebhook, type WorkflowWebhookShape } from "../Services/WorkflowWebhook.ts"; + +const toWebhookError = (cause: unknown) => + new WorkflowEventStoreError({ message: "workflow webhook store failed", cause }); + +const wrap = (effect: Effect.Effect) => + effect.pipe(Effect.mapError(toWebhookError)); + +const hashToken = (token: string): string => createHash("sha256").update(token).digest("hex"); + +export const workflowWebhookPath = (boardId: string): string => + `/hooks/workflow/${encodeURIComponent(boardId)}`; + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + + const getConfig: WorkflowWebhookShape["getConfig"] = (boardId, rotate) => + Effect.gen(function* () { + const rows = yield* wrap(sql<{ readonly tokenPrefix: string }>` + SELECT token_prefix AS "tokenPrefix" + FROM workflow_board_webhook + WHERE board_id = ${boardId} + `); + const existing = rows[0]; + if (existing !== undefined && !rotate) { + return { + path: workflowWebhookPath(boardId as string), + hasToken: true, + tokenPrefix: existing.tokenPrefix, + }; + } + const token = randomBytes(32).toString("hex"); + const tokenPrefix = token.slice(0, 8); + const createdAt = yield* nowIso; + yield* wrap(sql` + INSERT INTO workflow_board_webhook (board_id, token_hash, token_prefix, created_at) + VALUES (${boardId}, ${hashToken(token)}, ${tokenPrefix}, ${createdAt}) + ON CONFLICT(board_id) DO UPDATE SET + token_hash = excluded.token_hash, + token_prefix = excluded.token_prefix, + created_at = excluded.created_at + `); + return { + path: workflowWebhookPath(boardId as string), + hasToken: true, + tokenPrefix, + token, + }; + }); + + const verifyToken: WorkflowWebhookShape["verifyToken"] = (boardId, token) => + Effect.gen(function* () { + const rows = yield* wrap(sql<{ readonly tokenHash: string }>` + SELECT token_hash AS "tokenHash" + FROM workflow_board_webhook + WHERE board_id = ${boardId} + `); + const stored = rows[0]?.tokenHash; + if (stored === undefined) { + return false; + } + const expected = Buffer.from(stored, "hex"); + const candidate = Buffer.from(hashToken(token), "hex"); + return expected.length === candidate.length && timingSafeEqual(expected, candidate); + }); + + const recordDelivery: WorkflowWebhookShape["recordDelivery"] = (boardId, deliveryId) => + Effect.gen(function* () { + const createdAt = yield* nowIso; + // RETURNING yields a row only when the insert actually happened, so a + // conflicting (duplicate) delivery is detected race-free. + const inserted = yield* wrap(sql<{ readonly deliveryId: string }>` + INSERT INTO workflow_webhook_delivery (board_id, delivery_id, created_at) + VALUES (${boardId}, ${deliveryId}, ${createdAt}) + ON CONFLICT(board_id, delivery_id) DO NOTHING + RETURNING delivery_id AS "deliveryId" + `); + return inserted.length === 0; + }); + + const deleteForBoard: WorkflowWebhookShape["deleteForBoard"] = (boardId) => + Effect.gen(function* () { + yield* wrap(sql`DELETE FROM workflow_webhook_delivery WHERE board_id = ${boardId}`); + yield* wrap(sql`DELETE FROM workflow_board_webhook WHERE board_id = ${boardId}`); + }); + + return { getConfig, verifyToken, recordDelivery, deleteForBoard } satisfies WorkflowWebhookShape; +}); + +export const WorkflowWebhookLive = Layer.effect(WorkflowWebhook, make); diff --git a/apps/server/src/workflow/Layers/WorkflowWorktreeJanitor.test.ts b/apps/server/src/workflow/Layers/WorkflowWorktreeJanitor.test.ts new file mode 100644 index 00000000000..a3cefa33c13 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowWorktreeJanitor.test.ts @@ -0,0 +1,113 @@ +import { assert, it } from "@effect/vitest"; +import type { TicketId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { MergeGitPort } from "../Services/TicketMergeService.ts"; +import { WorkflowWorktreeJanitor } from "../Services/WorkflowWorktreeJanitor.ts"; +import { ticketRefsPrefix } from "../ticketRefs.ts"; +import { WorkflowWorktreeJanitorLive } from "./WorkflowWorktreeJanitor.ts"; + +interface RecordedGitCall { + readonly cwd: string; + readonly args: ReadonlyArray; +} + +const ticketId = "ticket-gc" as TicketId; + +const gitCalls: Array = []; + +const stubGit = Layer.succeed(MergeGitPort, { + run: (input) => + Effect.sync(() => { + gitCalls.push({ cwd: input.cwd, args: input.args }); + if (input.args[0] === "worktree" && input.args[1] === "list") { + return { + exitCode: 0, + stdout: [ + "worktree /repo", + "branch refs/heads/main", + "", + "worktree /repo-worktrees/ticket-gc", + `branch refs/heads/workflow/${ticketId}`, + "", + ].join("\n"), + stderr: "", + }; + } + if (input.args[0] === "for-each-ref") { + return { + exitCode: 0, + stdout: `${ticketRefsPrefix(ticketId)}/base\n${ticketRefsPrefix(ticketId)}/step/abc/pre\n`, + stderr: "", + }; + } + return { exitCode: 0, stdout: "", stderr: "" }; + }), +}); + +const layer = it.layer( + WorkflowWorktreeJanitorLive.pipe( + Layer.provideMerge(stubGit), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowWorktreeJanitor", (it) => { + it.effect("removes the worktree, branch, refs and lease row for a ticket", () => + Effect.gen(function* () { + gitCalls.length = 0; + const janitor = yield* WorkflowWorktreeJanitor; + const sql = yield* SqlClient.SqlClient; + + yield* sql` + INSERT INTO worktree_lease ( + worktree_ref, owner_kind, owner_id, fence_token, acquired_at, expires_at + ) + VALUES ( + ${`workflow/${ticketId}`}, 'step', 'step-run-gc', 1, + '2026-06-09T00:00:00.000Z', '2026-06-09T01:00:00.000Z' + ) + `; + + yield* janitor.run({ repoRoot: "/repo", ticketIds: [ticketId] }); + + assert.ok( + gitCalls.some( + (call) => + call.args[0] === "worktree" && + call.args[1] === "remove" && + call.args.includes("/repo-worktrees/ticket-gc"), + ), + ); + assert.ok(gitCalls.some((call) => call.args[0] === "branch" && call.args[1] === "-D")); + assert.equal( + gitCalls.filter((call) => call.args[0] === "update-ref" && call.args[1] === "-d").length, + 2, + ); + + const leases = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM worktree_lease + WHERE worktree_ref = ${`workflow/${ticketId}`} + `; + assert.equal(leases[0]?.count, 0); + }), + ); + + it.effect("collects board plans before deletion and tolerates missing rows", () => + Effect.gen(function* () { + const janitor = yield* WorkflowWorktreeJanitor; + const missing = yield* janitor.collectBoardPlan("board-missing" as never); + assert.equal(missing, null); + + const missingTicket = yield* janitor.collectTicketPlan("ticket-missing" as never); + assert.equal(missingTicket, null); + + yield* janitor.run(null); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowWorktreeJanitor.ts b/apps/server/src/workflow/Layers/WorkflowWorktreeJanitor.ts new file mode 100644 index 00000000000..db6c2f28239 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowWorktreeJanitor.ts @@ -0,0 +1,183 @@ +import type { TicketId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MergeGitPort } from "../Services/TicketMergeService.ts"; +import { + WorkflowWorktreeJanitor, + type WorkflowWorktreeJanitorShape, + type WorktreeCleanupPlan, +} from "../Services/WorkflowWorktreeJanitor.ts"; +import { ticketRefsPrefix } from "../ticketRefs.ts"; + +interface RepoRootRow { + readonly repoRoot: string; +} + +interface TicketIdRow { + readonly ticketId: TicketId; +} + +const ticketWorktreeRef = (ticketId: TicketId) => `workflow/${ticketId}`; + +// Parses `git worktree list --porcelain` into branch-ref → worktree-path. +const worktreePathsByBranch = (porcelain: string): Map => { + const out = new Map(); + let currentPath: string | null = null; + for (const line of porcelain.split("\n")) { + if (line.startsWith("worktree ")) { + currentPath = line.slice("worktree ".length).trim(); + } else if (line.startsWith("branch ") && currentPath !== null) { + out.set(line.slice("branch ".length).trim(), currentPath); + } else if (line.trim().length === 0) { + currentPath = null; + } + } + return out; +}; + +const make = Effect.gen(function* () { + const git = yield* MergeGitPort; + const sql = yield* SqlClient.SqlClient; + + const bestEffort = (label: string, effect: Effect.Effect) => + effect.pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow worktree cleanup step failed", { label, cause }), + ), + Effect.asVoid, + ); + + const collectBoardPlan: WorkflowWorktreeJanitorShape["collectBoardPlan"] = (boardId) => + Effect.gen(function* () { + const roots = yield* sql` + SELECT projects.workspace_root AS "repoRoot" + FROM projection_board AS board + INNER JOIN projection_projects AS projects + ON projects.project_id = board.project_id + WHERE board.board_id = ${boardId} + LIMIT 1 + `; + const repoRoot = roots[0]?.repoRoot; + if (repoRoot === undefined) { + return null; + } + const tickets = yield* sql` + SELECT ticket_id AS "ticketId" + FROM projection_ticket + WHERE board_id = ${boardId} + `; + if (tickets.length === 0) { + return null; + } + return { repoRoot, ticketIds: tickets.map((row) => row.ticketId) }; + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow worktree cleanup board plan failed", { boardId, cause }).pipe( + Effect.as(null), + ), + ), + ); + + const collectTicketPlan: WorkflowWorktreeJanitorShape["collectTicketPlan"] = (ticketId) => + Effect.gen(function* () { + const roots = yield* sql` + SELECT projects.workspace_root AS "repoRoot" + FROM projection_ticket AS ticket + INNER JOIN projection_board AS board + ON board.board_id = ticket.board_id + INNER JOIN projection_projects AS projects + ON projects.project_id = board.project_id + WHERE ticket.ticket_id = ${ticketId} + LIMIT 1 + `; + const repoRoot = roots[0]?.repoRoot; + if (repoRoot === undefined) { + return null; + } + return { repoRoot, ticketIds: [ticketId] }; + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow worktree cleanup ticket plan failed", { ticketId, cause }).pipe( + Effect.as(null), + ), + ), + ); + + const cleanupTicket = (plan: WorktreeCleanupPlan, ticketId: TicketId) => + Effect.gen(function* () { + const worktreeRef = ticketWorktreeRef(ticketId); + + yield* bestEffort( + "remove worktree", + Effect.gen(function* () { + const list = yield* git.run({ + cwd: plan.repoRoot, + args: ["worktree", "list", "--porcelain"], + }); + const path = worktreePathsByBranch(list.stdout).get(`refs/heads/${worktreeRef}`); + if (path !== undefined) { + yield* git.run({ + cwd: plan.repoRoot, + args: ["worktree", "remove", "--force", path], + allowNonZeroExit: true, + }); + } + yield* git.run({ + cwd: plan.repoRoot, + args: ["worktree", "prune"], + allowNonZeroExit: true, + }); + }), + ); + + yield* bestEffort( + "delete ticket branch", + git.run({ + cwd: plan.repoRoot, + args: ["branch", "-D", worktreeRef], + allowNonZeroExit: true, + }), + ); + + yield* bestEffort( + "delete ticket checkpoint refs", + Effect.gen(function* () { + const refs = yield* git.run({ + cwd: plan.repoRoot, + args: ["for-each-ref", "--format=%(refname)", `${ticketRefsPrefix(ticketId)}/`], + }); + for (const ref of refs.stdout.split("\n")) { + const trimmed = ref.trim(); + if (trimmed.length > 0) { + yield* git.run({ + cwd: plan.repoRoot, + args: ["update-ref", "-d", trimmed], + allowNonZeroExit: true, + }); + } + } + }), + ); + + yield* bestEffort( + "delete worktree lease row", + sql` + DELETE FROM worktree_lease + WHERE worktree_ref = ${worktreeRef} + `, + ); + }); + + const run: WorkflowWorktreeJanitorShape["run"] = (plan) => + plan === null + ? Effect.void + : Effect.forEach(plan.ticketIds, (ticketId) => cleanupTicket(plan, ticketId), { + discard: true, + }); + + return { collectBoardPlan, collectTicketPlan, run } satisfies WorkflowWorktreeJanitorShape; +}); + +export const WorkflowWorktreeJanitorLive = Layer.effect(WorkflowWorktreeJanitor, make); diff --git a/apps/server/src/workflow/Layers/WorktreeLeaseService.migration.test.ts b/apps/server/src/workflow/Layers/WorktreeLeaseService.migration.test.ts new file mode 100644 index 00000000000..ef087921a84 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorktreeLeaseService.migration.test.ts @@ -0,0 +1,23 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; + +const layer = it.layer(MigrationsLive.pipe(Layer.provideMerge(SqlitePersistenceMemory))); + +layer("M3 migrations", (it) => { + it.effect("creates lease, dispatch outbox, and setup run tables", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master + WHERE type = 'table' + AND name IN ('worktree_lease', 'workflow_dispatch_outbox', 'workflow_setup_run') + `; + assert.equal(rows.length, 3); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorktreeLeaseService.test.ts b/apps/server/src/workflow/Layers/WorktreeLeaseService.test.ts new file mode 100644 index 00000000000..23454183f87 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorktreeLeaseService.test.ts @@ -0,0 +1,40 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { WorktreeLeaseService } from "../Services/WorktreeLeaseService.ts"; +import { WorktreeLeaseServiceLive } from "./WorktreeLeaseService.ts"; + +const layer = it.layer( + WorktreeLeaseServiceLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorktreeLeaseService", (it) => { + it.effect("acquire returns a monotonically increasing fence token", () => + Effect.gen(function* () { + const lease = yield* WorktreeLeaseService; + const a = yield* lease.acquire("wt-1", "step", "sr-1"); + yield* lease.release("wt-1", a.fenceToken); + const b = yield* lease.acquire("wt-1", "step", "sr-2"); + + assert.isAbove(b.fenceToken, a.fenceToken); + }), + ); + + it.effect("validate rejects a stale token", () => + Effect.gen(function* () { + const lease = yield* WorktreeLeaseService; + const a = yield* lease.acquire("wt-2", "step", "sr-1"); + yield* lease.release("wt-2", a.fenceToken); + yield* lease.acquire("wt-2", "step", "sr-2"); + const valid = yield* lease.isValid("wt-2", a.fenceToken); + + assert.equal(valid, false); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorktreeLeaseService.ts b/apps/server/src/workflow/Layers/WorktreeLeaseService.ts new file mode 100644 index 00000000000..a8da426c7d4 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorktreeLeaseService.ts @@ -0,0 +1,96 @@ +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorktreeLeaseService, + type Lease, + type WorktreeLeaseServiceShape, +} from "../Services/WorktreeLeaseService.ts"; + +const leaseExpiresAt = (now: DateTime.Utc) => DateTime.add(now, { minutes: 30 }); + +const toLeaseError = (cause: unknown) => + new WorkflowEventStoreError({ message: "lease op failed", cause }); + +const wrap = (effect: Effect.Effect) => effect.pipe(Effect.mapError(toLeaseError)); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const acquire: WorktreeLeaseServiceShape["acquire"] = (worktreeRef, ownerKind, ownerId) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const acquiredAt = DateTime.formatIso(now); + const expiresAt = DateTime.formatIso(leaseExpiresAt(now)); + const rows = yield* wrap(sql` + INSERT INTO worktree_lease ( + worktree_ref, + owner_kind, + owner_id, + fence_token, + acquired_at, + expires_at + ) + VALUES ( + ${worktreeRef}, + ${ownerKind}, + ${ownerId}, + COALESCE( + (SELECT fence_token FROM worktree_lease WHERE worktree_ref = ${worktreeRef}), + 0 + ) + 1, + ${acquiredAt}, + ${expiresAt} + ) + ON CONFLICT(worktree_ref) DO UPDATE SET + owner_kind = excluded.owner_kind, + owner_id = excluded.owner_id, + fence_token = worktree_lease.fence_token + 1, + acquired_at = excluded.acquired_at, + expires_at = excluded.expires_at + RETURNING fence_token AS "fenceToken" + `); + const lease = rows[0]; + if (!lease) { + return yield* new WorkflowEventStoreError({ message: "lease acquire returned no row" }); + } + return lease; + }); + + const release: WorktreeLeaseServiceShape["release"] = (worktreeRef, fenceToken) => + Effect.gen(function* () { + const now = DateTime.formatIso(yield* DateTime.now); + yield* wrap(sql` + UPDATE worktree_lease + SET owner_kind = 'released', + owner_id = '', + fence_token = fence_token + 1, + acquired_at = ${now}, + expires_at = ${now} + WHERE worktree_ref = ${worktreeRef} + AND fence_token = ${fenceToken} + `); + }).pipe(Effect.asVoid); + + const isValid: WorktreeLeaseServiceShape["isValid"] = (worktreeRef, fenceToken) => + wrap(sql<{ readonly fenceToken: number; readonly ownerKind: string }>` + SELECT + fence_token AS "fenceToken", + owner_kind AS "ownerKind" + FROM worktree_lease + WHERE worktree_ref = ${worktreeRef} + `).pipe( + Effect.map((rows) => { + const row = rows[0]; + return row?.fenceToken === fenceToken && row.ownerKind !== "released"; + }), + ); + + return { acquire, release, isValid } satisfies WorktreeLeaseServiceShape; +}); + +export const WorktreeLeaseServiceLive = Layer.effect(WorktreeLeaseService, make); diff --git a/apps/server/src/workflow/Services/ApprovalGate.ts b/apps/server/src/workflow/Services/ApprovalGate.ts new file mode 100644 index 00000000000..aee2c5153c3 --- /dev/null +++ b/apps/server/src/workflow/Services/ApprovalGate.ts @@ -0,0 +1,13 @@ +import type { StepRunId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface ApprovalGateShape { + readonly park: (stepRunId: StepRunId) => Effect.Effect; + readonly await: (stepRunId: StepRunId) => Effect.Effect; + readonly resolve: (stepRunId: StepRunId, approved: boolean) => Effect.Effect; +} + +export class ApprovalGate extends Context.Service()( + "t3/workflow/Services/ApprovalGate", +) {} diff --git a/apps/server/src/workflow/Services/BoardDiscovery.ts b/apps/server/src/workflow/Services/BoardDiscovery.ts new file mode 100644 index 00000000000..b95fa4469d8 --- /dev/null +++ b/apps/server/src/workflow/Services/BoardDiscovery.ts @@ -0,0 +1,17 @@ +import type { BoardListEntry, ProjectId } from "@t3tools/contracts"; +import { WorkflowRpcError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface BoardDiscoveryShape { + readonly discover: ( + projectId: ProjectId, + ) => Effect.Effect, WorkflowRpcError>; + readonly list: ( + projectId: ProjectId, + ) => Effect.Effect, WorkflowRpcError>; +} + +export class BoardDiscovery extends Context.Service()( + "t3/workflow/Services/BoardDiscovery", +) {} diff --git a/apps/server/src/workflow/Services/BoardRegistry.ts b/apps/server/src/workflow/Services/BoardRegistry.ts new file mode 100644 index 00000000000..aa4600990a2 --- /dev/null +++ b/apps/server/src/workflow/Services/BoardRegistry.ts @@ -0,0 +1,29 @@ +import type { BoardId, LaneKey, WorkflowDefinition, WorkflowLane } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export class BoardRegistryError extends Schema.TaggedErrorClass()( + "BoardRegistryError", + { message: Schema.String }, +) {} + +export interface BoardRegistryShape { + readonly register: ( + boardId: BoardId, + definition: unknown, + ) => Effect.Effect; + readonly unregister: (boardId: BoardId) => Effect.Effect; + readonly getDefinition: (boardId: BoardId) => Effect.Effect; + readonly listDefinitions: () => Effect.Effect< + ReadonlyArray<{ + readonly boardId: BoardId; + readonly definition: WorkflowDefinition; + }> + >; + readonly getLane: (boardId: BoardId, laneKey: LaneKey) => Effect.Effect; +} + +export class BoardRegistry extends Context.Service()( + "t3/workflow/Services/BoardRegistry", +) {} diff --git a/apps/server/src/workflow/Services/CapturedStepOutputReader.ts b/apps/server/src/workflow/Services/CapturedStepOutputReader.ts new file mode 100644 index 00000000000..40d5e525611 --- /dev/null +++ b/apps/server/src/workflow/Services/CapturedStepOutputReader.ts @@ -0,0 +1,22 @@ +import type { StepRunId, ThreadId, TurnId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface CapturedStepOutputReadInput { + readonly stepRunId: StepRunId; + readonly threadId: ThreadId; + readonly turnId: TurnId; +} + +export interface CapturedStepOutputReaderShape { + readonly read: ( + input: CapturedStepOutputReadInput, + ) => Effect.Effect; +} + +export class CapturedStepOutputReader extends Context.Service< + CapturedStepOutputReader, + CapturedStepOutputReaderShape +>()("t3/workflow/Services/CapturedStepOutputReader") {} diff --git a/apps/server/src/workflow/Services/DurableApprovalResume.ts b/apps/server/src/workflow/Services/DurableApprovalResume.ts new file mode 100644 index 00000000000..1b225f63997 --- /dev/null +++ b/apps/server/src/workflow/Services/DurableApprovalResume.ts @@ -0,0 +1,13 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface DurableApprovalResumeShape { + readonly resume: () => Effect.Effect; +} + +export class DurableApprovalResume extends Context.Service< + DurableApprovalResume, + DurableApprovalResumeShape +>()("t3/workflow/Services/DurableApprovalResume") {} diff --git a/apps/server/src/workflow/Services/Errors.ts b/apps/server/src/workflow/Services/Errors.ts new file mode 100644 index 00000000000..a659b1ddcd7 --- /dev/null +++ b/apps/server/src/workflow/Services/Errors.ts @@ -0,0 +1,9 @@ +import * as Schema from "effect/Schema"; + +export class WorkflowEventStoreError extends Schema.TaggedErrorClass()( + "WorkflowEventStoreError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} diff --git a/apps/server/src/workflow/Services/PredicateEvaluator.ts b/apps/server/src/workflow/Services/PredicateEvaluator.ts new file mode 100644 index 00000000000..aea1df78c4f --- /dev/null +++ b/apps/server/src/workflow/Services/PredicateEvaluator.ts @@ -0,0 +1,28 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export interface PredicateEvaluation { + readonly result: boolean; + readonly matchedPaths: ReadonlyArray; +} + +export class PredicateEvaluationError extends Schema.TaggedErrorClass()( + "PredicateEvaluationError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export interface PredicateEvaluatorShape { + readonly evaluate: ( + rule: unknown, + context: unknown, + ) => Effect.Effect; +} + +export class PredicateEvaluator extends Context.Service< + PredicateEvaluator, + PredicateEvaluatorShape +>()("t3/workflow/Services/PredicateEvaluator") {} diff --git a/apps/server/src/workflow/Services/ProjectScriptTrust.ts b/apps/server/src/workflow/Services/ProjectScriptTrust.ts new file mode 100644 index 00000000000..c40e5d1680d --- /dev/null +++ b/apps/server/src/workflow/Services/ProjectScriptTrust.ts @@ -0,0 +1,24 @@ +import type { ProjectId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface ProjectScriptTrustShape { + readonly isTrusted: (projectId: ProjectId) => Effect.Effect; + readonly setTrusted: ( + projectId: ProjectId, + trusted: boolean, + ) => Effect.Effect; +} + +export class ProjectScriptTrust extends Context.Service< + ProjectScriptTrust, + ProjectScriptTrustShape +>()("t3/workflow/Services/ProjectScriptTrust") {} + +export const ProjectScriptTrustDenyAll = Layer.succeed(ProjectScriptTrust, { + isTrusted: () => Effect.succeed(false), + setTrusted: () => Effect.void, +} satisfies ProjectScriptTrustShape); diff --git a/apps/server/src/workflow/Services/ProjectWorkspaceResolver.ts b/apps/server/src/workflow/Services/ProjectWorkspaceResolver.ts new file mode 100644 index 00000000000..7aec6ff1d00 --- /dev/null +++ b/apps/server/src/workflow/Services/ProjectWorkspaceResolver.ts @@ -0,0 +1,21 @@ +import type { ProjectId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export class ProjectWorkspaceResolverError extends Schema.TaggedErrorClass()( + "ProjectWorkspaceResolverError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export interface ProjectWorkspaceResolverShape { + readonly resolve: (projectId: ProjectId) => Effect.Effect; +} + +export class ProjectWorkspaceResolver extends Context.Service< + ProjectWorkspaceResolver, + ProjectWorkspaceResolverShape +>()("t3/workflow/Services/ProjectWorkspaceResolver") {} diff --git a/apps/server/src/workflow/Services/ProviderDispatchOutbox.ts b/apps/server/src/workflow/Services/ProviderDispatchOutbox.ts new file mode 100644 index 00000000000..89af494a811 --- /dev/null +++ b/apps/server/src/workflow/Services/ProviderDispatchOutbox.ts @@ -0,0 +1,84 @@ +import type { + ApprovalRequestId, + DispatchId, + ProviderOptionSelections, + StepRunId, + ThreadId, + TicketId, + TurnId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface DispatchRequest { + readonly dispatchId: DispatchId; + readonly ticketId: TicketId; + readonly stepRunId: StepRunId; + readonly threadId: ThreadId; + readonly providerInstance: string; + readonly model: string; + readonly instruction: string; + readonly worktreePath: string; + readonly options?: ProviderOptionSelections; + // Project + title for the hidden thread shell that lets provider runtime + // ingestion project this thread's turns/messages/activities. Without a + // shell, ingestion drops the events and the turn never reaches a terminal + // state from the workflow's perspective. + readonly projectId?: string; + readonly threadTitle?: string; + // Defaults to "full-access" (worktree-isolated steps); intake runs at the + // real project root and passes a stricter mode. + readonly runtimeMode?: "approval-required" | "auto-accept-edits" | "full-access"; +} + +export interface ProviderTurnPortShape { + readonly ensureTurnStarted: ( + req: DispatchRequest, + ) => Effect.Effect<{ readonly turnId: TurnId }, WorkflowEventStoreError>; +} + +export class ProviderTurnPort extends Context.Service()( + "t3/workflow/Services/ProviderDispatchOutbox/ProviderTurnPort", +) {} + +export type ProviderDispatchTerminalResult = + | { readonly ok: true } + | { readonly ok: false; readonly error?: string } + | { + readonly ok: false; + readonly awaitingUser: true; + readonly waitingReason: string; + readonly providerThreadId: ThreadId; + readonly providerRequestId: ApprovalRequestId; + readonly providerResponseKind: "request" | "user-input"; + readonly providerQuestionId?: string; + }; + +export interface ProviderDispatchOutboxShape { + readonly confirmStep: (stepRunId: StepRunId) => Effect.Effect; + readonly ensureStarted: ( + req: DispatchRequest, + ) => Effect.Effect<{ readonly turnId: TurnId }, WorkflowEventStoreError>; + readonly getDispatchForStep: ( + stepRunId: StepRunId, + ) => Effect.Effect< + { readonly threadId: ThreadId; readonly turnId: TurnId } | null, + WorkflowEventStoreError + >; + readonly awaitTerminal: ( + dispatchId: DispatchId, + threadId: ThreadId, + ) => Effect.Effect; + readonly awaitStepTerminal: ( + stepRunId: StepRunId, + threadId: ThreadId, + ) => Effect.Effect; + readonly recoverPending: () => Effect.Effect; +} + +export class ProviderDispatchOutbox extends Context.Service< + ProviderDispatchOutbox, + ProviderDispatchOutboxShape +>()("t3/workflow/Services/ProviderDispatchOutbox") {} diff --git a/apps/server/src/workflow/Services/ProviderResponsePort.ts b/apps/server/src/workflow/Services/ProviderResponsePort.ts new file mode 100644 index 00000000000..2fa15772b87 --- /dev/null +++ b/apps/server/src/workflow/Services/ProviderResponsePort.ts @@ -0,0 +1,25 @@ +import type { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export type ProviderResponseKind = "request" | "user-input"; + +export interface ProviderResponseInput { + readonly threadId: ThreadId; + readonly requestId: ApprovalRequestId; + readonly responseKind: ProviderResponseKind; + readonly approved: boolean; + readonly questionId?: string; + readonly text?: string; +} + +export interface ProviderResponsePortShape { + readonly respond: (input: ProviderResponseInput) => Effect.Effect; +} + +export class ProviderResponsePort extends Context.Service< + ProviderResponsePort, + ProviderResponsePortShape +>()("t3/workflow/Services/ProviderResponsePort") {} diff --git a/apps/server/src/workflow/Services/ScriptCancelRegistry.ts b/apps/server/src/workflow/Services/ScriptCancelRegistry.ts new file mode 100644 index 00000000000..9479ff16dd5 --- /dev/null +++ b/apps/server/src/workflow/Services/ScriptCancelRegistry.ts @@ -0,0 +1,21 @@ +import type { StepRunId, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface ScriptCancelHandle { + readonly scriptThreadId: ThreadId; + readonly terminalId: string; +} + +export interface ScriptCancelRegistryShape { + readonly register: (stepRunId: StepRunId, handle: ScriptCancelHandle) => Effect.Effect; + readonly unregister: (stepRunId: StepRunId) => Effect.Effect; + readonly cancel: (stepRunId: StepRunId) => Effect.Effect; +} + +export class ScriptCancelRegistry extends Context.Service< + ScriptCancelRegistry, + ScriptCancelRegistryShape +>()("t3/workflow/Services/ScriptCancelRegistry") {} diff --git a/apps/server/src/workflow/Services/ScriptCommandRunner.ts b/apps/server/src/workflow/Services/ScriptCommandRunner.ts new file mode 100644 index 00000000000..a24b5bd95b6 --- /dev/null +++ b/apps/server/src/workflow/Services/ScriptCommandRunner.ts @@ -0,0 +1,33 @@ +import type { ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Duration from "effect/Duration"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export type ScriptCommandOutcome = "exited" | "timeout" | "cancelled"; + +export interface ScriptCommandRunInput { + readonly scriptThreadId: ThreadId; + readonly terminalId: string; + readonly cwd: string; + readonly run: string; + readonly timeout: Duration.Input; +} + +export interface ScriptCommandResult { + readonly exitCode: number | null; + readonly signal: number | null; + readonly outcome: ScriptCommandOutcome; +} + +export interface ScriptCommandRunnerShape { + readonly run: ( + input: ScriptCommandRunInput, + ) => Effect.Effect; +} + +export class ScriptCommandRunner extends Context.Service< + ScriptCommandRunner, + ScriptCommandRunnerShape +>()("t3/workflow/Services/ScriptCommandRunner") {} diff --git a/apps/server/src/workflow/Services/ScriptStepExecutor.ts b/apps/server/src/workflow/Services/ScriptStepExecutor.ts new file mode 100644 index 00000000000..37dab5d4cc4 --- /dev/null +++ b/apps/server/src/workflow/Services/ScriptStepExecutor.ts @@ -0,0 +1,26 @@ +import type { StepOutcome } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; +import type { StepExecutionContext } from "./StepExecutor.ts"; +import type { WorktreeHandle } from "./WorktreePort.ts"; + +export type ScriptStep = Extract; + +export interface ScriptStepExecutionInput { + readonly ctx: StepExecutionContext; + readonly step: ScriptStep; + readonly worktree: WorktreeHandle; +} + +export interface ScriptStepExecutorShape { + readonly execute: ( + input: ScriptStepExecutionInput, + ) => Effect.Effect; +} + +export class ScriptStepExecutor extends Context.Service< + ScriptStepExecutor, + ScriptStepExecutorShape +>()("t3/workflow/Services/ScriptStepExecutor") {} diff --git a/apps/server/src/workflow/Services/SetupRunService.ts b/apps/server/src/workflow/Services/SetupRunService.ts new file mode 100644 index 00000000000..17f58a57ed7 --- /dev/null +++ b/apps/server/src/workflow/Services/SetupRunService.ts @@ -0,0 +1,44 @@ +import type { SetupRunId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface SetupTerminalPortShape { + readonly launch: (input: { + readonly threadId?: string; + readonly projectId?: string; + readonly projectCwd?: string; + readonly worktreePath: string; + readonly preferredTerminalId?: string; + }) => Effect.Effect<{ readonly terminalId: string | null }, WorkflowEventStoreError>; + readonly awaitExit: (input: { + readonly terminalId: string | null; + readonly timeoutMs?: number; + }) => Effect.Effect<{ readonly exitCode: number }, WorkflowEventStoreError>; +} + +export class SetupTerminalPort extends Context.Service()( + "t3/workflow/Services/SetupRunService/SetupTerminalPort", +) {} + +export type SetupStatus = "completed" | "failed" | "timed_out"; + +export interface SetupRunServiceShape { + readonly runSetup: ( + ticketId: TicketId, + worktreeRef: string, + worktreePath: string, + setupRunId: SetupRunId, + // Required by the setup runner to resolve the project — a worktree path + // alone cannot, and workspace-root matching breaks under canonicalization. + projectId?: string, + ) => Effect.Effect< + { readonly status: SetupStatus; readonly exitCode: number | null }, + WorkflowEventStoreError + >; +} + +export class SetupRunService extends Context.Service()( + "t3/workflow/Services/SetupRunService", +) {} diff --git a/apps/server/src/workflow/Services/StepExecutor.ts b/apps/server/src/workflow/Services/StepExecutor.ts new file mode 100644 index 00000000000..558c117af90 --- /dev/null +++ b/apps/server/src/workflow/Services/StepExecutor.ts @@ -0,0 +1,28 @@ +import type { + BoardId, + LaneEntryToken, + PipelineRunId, + StepOutcome, + StepRunId, + TicketId, + WorkflowStep, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface StepExecutionContext { + readonly ticketId: TicketId; + readonly boardId: BoardId; + readonly pipelineRunId: PipelineRunId; + readonly stepRunId: StepRunId; + readonly laneEntryToken: LaneEntryToken; + readonly step: WorkflowStep; +} + +export interface StepExecutorShape { + readonly execute: (ctx: StepExecutionContext) => Effect.Effect; +} + +export class StepExecutor extends Context.Service()( + "t3/workflow/Services/StepExecutor", +) {} diff --git a/apps/server/src/workflow/Services/StepUsageReader.ts b/apps/server/src/workflow/Services/StepUsageReader.ts new file mode 100644 index 00000000000..ba097938701 --- /dev/null +++ b/apps/server/src/workflow/Services/StepUsageReader.ts @@ -0,0 +1,16 @@ +import type { ThreadId, WorkflowStepUsage } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface StepUsageReaderShape { + /** + * Latest token-usage snapshot for a workflow dispatch thread, mapped to the + * workflow usage shape. Undefined when the provider emitted no usage. + * Never fails — usage is best-effort telemetry. + */ + readonly read: (threadId: ThreadId) => Effect.Effect; +} + +export class StepUsageReader extends Context.Service()( + "t3/workflow/Services/StepUsageReader", +) {} diff --git a/apps/server/src/workflow/Services/TicketCheckpointService.ts b/apps/server/src/workflow/Services/TicketCheckpointService.ts new file mode 100644 index 00000000000..9c2d2948572 --- /dev/null +++ b/apps/server/src/workflow/Services/TicketCheckpointService.ts @@ -0,0 +1,27 @@ +import type { StepRunId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface TicketCheckpointServiceShape { + readonly captureBaseline: ( + ticketId: TicketId, + cwd: string, + ) => Effect.Effect; + readonly hasBaseline: ( + ticketId: TicketId, + cwd: string, + ) => Effect.Effect; + readonly captureStep: ( + ticketId: TicketId, + stepRunId: StepRunId, + cwd: string, + kind: "pre" | "post", + ) => Effect.Effect; +} + +export class TicketCheckpointService extends Context.Service< + TicketCheckpointService, + TicketCheckpointServiceShape +>()("t3/workflow/Services/TicketCheckpointService") {} diff --git a/apps/server/src/workflow/Services/TicketDiffQuery.ts b/apps/server/src/workflow/Services/TicketDiffQuery.ts new file mode 100644 index 00000000000..854a7810618 --- /dev/null +++ b/apps/server/src/workflow/Services/TicketDiffQuery.ts @@ -0,0 +1,31 @@ +import type { TicketDiff, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorktreeDiffPortShape { + readonly diffRefToWorktree: (input: { + readonly cwd: string; + readonly baseRef: string; + }) => Effect.Effect< + { readonly patch: string; readonly truncated: boolean }, + WorkflowEventStoreError + >; +} + +export class WorktreeDiffPort extends Context.Service()( + "t3/workflow/Services/TicketDiffQuery/WorktreeDiffPort", +) {} + +export interface TicketDiffQueryShape { + readonly getTicketDiff: ( + ticketId: TicketId, + cwd: string, + baseRef: string, + ) => Effect.Effect; +} + +export class TicketDiffQuery extends Context.Service()( + "t3/workflow/Services/TicketDiffQuery", +) {} diff --git a/apps/server/src/workflow/Services/TicketMergeService.ts b/apps/server/src/workflow/Services/TicketMergeService.ts new file mode 100644 index 00000000000..758b1850865 --- /dev/null +++ b/apps/server/src/workflow/Services/TicketMergeService.ts @@ -0,0 +1,40 @@ +import type { MergeStep, StepOutcome, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface MergeGitResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +export interface MergeGitPortShape { + readonly run: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly allowNonZeroExit?: boolean; + }) => Effect.Effect; +} + +export class MergeGitPort extends Context.Service()( + "t3/workflow/Services/TicketMergeService/MergeGitPort", +) {} + +export interface TicketMergeInput { + readonly ticketId: TicketId; + readonly repoRoot: string; + readonly worktreePath: string; + readonly worktreeRef: string; + readonly step: MergeStep; +} + +export interface TicketMergeServiceShape { + readonly merge: (input: TicketMergeInput) => Effect.Effect; +} + +export class TicketMergeService extends Context.Service< + TicketMergeService, + TicketMergeServiceShape +>()("t3/workflow/Services/TicketMergeService") {} diff --git a/apps/server/src/workflow/Services/TurnStateReader.ts b/apps/server/src/workflow/Services/TurnStateReader.ts new file mode 100644 index 00000000000..57043051da2 --- /dev/null +++ b/apps/server/src/workflow/Services/TurnStateReader.ts @@ -0,0 +1,35 @@ +import type { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export type TurnState = + | { readonly _tag: "running" } + | { readonly _tag: "completed" } + | { + readonly _tag: "awaiting_user"; + readonly waitingReason: string; + readonly providerThreadId: ThreadId; + readonly providerRequestId: ApprovalRequestId; + readonly providerResponseKind: "request" | "user-input"; + readonly providerQuestionId?: string; + } + | { readonly _tag: "failed"; readonly error: string }; + +export interface TurnProjectionPortShape { + readonly getLatestTurnState: ( + threadId: ThreadId, + ) => Effect.Effect<{ readonly state: string; readonly completed: boolean }>; +} + +export class TurnProjectionPort extends Context.Service< + TurnProjectionPort, + TurnProjectionPortShape +>()("t3/workflow/Services/TurnStateReader/TurnProjectionPort") {} + +export interface TurnStateReaderShape { + readonly read: (threadId: ThreadId) => Effect.Effect; +} + +export class TurnStateReader extends Context.Service()( + "t3/workflow/Services/TurnStateReader", +) {} diff --git a/apps/server/src/workflow/Services/WorkflowBoardEvents.ts b/apps/server/src/workflow/Services/WorkflowBoardEvents.ts new file mode 100644 index 00000000000..136f6e54fe8 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowBoardEvents.ts @@ -0,0 +1,14 @@ +import type { BoardId, BoardTicketView } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; + +export interface WorkflowBoardEventsShape { + readonly publish: (ticket: BoardTicketView) => Effect.Effect; + readonly stream: (boardId: BoardId) => Stream.Stream; +} + +export class WorkflowBoardEvents extends Context.Service< + WorkflowBoardEvents, + WorkflowBoardEventsShape +>()("t3/workflow/Services/WorkflowBoardEvents") {} diff --git a/apps/server/src/workflow/Services/WorkflowBoardSaveLocks.ts b/apps/server/src/workflow/Services/WorkflowBoardSaveLocks.ts new file mode 100644 index 00000000000..f42503ef942 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowBoardSaveLocks.ts @@ -0,0 +1,15 @@ +import type { BoardId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface WorkflowBoardSaveLocksShape { + readonly withSaveLock: ( + boardId: BoardId, + effect: Effect.Effect, + ) => Effect.Effect; +} + +export class WorkflowBoardSaveLocks extends Context.Service< + WorkflowBoardSaveLocks, + WorkflowBoardSaveLocksShape +>()("t3/workflow/Services/WorkflowBoardSaveLocks") {} diff --git a/apps/server/src/workflow/Services/WorkflowBoardVersionStore.ts b/apps/server/src/workflow/Services/WorkflowBoardVersionStore.ts new file mode 100644 index 00000000000..aa0c6279094 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowBoardVersionStore.ts @@ -0,0 +1,44 @@ +import type { BoardId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export type WorkflowBoardVersionSource = "create" | "save" | "revert" | "import" | "rename"; + +export interface WorkflowBoardVersionRecordInput { + readonly boardId: BoardId; + readonly versionHash: string; + readonly contentJson: string; + readonly source: WorkflowBoardVersionSource; +} + +export interface WorkflowBoardVersionSummaryRow { + readonly versionId: number; + readonly versionHash: string; + readonly source: WorkflowBoardVersionSource; + readonly createdAt: string; +} + +export interface WorkflowBoardVersionRow extends WorkflowBoardVersionSummaryRow { + readonly contentJson: string; +} + +export interface WorkflowBoardVersionStoreShape { + readonly record: ( + input: WorkflowBoardVersionRecordInput, + ) => Effect.Effect; + readonly list: ( + boardId: BoardId, + ) => Effect.Effect, WorkflowEventStoreError>; + readonly get: ( + boardId: BoardId, + versionId: number, + ) => Effect.Effect; + readonly deleteForBoard: (boardId: BoardId) => Effect.Effect; +} + +export class WorkflowBoardVersionStore extends Context.Service< + WorkflowBoardVersionStore, + WorkflowBoardVersionStoreShape +>()("t3/workflow/Services/WorkflowBoardVersionStore") {} diff --git a/apps/server/src/workflow/Services/WorkflowEngine.ts b/apps/server/src/workflow/Services/WorkflowEngine.ts new file mode 100644 index 00000000000..ef01f936e3c --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowEngine.ts @@ -0,0 +1,87 @@ +import type { + BoardId, + LaneKey, + StepRunId, + ThreadId, + TicketAttachment, + TicketId, + TurnId, + WorkflowStepUsage, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export type RecoveredStepResult = + | { readonly _tag: "completed"; readonly output?: unknown; readonly usage?: WorkflowStepUsage } + | { + readonly _tag: "failed"; + readonly error: string; + readonly retryable?: boolean; + readonly usage?: WorkflowStepUsage; + } + | { readonly _tag: "blocked"; readonly reason: string }; + +export interface WorkflowEngineShape { + readonly createTicket: (input: { + readonly boardId: BoardId; + readonly title: string; + readonly description?: string; + readonly initialLane: LaneKey; + readonly dependsOn?: ReadonlyArray; + readonly tokenBudget?: number; + }) => Effect.Effect; + readonly editTicket: (input: { + readonly ticketId: TicketId; + readonly title?: string | undefined; + readonly description?: string | undefined; + readonly dependsOn?: ReadonlyArray | undefined; + readonly tokenBudget?: number | null | undefined; + }) => Effect.Effect; + readonly moveTicket: ( + ticketId: TicketId, + toLane: LaneKey, + ) => Effect.Effect; + readonly runLane: (ticketId: TicketId) => Effect.Effect; + // Webhook-correlated event: evaluates the ticket's current lane onEvent + // matchers and moves/queues the ticket like a manual move when one fires. + readonly ingestExternalEvent: (input: { + readonly boardId: BoardId; + readonly name: string; + readonly ticketId: TicketId; + readonly payload: unknown; + }) => Effect.Effect< + { readonly outcome: "moved" | "queued" | "noop"; readonly toLane?: string }, + WorkflowEventStoreError + >; + readonly resolveApproval: ( + stepRunId: StepRunId, + approved: boolean, + ) => Effect.Effect; + readonly answerTicketStep: (input: { + readonly stepRunId: StepRunId; + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray | undefined; + }) => Effect.Effect; + readonly postTicketMessage: (input: { + readonly ticketId: TicketId; + readonly text?: string | undefined; + readonly attachments?: ReadonlyArray | undefined; + }) => Effect.Effect; + readonly cancelStep: (stepRunId: StepRunId) => Effect.Effect; + readonly cancelBoardPipelines: (boardId: BoardId) => Effect.Effect; + readonly cancelTicketPipelines: ( + ticketId: TicketId, + ) => Effect.Effect; + readonly recoverBoardWip: (boardId: BoardId) => Effect.Effect; + readonly completeRecoveredStep: ( + stepRunId: StepRunId, + result: RecoveredStepResult, + captureTurn?: { readonly threadId: ThreadId; readonly turnId: TurnId }, + ) => Effect.Effect; +} + +export class WorkflowEngine extends Context.Service()( + "t3/workflow/Services/WorkflowEngine", +) {} diff --git a/apps/server/src/workflow/Services/WorkflowEventCommitter.ts b/apps/server/src/workflow/Services/WorkflowEventCommitter.ts new file mode 100644 index 00000000000..905df0890af --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowEventCommitter.ts @@ -0,0 +1,17 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; +import type { WorkflowEventInput } from "./WorkflowEventStore.ts"; + +export interface WorkflowEventCommitterShape { + readonly commit: (event: WorkflowEventInput) => Effect.Effect; + readonly commitMany: ( + events: ReadonlyArray, + ) => Effect.Effect; +} + +export class WorkflowEventCommitter extends Context.Service< + WorkflowEventCommitter, + WorkflowEventCommitterShape +>()("t3/workflow/Services/WorkflowEventCommitter") {} diff --git a/apps/server/src/workflow/Services/WorkflowEventStore.ts b/apps/server/src/workflow/Services/WorkflowEventStore.ts new file mode 100644 index 00000000000..055cb921356 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowEventStore.ts @@ -0,0 +1,32 @@ +import type { BoardId, TicketId, WorkflowEvent } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export type PersistedWorkflowEvent = WorkflowEvent & { readonly sequence: number }; + +type DistributiveOmit = T extends unknown ? Omit : never; +export type WorkflowEventInput = DistributiveOmit; + +export interface WorkflowEventStoreShape { + readonly append: ( + event: WorkflowEventInput, + ) => Effect.Effect; + readonly readByTicket: ( + ticketId: TicketId, + ) => Stream.Stream; + readonly readFromSequence: ( + sequenceExclusive: number, + limit?: number, + ) => Stream.Stream; + readonly readAll: () => Stream.Stream; + readonly deleteForBoard: (boardId: BoardId) => Effect.Effect; + readonly deleteForTicket: (ticketId: TicketId) => Effect.Effect; +} + +export class WorkflowEventStore extends Context.Service< + WorkflowEventStore, + WorkflowEventStoreShape +>()("t3/workflow/Services/WorkflowEventStore") {} diff --git a/apps/server/src/workflow/Services/WorkflowFileLoader.ts b/apps/server/src/workflow/Services/WorkflowFileLoader.ts new file mode 100644 index 00000000000..523acafb787 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowFileLoader.ts @@ -0,0 +1,46 @@ +import type { BoardId, ProjectId, WorkflowDefinition } from "@t3tools/contracts"; +import { WorkflowRpcError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { LintError } from "../workflowFile.ts"; + +export interface WorkflowFilePortShape { + readonly readFileString: (filePath: string) => Effect.Effect; + readonly instructionFileExists: (input: { + readonly repoRoot: string; + readonly repoRelativePath: string; + }) => Effect.Effect; +} + +export class WorkflowFilePort extends Context.Service()( + "t3/workflow/Services/WorkflowFileLoader/WorkflowFilePort", +) {} + +export interface WorkflowProviderInstancePortShape { + readonly providerInstanceExists: (instanceId: string) => Effect.Effect; +} + +export class WorkflowProviderInstancePort extends Context.Service< + WorkflowProviderInstancePort, + WorkflowProviderInstancePortShape +>()("t3/workflow/Services/WorkflowFileLoader/WorkflowProviderInstancePort") {} + +export interface WorkflowFileLoaderShape { + readonly lintDefinition: (input: { + readonly definition: WorkflowDefinition; + readonly projectId: ProjectId; + readonly workspaceRoot: string; + }) => Effect.Effect, WorkflowRpcError>; + readonly loadAndRegister: (input: { + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly relativePath: string; + }) => Effect.Effect; +} + +export class WorkflowFileLoader extends Context.Service< + WorkflowFileLoader, + WorkflowFileLoaderShape +>()("t3/workflow/Services/WorkflowFileLoader") {} diff --git a/apps/server/src/workflow/Services/WorkflowIds.ts b/apps/server/src/workflow/Services/WorkflowIds.ts new file mode 100644 index 00000000000..ec5d06c67a8 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowIds.ts @@ -0,0 +1,25 @@ +import type { + LaneEntryToken, + MessageId, + PipelineRunId, + ScriptRunId, + StepRunId, + TicketId, + WorkflowEventId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +export interface WorkflowIdsShape { + readonly ticketId: () => Effect.Effect; + readonly pipelineRunId: () => Effect.Effect; + readonly scriptRunId: () => Effect.Effect; + readonly stepRunId: () => Effect.Effect; + readonly messageId: () => Effect.Effect; + readonly eventId: () => Effect.Effect; + readonly token: () => Effect.Effect; +} + +export class WorkflowIds extends Context.Service()( + "t3/workflow/Services/WorkflowIds", +) {} diff --git a/apps/server/src/workflow/Services/WorkflowIntake.ts b/apps/server/src/workflow/Services/WorkflowIntake.ts new file mode 100644 index 00000000000..615bea6d369 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowIntake.ts @@ -0,0 +1,27 @@ +import type { AgentSelection, BoardId, WorkflowTicketProposal } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorkflowIntakeInput { + readonly boardId: BoardId; + readonly braindump: string; + readonly agent: AgentSelection; +} + +/** + * Turns a free-form braindump into proposed tickets by running a one-shot + * agent turn at the board's project root. Proposals are returned to the + * client for review — nothing is created server-side. + */ +export interface WorkflowIntakeShape { + readonly proposeTickets: ( + input: WorkflowIntakeInput, + ) => Effect.Effect, WorkflowEventStoreError>; +} + +export class WorkflowIntakeService extends Context.Service< + WorkflowIntakeService, + WorkflowIntakeShape +>()("t3/workflow/Services/WorkflowIntake/WorkflowIntakeService") {} diff --git a/apps/server/src/workflow/Services/WorkflowProjectionPipeline.ts b/apps/server/src/workflow/Services/WorkflowProjectionPipeline.ts new file mode 100644 index 00000000000..809b209d81e --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowProjectionPipeline.ts @@ -0,0 +1,14 @@ +import type { WorkflowEvent } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorkflowProjectionPipelineShape { + readonly projectEvent: (event: WorkflowEvent) => Effect.Effect; +} + +export class WorkflowProjectionPipeline extends Context.Service< + WorkflowProjectionPipeline, + WorkflowProjectionPipelineShape +>()("t3/workflow/Services/WorkflowProjectionPipeline") {} diff --git a/apps/server/src/workflow/Services/WorkflowReadModel.ts b/apps/server/src/workflow/Services/WorkflowReadModel.ts new file mode 100644 index 00000000000..a3e1784dadf --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowReadModel.ts @@ -0,0 +1,225 @@ +import type { + BoardId, + LaneKey, + MessageId, + PipelineRunId, + ProjectId, + StepRunId, + TicketAttachment, + TicketId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface BoardRow { + readonly boardId: string; + readonly projectId: string; + readonly name: string; + readonly workflowFilePath: string; + readonly workflowVersionHash: string; + readonly maxConcurrentTickets: number; +} + +export interface BoardListRow { + readonly boardId: string; + readonly name: string; + readonly filePath: string; +} + +export interface TicketRow { + readonly ticketId: string; + readonly boardId: string; + readonly title: string; + readonly description: string | null; + readonly currentLaneKey: string; + readonly currentLaneEntryToken: string | null; + readonly status: string; + readonly queuedAt: string | null; + readonly totalTokens: number | null; + readonly totalDurationMs: number | null; + // Blocked-by edges; optional so non-dependency readers stay untouched. + readonly dependsOn?: ReadonlyArray; + readonly unresolvedDependencyCount?: number; + readonly tokenBudget?: number | null; + readonly updatedAt?: string; +} + +export interface BoardDigestRow { + readonly windowHours: number; + readonly createdCount: number; + readonly shippedCount: number; + readonly totalTokens: number; + readonly totalDurationMs: number; + readonly needsAttention: ReadonlyArray<{ + readonly ticketId: string; + readonly title: string; + readonly status: string; + readonly laneKey: string; + readonly sinceMs: number; + }>; +} + +// A queued dependent whose last unresolved dependency just resolved — the +// admission sweep should visit its lane. +export interface ReleasableDependentRow { + readonly ticketId: string; + readonly boardId: string; + readonly laneKey: string; +} + +export interface TicketMessageRow { + readonly messageId: MessageId; + readonly ticketId: TicketId; + readonly stepRunId: StepRunId | null; + readonly author: "agent" | "user"; + readonly body: string; + readonly attachments: ReadonlyArray; + readonly createdAt: string; +} + +/** + * Lightweight discussion row for agent instruction context — deliberately + * carries only an attachment count so listing a long thread never decodes + * attachment data URLs. + */ +export interface TicketDiscussionRow { + readonly author: "agent" | "user"; + readonly body: string; + readonly createdAt: string; + readonly attachmentCount: number; +} + +export interface RouteDecisionStepSnapshot { + readonly status: string; + readonly exitCode: number | null; + // Bounded highlight only — raw captured output stays on the step run. + readonly verdict: string | null; +} + +/** + * Why a ticket arrived in a lane, derived from the event log: + * TicketRouteDecided events (automatic routing, with snapshot highlights) + * plus manual TicketMovedToLane events. Snapshot fields are null when the + * stored snapshot is missing or malformed. + */ +export interface TicketRouteDecisionRow { + readonly occurredAt: string; + readonly fromLane: string | null; + readonly toLane: string; + readonly source: "step_on" | "lane_transition" | "lane_on" | "manual" | "external_event"; + readonly matchedTransitionIndex: number | null; + readonly eventName: string | null; + readonly pipelineResult: "success" | "failure" | "blocked" | null; + readonly laneRunCount: number | null; + readonly steps: Readonly> | null; +} + +export interface StepRunRow { + readonly stepRunId: string; + readonly stepKey: string; + readonly stepType: string; + readonly attempt: number | null; + readonly status: string; + readonly waitingReason: string | null; + readonly blockedReason: string | null; + readonly providerResponseKind: "request" | "user-input" | null; + readonly scriptThreadId: string | null; + readonly terminalId: string | null; + readonly scriptStatus: string | null; + readonly exitCode: number | null; + readonly signal: number | null; + readonly output: unknown | null; + readonly startedAt: string | null; + readonly finishedAt: string | null; + readonly providerThreadId: string | null; + readonly inputTokens: number | null; + readonly cachedInputTokens: number | null; + readonly outputTokens: number | null; + readonly totalTokens: number | null; +} + +export interface PipelineStepRunRow { + readonly stepKey: string; + readonly stepType: string; + readonly status: string; + readonly exitCode: number | null; + readonly output: unknown | null; +} + +export interface TicketDetail { + readonly ticket: TicketRow; + readonly steps: ReadonlyArray; + readonly messages: ReadonlyArray; +} + +export interface WorkflowReadModelShape { + readonly registerBoard: (board: { + readonly boardId: BoardId; + readonly projectId: ProjectId; + readonly name: string; + readonly workflowFilePath: string; + readonly workflowVersionHash: string; + readonly maxConcurrentTickets: number; + }) => Effect.Effect; + readonly getBoard: (boardId: BoardId) => Effect.Effect; + readonly deleteBoard: (boardId: BoardId) => Effect.Effect; + readonly deleteBoardTicketState: ( + boardId: BoardId, + ) => Effect.Effect; + readonly deleteTicketState: (ticketId: TicketId) => Effect.Effect; + readonly listBoardsForProject: ( + projectId: ProjectId, + ) => Effect.Effect, WorkflowEventStoreError>; + readonly listTickets: ( + boardId: BoardId, + ) => Effect.Effect, WorkflowEventStoreError>; + readonly countAdmittedInLane: ( + boardId: BoardId, + laneKey: LaneKey, + ) => Effect.Effect; + readonly oldestQueuedForLane: ( + boardId: BoardId, + laneKey: LaneKey, + ) => Effect.Effect; + readonly getTicketDetail: ( + ticketId: TicketId, + ) => Effect.Effect; + readonly listTicketMessages: ( + ticketId: TicketId, + ) => Effect.Effect, WorkflowEventStoreError>; + // The newest `limit` messages in chronological order, attachment counts + // only — cheap enough to call on every agent step. + readonly listTicketDiscussion: ( + ticketId: TicketId, + limit: number, + ) => Effect.Effect, WorkflowEventStoreError>; + readonly listTicketRouteDecisions: ( + ticketId: TicketId, + ) => Effect.Effect, WorkflowEventStoreError>; + readonly listReleasableDependents: ( + ticketId: TicketId, + ) => Effect.Effect, WorkflowEventStoreError>; + // Every ticket that depends on the given one, regardless of state — used to + // republish their views when the dependency's resolution changes. + readonly listDependentTicketIds: ( + ticketId: TicketId, + ) => Effect.Effect, WorkflowEventStoreError>; + readonly getBoardDigest: ( + boardId: BoardId, + windowHours: number, + ) => Effect.Effect; + // Pipeline runs (including the given one) this ticket has had in the same + // lane — feeds the lane.runCount routing variable for bounded loops. + readonly countLanePipelineRuns: ( + pipelineRunId: PipelineRunId, + ) => Effect.Effect; + readonly listStepRunsForPipeline: ( + pipelineRunId: PipelineRunId, + ) => Effect.Effect, WorkflowEventStoreError>; +} + +export class WorkflowReadModel extends Context.Service()( + "t3/workflow/Services/WorkflowReadModel", +) {} diff --git a/apps/server/src/workflow/Services/WorkflowRecovery.ts b/apps/server/src/workflow/Services/WorkflowRecovery.ts new file mode 100644 index 00000000000..269de5ba9d9 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowRecovery.ts @@ -0,0 +1,12 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorkflowRecoveryShape { + readonly recover: () => Effect.Effect; +} + +export class WorkflowRecovery extends Context.Service()( + "t3/workflow/Services/WorkflowRecovery", +) {} diff --git a/apps/server/src/workflow/Services/WorkflowRoutingContextBuilder.ts b/apps/server/src/workflow/Services/WorkflowRoutingContextBuilder.ts new file mode 100644 index 00000000000..4cfa925d810 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowRoutingContextBuilder.ts @@ -0,0 +1,40 @@ +import type { PipelineRunId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export type RoutingPipelineResult = "success" | "failure" | "blocked"; + +export interface WorkflowRoutingStepContext { + readonly exitCode: number | null; + readonly status: string; + readonly output: unknown | null; +} + +export interface WorkflowRoutingContext { + readonly pipeline: { + readonly result: RoutingPipelineResult; + }; + readonly lane: { + // How many pipeline runs (including this one) this ticket has had in the + // current lane — lets transitions bound loops, e.g. re-enter the lane + // while runCount < 3 and escalate to a manual lane afterwards. + readonly runCount: number; + }; + readonly status: string; + readonly steps: Readonly>; +} + +export interface WorkflowRoutingContextBuilderShape { + readonly build: (input: { + readonly ticketId: TicketId; + readonly pipelineRunId: PipelineRunId; + readonly result: RoutingPipelineResult; + }) => Effect.Effect; +} + +export class WorkflowRoutingContextBuilder extends Context.Service< + WorkflowRoutingContextBuilder, + WorkflowRoutingContextBuilderShape +>()("t3/workflow/Services/WorkflowRoutingContextBuilder") {} diff --git a/apps/server/src/workflow/Services/WorkflowTerminalRetentionSweeper.ts b/apps/server/src/workflow/Services/WorkflowTerminalRetentionSweeper.ts new file mode 100644 index 00000000000..eb0c6b15b8a --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowTerminalRetentionSweeper.ts @@ -0,0 +1,19 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +export interface WorkflowTerminalRetentionSweepResult { + readonly candidateCount: number; + readonly deletedCount: number; + readonly failedCount: number; +} + +export interface WorkflowTerminalRetentionSweeperShape { + readonly sweep: () => Effect.Effect; + readonly start: () => Effect.Effect; +} + +export class WorkflowTerminalRetentionSweeper extends Context.Service< + WorkflowTerminalRetentionSweeper, + WorkflowTerminalRetentionSweeperShape +>()("t3/workflow/Services/WorkflowTerminalRetentionSweeper") {} diff --git a/apps/server/src/workflow/Services/WorkflowThreadJanitor.ts b/apps/server/src/workflow/Services/WorkflowThreadJanitor.ts new file mode 100644 index 00000000000..b8ba89b7411 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowThreadJanitor.ts @@ -0,0 +1,29 @@ +import type { BoardId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +/** + * Deletes the hidden orchestration threads created for workflow dispatches + * (agent steps, review panels) when their owning ticket or board is deleted. + * Thread ids must be collected BEFORE the workflow cascade removes the + * outbox rows that know them; deletion runs after, through the real + * thread.delete command path. + */ +export interface WorkflowThreadJanitorShape { + readonly collectBoardThreads: ( + boardId: BoardId, + ) => Effect.Effect, WorkflowEventStoreError>; + readonly collectTicketThreads: ( + ticketId: TicketId, + ) => Effect.Effect, WorkflowEventStoreError>; + readonly deleteThreads: ( + threadIds: ReadonlyArray, + ) => Effect.Effect; +} + +export class WorkflowThreadJanitor extends Context.Service< + WorkflowThreadJanitor, + WorkflowThreadJanitorShape +>()("t3/workflow/Services/WorkflowThreadJanitor") {} diff --git a/apps/server/src/workflow/Services/WorkflowWebhook.ts b/apps/server/src/workflow/Services/WorkflowWebhook.ts new file mode 100644 index 00000000000..4e7da2dd191 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowWebhook.ts @@ -0,0 +1,53 @@ +import type { BoardId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorkflowWebhookConfigResult { + readonly path: string; + readonly hasToken: boolean; + readonly tokenPrefix?: string; + /** Present only when the token was just created or rotated. */ + readonly token?: string; +} + +export type WorkflowWebhookOutcome = "moved" | "queued" | "noop" | "duplicate"; + +export interface WorkflowExternalEventInput { + readonly boardId: BoardId; + readonly name: string; + readonly ticketId: TicketId; + readonly payload: unknown; + readonly deliveryId?: string; +} + +/** + * Per-board webhook ingress: token issue/verify (sha256 at rest, plaintext + * shown once) and delivery dedupe. Event evaluation itself lives in the + * engine (ingestExternalEvent). + */ +export interface WorkflowWebhookShape { + readonly getConfig: ( + boardId: BoardId, + rotate: boolean, + ) => Effect.Effect; + readonly verifyToken: ( + boardId: BoardId, + token: string, + ) => Effect.Effect; + /** True when this delivery id was seen before (and records it if not). */ + readonly recordDelivery: ( + boardId: BoardId, + deliveryId: string, + ) => Effect.Effect; + /** + * Drops the token and delivery log when a board is deleted, so a recreated + * board with the same id never inherits the old token holder's access. + */ + readonly deleteForBoard: (boardId: BoardId) => Effect.Effect; +} + +export class WorkflowWebhook extends Context.Service()( + "t3/workflow/Services/WorkflowWebhook", +) {} diff --git a/apps/server/src/workflow/Services/WorkflowWorktreeJanitor.ts b/apps/server/src/workflow/Services/WorkflowWorktreeJanitor.ts new file mode 100644 index 00000000000..b8ef98f47cd --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowWorktreeJanitor.ts @@ -0,0 +1,25 @@ +import type { BoardId, TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +/** + * Everything needed to clean a ticket's git residue after its rows are gone. + * Plans are collected BEFORE the DB cascade (the repo root and ticket list are + * only resolvable while the projections still exist) and executed after it. + */ +export interface WorktreeCleanupPlan { + readonly repoRoot: string; + readonly ticketIds: ReadonlyArray; +} + +export interface WorkflowWorktreeJanitorShape { + readonly collectBoardPlan: (boardId: BoardId) => Effect.Effect; + readonly collectTicketPlan: (ticketId: TicketId) => Effect.Effect; + /** Best-effort: removes worktrees, ticket branches, checkpoint refs and lease rows. Never fails. */ + readonly run: (plan: WorktreeCleanupPlan | null) => Effect.Effect; +} + +export class WorkflowWorktreeJanitor extends Context.Service< + WorkflowWorktreeJanitor, + WorkflowWorktreeJanitorShape +>()("t3/workflow/Services/WorkflowWorktreeJanitor") {} diff --git a/apps/server/src/workflow/Services/WorktreeLeaseService.ts b/apps/server/src/workflow/Services/WorktreeLeaseService.ts new file mode 100644 index 00000000000..caec92f1cd4 --- /dev/null +++ b/apps/server/src/workflow/Services/WorktreeLeaseService.ts @@ -0,0 +1,29 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface Lease { + readonly fenceToken: number; +} + +export interface WorktreeLeaseServiceShape { + readonly acquire: ( + worktreeRef: string, + ownerKind: "step" | "user", + ownerId: string, + ) => Effect.Effect; + readonly release: ( + worktreeRef: string, + fenceToken: number, + ) => Effect.Effect; + readonly isValid: ( + worktreeRef: string, + fenceToken: number, + ) => Effect.Effect; +} + +export class WorktreeLeaseService extends Context.Service< + WorktreeLeaseService, + WorktreeLeaseServiceShape +>()("t3/workflow/Services/WorktreeLeaseService") {} diff --git a/apps/server/src/workflow/Services/WorktreePort.ts b/apps/server/src/workflow/Services/WorktreePort.ts new file mode 100644 index 00000000000..20cedc9622c --- /dev/null +++ b/apps/server/src/workflow/Services/WorktreePort.ts @@ -0,0 +1,24 @@ +import type { TicketId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorktreeHandle { + readonly repoRoot: string; + readonly worktreeRef: string; + readonly path: string; + // Project identity for services that must resolve the project exactly + // (path matching breaks under canonicalization, e.g. /tmp vs /private/tmp). + readonly projectId?: string; +} + +export interface WorktreePortShape { + readonly ensureWorktree: ( + ticketId: TicketId, + ) => Effect.Effect; +} + +export class WorktreePort extends Context.Service()( + "t3/workflow/Services/WorktreePort", +) {} diff --git a/apps/server/src/workflow/WorkflowEngineLive.test.ts b/apps/server/src/workflow/WorkflowEngineLive.test.ts new file mode 100644 index 00000000000..b3fa35aafc1 --- /dev/null +++ b/apps/server/src/workflow/WorkflowEngineLive.test.ts @@ -0,0 +1,70 @@ +import { assert, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { MigrationsLive } from "../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { makeStubStepExecutor } from "./Layers/StubStepExecutor.ts"; +import { BoardRegistry } from "./Services/BoardRegistry.ts"; +import { ScriptCancelRegistry } from "./Services/ScriptCancelRegistry.ts"; +import { WorkflowEngine } from "./Services/WorkflowEngine.ts"; +import { WorkflowReadModel } from "./Services/WorkflowReadModel.ts"; +import { WorkflowEngineCoreLive } from "./WorkflowEngineLive.ts"; + +const definition = { + name: "wf", + lanes: [{ key: "backlog", name: "Backlog", entry: "manual" }], +}; + +let cryptoByte = 0; +const TestCrypto = Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => { + const bytes = new Uint8Array(size); + bytes.fill(cryptoByte); + cryptoByte = (cryptoByte + 1) % 256; + return bytes; + }, + digest: (_algorithm, data) => Effect.succeed(data), + }), +); + +const layer = it.layer( + WorkflowEngineCoreLive.pipe( + Layer.provideMerge(makeStubStepExecutor({ default: { _tag: "completed" } })), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(TestCrypto), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowEngineCoreLive", (it) => { + it.effect("composes the engine core with an injected StepExecutor", () => + Effect.gen(function* () { + cryptoByte = 0; + const registry = yield* BoardRegistry; + yield* registry.register("b-live" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + const ticketId = yield* engine.createTicket({ + boardId: "b-live" as never, + title: "Live layer", + initialLane: "backlog" as never, + }); + const detail = yield* read.getTicketDetail(ticketId); + + assert.equal(detail?.ticket.title, "Live layer"); + assert.equal(detail?.ticket.currentLaneKey, "backlog"); + }), + ); +}); diff --git a/apps/server/src/workflow/WorkflowEngineLive.ts b/apps/server/src/workflow/WorkflowEngineLive.ts new file mode 100644 index 00000000000..d1eacadb4c0 --- /dev/null +++ b/apps/server/src/workflow/WorkflowEngineLive.ts @@ -0,0 +1,22 @@ +import * as Layer from "effect/Layer"; + +import { ApprovalGateLive } from "./Layers/ApprovalGate.ts"; +import { BoardRegistryLive } from "./Layers/BoardRegistry.ts"; +import { PredicateEvaluatorLive } from "./Layers/PredicateEvaluator.ts"; +import { WorkflowBoardSaveLocksLive } from "./Layers/WorkflowBoardSaveLocks.ts"; +import { WorkflowEngineLayer } from "./Layers/WorkflowEngine.ts"; +import { WorkflowEventCommitterLive } from "./Layers/WorkflowEventCommitter.ts"; +import { WorkflowIdsLive } from "./Layers/WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./Layers/WorkflowRoutingContextBuilder.ts"; +import { WorkflowFoundationLive } from "./WorkflowFoundationLive.ts"; + +export const WorkflowEngineCoreLive = WorkflowEngineLayer.pipe( + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(WorkflowIdsLive), + Layer.provideMerge(WorkflowFoundationLive), +); diff --git a/apps/server/src/workflow/WorkflowFoundationLive.test.ts b/apps/server/src/workflow/WorkflowFoundationLive.test.ts new file mode 100644 index 00000000000..e9cd3c3a393 --- /dev/null +++ b/apps/server/src/workflow/WorkflowFoundationLive.test.ts @@ -0,0 +1,27 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { MigrationsLive } from "../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { WorkflowEventStore } from "./Services/WorkflowEventStore.ts"; +import { WorkflowReadModel } from "./Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "./WorkflowFoundationLive.ts"; + +const layer = it.layer( + WorkflowFoundationLive.pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), +); + +layer("WorkflowFoundationLive", (it) => { + it.effect("provides event store and read model together", () => + Effect.gen(function* () { + const store = yield* WorkflowEventStore; + const read = yield* WorkflowReadModel; + assert.isDefined(store.append); + assert.isDefined(read.getBoard); + }), + ); +}); diff --git a/apps/server/src/workflow/WorkflowFoundationLive.ts b/apps/server/src/workflow/WorkflowFoundationLive.ts new file mode 100644 index 00000000000..29191a8f42a --- /dev/null +++ b/apps/server/src/workflow/WorkflowFoundationLive.ts @@ -0,0 +1,13 @@ +import * as Layer from "effect/Layer"; + +import { WorkflowEventStoreLive } from "./Layers/WorkflowEventStore.ts"; +import { WorkflowBoardVersionStoreLive } from "./Layers/WorkflowBoardVersionStore.ts"; +import { WorkflowProjectionPipelineLive } from "./Layers/WorkflowProjectionPipeline.ts"; +import { WorkflowReadModelLive } from "./Layers/WorkflowReadModel.ts"; + +export const WorkflowFoundationLive = Layer.mergeAll( + WorkflowEventStoreLive, + WorkflowBoardVersionStoreLive, + WorkflowProjectionPipelineLive, + WorkflowReadModelLive, +); diff --git a/apps/server/src/workflow/WorkflowRuntimeLive.ts b/apps/server/src/workflow/WorkflowRuntimeLive.ts new file mode 100644 index 00000000000..1b9dc7331ea --- /dev/null +++ b/apps/server/src/workflow/WorkflowRuntimeLive.ts @@ -0,0 +1,123 @@ +import * as Layer from "effect/Layer"; + +import { ProjectionThreadActivityRepositoryLive } from "../persistence/Layers/ProjectionThreadActivities.ts"; +import { ProjectionThreadMessageRepositoryLive } from "../persistence/Layers/ProjectionThreadMessages.ts"; +import { ProjectionTurnRepositoryLive } from "../persistence/Layers/ProjectionTurns.ts"; +import { ApprovalGateLive } from "./Layers/ApprovalGate.ts"; +import { BoardDiscoveryLive } from "./Layers/BoardDiscovery.ts"; +import { BoardRegistryLive } from "./Layers/BoardRegistry.ts"; +import { CapturedStepOutputReaderLive } from "./Layers/CapturedStepOutputReader.ts"; +import { DurableApprovalResumeLive } from "./Layers/DurableApprovalResume.ts"; +import { ScriptCancelRegistryLive } from "./Layers/ScriptCancelRegistry.ts"; +import { ScriptCommandRunnerLive } from "./Layers/ScriptCommandRunner.ts"; +import { ScriptStepExecutorLive } from "./Layers/ScriptStepExecutor.ts"; +import { + ProviderDispatchOutboxLive, + ProviderTurnPortLive, +} from "./Layers/ProviderDispatchOutbox.ts"; +import { ProjectScriptTrustLive } from "./Layers/ProjectScriptTrust.ts"; +import { ProviderResponsePortLive } from "./Layers/ProviderResponsePort.ts"; +import { ProjectWorkspaceResolverLive } from "./Layers/ProjectWorkspaceResolver.ts"; +import { PredicateEvaluatorLive } from "./Layers/PredicateEvaluator.ts"; +import { RealStepExecutorLive, WorktreePortLive } from "./Layers/RealStepExecutor.ts"; +import { SetupRunServiceLive, SetupTerminalPortLive } from "./Layers/SetupRunService.ts"; +import { StepUsageReaderLive } from "./Layers/StepUsageReader.ts"; +import { TicketCheckpointServiceLive } from "./Layers/TicketCheckpointService.ts"; +import { MergeGitPortLive, TicketMergeServiceLive } from "./Layers/TicketMergeService.ts"; +import { WorkflowThreadJanitorLive } from "./Layers/WorkflowThreadJanitor.ts"; +import { WorkflowWebhookLive } from "./Layers/WorkflowWebhook.ts"; +import { WorkflowWorktreeJanitorLive } from "./Layers/WorkflowWorktreeJanitor.ts"; +import { TicketDiffQueryLive, WorktreeDiffPortLive } from "./Layers/TicketDiffQuery.ts"; +import { TurnProjectionPortLive, TurnStateReaderLive } from "./Layers/TurnStateReader.ts"; +import { WorkflowBoardEventsLive } from "./Layers/WorkflowBoardEvents.ts"; +import { WorkflowBoardSaveLocksLive } from "./Layers/WorkflowBoardSaveLocks.ts"; +import { WorkflowEngineLayer } from "./Layers/WorkflowEngine.ts"; +import { WorkflowEventCommitterLive } from "./Layers/WorkflowEventCommitter.ts"; +import { + WorkflowFileLoaderLive, + WorkflowFilePortLive, + WorkflowProviderInstancePortLive, +} from "./Layers/WorkflowFileLoader.ts"; +import { WorkflowIdsLive } from "./Layers/WorkflowIds.ts"; +import { WorkflowIntakeLive } from "./Layers/WorkflowIntake.ts"; +import { WorkflowRecoveryLive } from "./Layers/WorkflowRecovery.ts"; +import { WorkflowRoutingContextBuilderLive } from "./Layers/WorkflowRoutingContextBuilder.ts"; +import { WorkflowTerminalRetentionSweeperLive } from "./Layers/WorkflowTerminalRetentionSweeper.ts"; +import { WorktreeLeaseServiceLive } from "./Layers/WorktreeLeaseService.ts"; +import { WorkflowFoundationLive } from "./WorkflowFoundationLive.ts"; + +const StepExecutionLive = RealStepExecutorLive.pipe(Layer.provideMerge(TicketMergeServiceLive)); + +const WorkflowRuntimeCoreBaseLive = Layer.mergeAll( + WorkflowEngineLayer, + WorkflowRecoveryLive.pipe(Layer.provideMerge(WorkflowEngineLayer)), + WorkflowTerminalRetentionSweeperLive.pipe(Layer.provideMerge(WorkflowEngineLayer)), +).pipe( + Layer.provideMerge(StepExecutionLive), + Layer.provideMerge(CapturedStepOutputReaderLive), + Layer.provideMerge(ScriptStepExecutorLive), + Layer.provideMerge(ScriptCommandRunnerLive), + Layer.provideMerge(ScriptCancelRegistryLive), + Layer.provideMerge(ProjectScriptTrustLive), + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge(TurnStateReaderLive), + Layer.provideMerge(SetupRunServiceLive), + Layer.provideMerge(WorktreeLeaseServiceLive), + Layer.provideMerge(DurableApprovalResumeLive), + Layer.provideMerge(WorkflowBoardEventsLive), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(PredicateEvaluatorLive), + Layer.provideMerge(WorkflowRoutingContextBuilderLive), + Layer.provideMerge(ApprovalGateLive), + Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(ProjectionThreadMessageRepositoryLive), +); + +export const WorkflowRuntimeCoreLive = WorkflowRuntimeCoreBaseLive.pipe( + Layer.provideMerge(WorkflowFoundationLive), +); + +export const WorkflowRuntimeLive = WorkflowRuntimeCoreLive.pipe( + Layer.provideMerge(WorkflowIdsLive), + Layer.provideMerge(ProviderTurnPortLive), + Layer.provideMerge(TurnProjectionPortLive), + Layer.provideMerge(SetupTerminalPortLive), + Layer.provideMerge(WorkflowWorktreeJanitorLive), + Layer.provideMerge(WorkflowThreadJanitorLive), + Layer.provideMerge(WorkflowWebhookLive), + Layer.provideMerge( + StepUsageReaderLive.pipe(Layer.provide(ProjectionThreadActivityRepositoryLive)), + ), + Layer.provideMerge(TicketCheckpointServiceLive), + Layer.provideMerge(MergeGitPortLive), + Layer.provideMerge(WorktreePortLive), + Layer.provideMerge(ProviderResponsePortLive), +); + +const WorkflowBoardDiscoverySupportLive = BoardDiscoveryLive.pipe( + Layer.provideMerge(ProjectWorkspaceResolverLive), + Layer.provideMerge(WorkflowFileLoaderLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), +); + +export const WorkflowRpcSupportLive = Layer.mergeAll( + WorkflowFileLoaderLive, + TicketDiffQueryLive, + ProjectWorkspaceResolverLive, + WorkflowBoardDiscoverySupportLive, + WorkflowBoardSaveLocksLive, +).pipe( + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowBoardEventsLive), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(WorkflowFilePortLive), + Layer.provideMerge(WorkflowProviderInstancePortLive), + Layer.provideMerge(WorktreeDiffPortLive), +); + +export const WorkflowServerRuntimeLive = WorkflowIntakeLive.pipe( + Layer.provideMerge(WorkflowRpcSupportLive), + Layer.provideMerge(WorkflowRuntimeLive), +); diff --git a/apps/server/src/workflow/boardDeletion.test.ts b/apps/server/src/workflow/boardDeletion.test.ts new file mode 100644 index 00000000000..2ce5085d39a --- /dev/null +++ b/apps/server/src/workflow/boardDeletion.test.ts @@ -0,0 +1,269 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { MigrationsLive } from "../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { WorkflowEventStore } from "./Services/WorkflowEventStore.ts"; +import { WorkflowReadModel } from "./Services/WorkflowReadModel.ts"; +import { deleteWorkflowBoardTicketOwnedState } from "./boardDeletion.ts"; +import { WorkflowEventStoreLive } from "./Layers/WorkflowEventStore.ts"; +import { WorkflowReadModelLive } from "./Layers/WorkflowReadModel.ts"; + +const deletionLayer = Layer.mergeAll(WorkflowEventStoreLive, WorkflowReadModelLive).pipe( + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), +); + +const seedTicketOwnedRows = (ticketId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const store = yield* WorkflowEventStore; + const now = "2026-06-08T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + ${ticketId}, + 'board-ticket-cascade', + ${ticketId}, + 'done', + 'done', + ${now}, + ${now} + ) + `; + yield* sql` + INSERT INTO projection_pipeline_run ( + pipeline_run_id, + ticket_id, + lane_key, + lane_entry_token, + status, + started_at + ) + VALUES (${`pipeline-${ticketId}`}, ${ticketId}, 'done', ${`token-${ticketId}`}, 'completed', ${now}) + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + status, + started_at + ) + VALUES (${`step-${ticketId}`}, ${`pipeline-${ticketId}`}, ${ticketId}, 'cleanup', 'script', 'completed', ${now}) + `; + yield* sql` + INSERT INTO workflow_script_run ( + script_run_id, + step_run_id, + ticket_id, + script_thread_id, + terminal_id, + status, + started_at + ) + VALUES (${`script-${ticketId}`}, ${`step-${ticketId}`}, ${ticketId}, ${`thread-${ticketId}`}, ${`terminal-${ticketId}`}, 'completed', ${now}) + `; + yield* sql` + INSERT INTO workflow_dispatch_outbox ( + dispatch_id, + ticket_id, + step_run_id, + thread_id, + provider_instance, + model, + instruction, + worktree_path, + status, + created_at + ) + VALUES (${`dispatch-${ticketId}`}, ${ticketId}, ${`step-${ticketId}`}, ${`thread-${ticketId}`}, 'codex', 'gpt-5.5', 'cleanup', ${`/tmp/${ticketId}`}, 'completed', ${now}) + `; + yield* sql` + INSERT INTO workflow_setup_run ( + setup_run_id, + ticket_id, + worktree_ref, + status, + started_at + ) + VALUES (${`setup-${ticketId}`}, ${ticketId}, ${`worktree-${ticketId}`}, 'completed', ${now}) + `; + yield* sql` + INSERT INTO projection_ticket_message ( + message_id, + ticket_id, + step_run_id, + author, + body, + attachments_json, + created_at + ) + VALUES (${`message-${ticketId}`}, ${ticketId}, ${`step-${ticketId}`}, 'user', 'cleanup', '[]', ${now}) + `; + yield* store.append({ + type: "TicketCreated", + eventId: `event-${ticketId}` as never, + ticketId: ticketId as never, + occurredAt: now as never, + payload: { + boardId: "board-ticket-cascade" as never, + title: ticketId as never, + laneKey: "done" as never, + }, + }); + }); + +const ticketOwnedRowCount = (ticketId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_ticket WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM projection_pipeline_run WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM projection_step_run WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_script_run WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_dispatch_outbox WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_setup_run WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM projection_ticket_message WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_events WHERE ticket_id = ${ticketId} + `; + return rows.reduce((total, row) => total + row.count, 0); + }); + +it.effect("deletes one ticket under the board save lock after cancelling active work", () => + Effect.gen(function* () { + const calls = yield* Ref.make>([]); + const sql = yield* SqlClient.SqlClient; + const record = (call: string) => Ref.update(calls, (current) => [...current, call]); + + yield* deleteWorkflowBoardTicketOwnedState( + { + saveLocks: { + withSaveLock: (boardId, effect) => + Effect.gen(function* () { + yield* record(`lock:${boardId}:enter`); + const result = yield* effect; + yield* record(`lock:${boardId}:exit`); + return result; + }), + }, + engine: { + cancelTicketPipelines: (ticketId) => record(`cancel:${ticketId}`), + }, + eventStore: { + deleteForTicket: (ticketId) => record(`events:${ticketId}`), + }, + readModel: { + deleteTicketState: (ticketId) => record(`read:${ticketId}`), + }, + sql, + }, + "board-ticket-cascade" as never, + "ticket-cascade" as never, + ); + + assert.deepEqual(yield* Ref.get(calls), [ + "lock:board-ticket-cascade:enter", + "cancel:ticket-cascade", + "events:ticket-cascade", + "read:ticket-cascade", + "lock:board-ticket-cascade:exit", + ]); + }).pipe(Effect.provide(SqlitePersistenceMemory)), +); + +it.effect("collects hidden dispatch threads before the cascade and deletes them after", () => + Effect.gen(function* () { + const calls = yield* Ref.make>([]); + const sql = yield* SqlClient.SqlClient; + const record = (call: string) => Ref.update(calls, (current) => [...current, call]); + + yield* deleteWorkflowBoardTicketOwnedState( + { + saveLocks: { + withSaveLock: (_boardId, effect) => effect, + }, + engine: { + cancelTicketPipelines: () => Effect.void, + }, + eventStore: { + deleteForTicket: () => record("cascade:events"), + }, + readModel: { + deleteTicketState: () => record("cascade:read"), + }, + sql, + threadJanitor: { + collectTicketThreads: (ticketId) => + record(`collect:${ticketId}`).pipe( + Effect.as(["thread-a", "thread-b"] as ReadonlyArray), + ), + deleteThreads: (threadIds) => record(`delete:${threadIds.join("+")}`), + }, + }, + "board-ticket-cascade" as never, + "ticket-threads" as never, + ); + + assert.deepEqual(yield* Ref.get(calls), [ + "collect:ticket-threads", + "cascade:events", + "cascade:read", + "delete:thread-a+thread-b", + ]); + }).pipe(Effect.provide(SqlitePersistenceMemory)), +); + +it.effect("rolls back events and read-model rows when the ticket cascade fails", () => + Effect.gen(function* () { + const eventStore = yield* WorkflowEventStore; + const readModel = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const ticketId = "ticket-cascade-rollback"; + + yield* seedTicketOwnedRows(ticketId); + yield* sql` + CREATE TRIGGER fail_ticket_cascade_step_delete + BEFORE DELETE ON projection_step_run + WHEN OLD.ticket_id = 'ticket-cascade-rollback' + BEGIN + SELECT RAISE(FAIL, 'simulated ticket cascade failure'); + END + `; + + const result = yield* Effect.exit( + deleteWorkflowBoardTicketOwnedState( + { + saveLocks: { + withSaveLock: (_boardId, effect) => effect, + }, + engine: { + cancelTicketPipelines: () => Effect.void, + }, + eventStore, + readModel, + sql, + }, + "board-ticket-cascade" as never, + ticketId as never, + ), + ); + + assert.equal(result._tag, "Failure"); + assert.equal(yield* ticketOwnedRowCount(ticketId), 8); + }).pipe(Effect.provide(deletionLayer)), +); diff --git a/apps/server/src/workflow/boardDeletion.ts b/apps/server/src/workflow/boardDeletion.ts new file mode 100644 index 00000000000..9594af34dd8 --- /dev/null +++ b/apps/server/src/workflow/boardDeletion.ts @@ -0,0 +1,109 @@ +import type { BoardId, TicketId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import type { BoardRegistryShape } from "./Services/BoardRegistry.ts"; +import type { WorkflowBoardSaveLocksShape } from "./Services/WorkflowBoardSaveLocks.ts"; +import type { WorkflowBoardVersionStoreShape } from "./Services/WorkflowBoardVersionStore.ts"; +import type { WorkflowEngineShape } from "./Services/WorkflowEngine.ts"; +import type { WorkflowEventStoreShape } from "./Services/WorkflowEventStore.ts"; +import type { WorkflowReadModelShape } from "./Services/WorkflowReadModel.ts"; +import type { WorkflowThreadJanitorShape } from "./Services/WorkflowThreadJanitor.ts"; +import type { WorkflowWebhookShape } from "./Services/WorkflowWebhook.ts"; +import type { WorkflowWorktreeJanitorShape } from "./Services/WorkflowWorktreeJanitor.ts"; + +export interface WorkflowBoardOwnedStateDeletionDeps { + readonly boardRegistry: Pick; + readonly engine: Pick; + readonly eventStore: Pick; + readonly readModel: Pick; + readonly versionStore: Pick; + readonly worktreeJanitor?: Pick; + readonly threadJanitor?: Pick< + WorkflowThreadJanitorShape, + "collectBoardThreads" | "deleteThreads" + >; + readonly webhook?: Pick; +} + +export interface WorkflowBoardTicketStateDeletionDeps { + readonly saveLocks: Pick; + readonly engine: Pick; + readonly eventStore: Pick; + readonly readModel: Pick; + readonly sql: Pick; + readonly worktreeJanitor?: Pick; + readonly threadJanitor?: Pick< + WorkflowThreadJanitorShape, + "collectTicketThreads" | "deleteThreads" + >; +} + +const noCleanup = Effect.succeed(null); +const noThreads = Effect.succeed([] as ReadonlyArray); + +export const deleteWorkflowBoardOwnedState = ( + deps: WorkflowBoardOwnedStateDeletionDeps, + boardId: BoardId, +) => + Effect.gen(function* () { + // Collected before the cascade — the repo root and ticket list are only + // resolvable while the projections still exist. + const cleanupPlan = yield* deps.worktreeJanitor?.collectBoardPlan(boardId) ?? noCleanup; + const threadIds = yield* deps.threadJanitor?.collectBoardThreads(boardId) ?? noThreads; + yield* deps.engine.cancelBoardPipelines(boardId); + yield* deps.webhook?.deleteForBoard(boardId) ?? Effect.void; + yield* deps.versionStore.deleteForBoard(boardId); + yield* deps.eventStore.deleteForBoard(boardId); + yield* deps.readModel.deleteBoardTicketState(boardId); + yield* deps.boardRegistry.unregister(boardId); + yield* deps.readModel.deleteBoard(boardId); + yield* deps.worktreeJanitor?.run(cleanupPlan) ?? Effect.void; + yield* deps.threadJanitor?.deleteThreads(threadIds) ?? Effect.void; + }); + +export const deleteWorkflowBoardTicketOwnedStateWhen = ( + deps: WorkflowBoardTicketStateDeletionDeps, + boardId: BoardId, + ticketId: TicketId, + shouldDelete: Effect.Effect, +) => + Effect.gen(function* () { + const deleted = yield* deps.saveLocks.withSaveLock( + boardId, + Effect.gen(function* () { + const cleanupPlan = yield* deps.worktreeJanitor?.collectTicketPlan(ticketId) ?? noCleanup; + const threadIds = yield* deps.threadJanitor?.collectTicketThreads(ticketId) ?? noThreads; + const deleted = yield* deps.sql.withTransaction( + Effect.gen(function* () { + if (!(yield* shouldDelete)) { + return false; + } + + yield* deps.engine.cancelTicketPipelines(ticketId); + yield* deps.eventStore.deleteForTicket(ticketId); + yield* deps.readModel.deleteTicketState(ticketId); + return true; + }), + ); + if (deleted) { + // Git/filesystem cleanup stays outside the DB transaction but under + // the board save lock so a concurrent re-create of the same ticket + // worktree cannot interleave with its removal. + yield* deps.worktreeJanitor?.run(cleanupPlan) ?? Effect.void; + yield* deps.threadJanitor?.deleteThreads(threadIds) ?? Effect.void; + } + return deleted; + }), + ); + return deleted; + }); + +export const deleteWorkflowBoardTicketOwnedState = ( + deps: WorkflowBoardTicketStateDeletionDeps, + boardId: BoardId, + ticketId: TicketId, +) => + deleteWorkflowBoardTicketOwnedStateWhen(deps, boardId, ticketId, Effect.succeed(true)).pipe( + Effect.asVoid, + ); diff --git a/apps/server/src/workflow/boardSlug.test.ts b/apps/server/src/workflow/boardSlug.test.ts new file mode 100644 index 00000000000..34e7a0b9b3e --- /dev/null +++ b/apps/server/src/workflow/boardSlug.test.ts @@ -0,0 +1,16 @@ +import { assert, describe, it } from "@effect/vitest"; +import { slugifyBoardName, uniqueBoardSlug } from "./boardSlug.ts"; + +describe("boardSlug", () => { + it("slugifies names", () => { + assert.equal(slugifyBoardName("Workflow Board"), "workflow-board"); + assert.equal(slugifyBoardName(" A/B board!! "), "a-b-board"); + assert.equal(slugifyBoardName("!!!"), "board"); + }); + + it("uniquifies against existing slugs", () => { + const existing = new Set(["workflow-board", "workflow-board-2"]); + assert.equal(uniqueBoardSlug("workflow-board", existing), "workflow-board-3"); + assert.equal(uniqueBoardSlug("fresh", existing), "fresh"); + }); +}); diff --git a/apps/server/src/workflow/boardSlug.ts b/apps/server/src/workflow/boardSlug.ts new file mode 100644 index 00000000000..07d999fe857 --- /dev/null +++ b/apps/server/src/workflow/boardSlug.ts @@ -0,0 +1,15 @@ +export const slugifyBoardName = (name: string): string => { + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug.length > 0 ? slug : "board"; +}; + +export const uniqueBoardSlug = (base: string, existing: ReadonlySet): string => { + if (!existing.has(base)) return base; + let n = 2; + while (existing.has(`${base}-${n}`)) n += 1; + return `${base}-${n}`; +}; diff --git a/apps/server/src/workflow/defaultBoard.test.ts b/apps/server/src/workflow/defaultBoard.test.ts new file mode 100644 index 00000000000..9ef432b423d --- /dev/null +++ b/apps/server/src/workflow/defaultBoard.test.ts @@ -0,0 +1,83 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; +import { WorkflowDefinition } from "@t3tools/contracts"; +import { defaultBoardDefinition } from "./defaultBoard.ts"; +import { encodeWorkflowDefinitionJson, lintWorkflowDefinition } from "./workflowFile.ts"; + +const decodeWorkflowDefinitionJson = Schema.decodeSync(Schema.fromJsonString(WorkflowDefinition)); + +describe("defaultBoardDefinition", () => { + const def = defaultBoardDefinition({ + name: "My board", + agent: { instance: "codex", model: "gpt-5.4" }, + }); + + it("round-trips through the board file encoder", () => { + const decoded = decodeWorkflowDefinitionJson(encodeWorkflowDefinitionJson(def)); + assert.equal(decoded.name, "My board"); + assert.deepEqual( + decoded.lanes.map((lane) => lane.key as string), + [ + "backlog", + "planning", + "specifying", + "planning_issues", + "implementation", + "owner_review", + "land", + "manual_review", + "implementation_issues", + "done", + ], + ); + }); + + it("passes the linter for a known agent instance", () => { + const errors = lintWorkflowDefinition(def, { + providerInstanceExists: (id) => id === "codex", + instructionFileExists: () => true, + }); + assert.deepEqual(errors, []); + }); + + it("bakes the agent into every agent step", () => { + for (const lane of def.lanes) { + for (const step of lane.pipeline ?? []) { + if (step.type === "agent") { + assert.equal(step.agent.instance, "codex"); + assert.equal(step.agent.model, "gpt-5.4"); + } + } + } + }); + + it("bounds the implementation review loop and escalates to manual review", () => { + const implementation = def.lanes.find((lane) => (lane.key as string) === "implementation"); + assert.ok(implementation); + const transitions = implementation.transitions ?? []; + assert.equal(transitions.length, 3); + assert.equal(transitions[0]?.to, "implementation"); + assert.equal(transitions[1]?.to, "manual_review"); + assert.equal(transitions[2]?.to, "owner_review"); + const loopRule = JSON.stringify(transitions[0]?.when); + assert.ok(loopRule.includes("lane.runCount")); + const review = implementation.pipeline?.find((step) => (step.key as string) === "review"); + assert.ok(review?.type === "agent" && review.captureOutput === true); + }); + + it("uses retry policies on the agent work steps and retention on done", () => { + for (const stepKey of ["plan", "spec", "implement"]) { + const step = def.lanes + .flatMap((lane) => lane.pipeline ?? []) + .find((candidate) => (candidate.key as string) === stepKey); + assert.ok( + step?.type === "agent" && step.retry?.maxAttempts === 2, + `step ${stepKey} should retry`, + ); + } + const done = def.lanes.find((lane) => (lane.key as string) === "done"); + assert.ok(done?.terminal === true && done.retention !== undefined); + const land = def.lanes.find((lane) => (lane.key as string) === "land"); + assert.equal(land?.pipeline?.[0]?.type, "merge"); + }); +}); diff --git a/apps/server/src/workflow/defaultBoard.ts b/apps/server/src/workflow/defaultBoard.ts new file mode 100644 index 00000000000..0495c309581 --- /dev/null +++ b/apps/server/src/workflow/defaultBoard.ts @@ -0,0 +1,244 @@ +import { WorkflowDefinition } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +export interface DefaultBoardAgent { + readonly instance: string; + readonly model: string; +} + +const decodeWorkflowDefinition = Schema.decodeUnknownSync(WorkflowDefinition); + +const PLAN_INSTRUCTION = `You are planning the ticket "{{ticket.title}}". + +Ticket description: +{{ticket.description}} + +Investigate the codebase and write a short, concrete implementation plan to a +file named .t3/ticket/{{ticket.id}}/PLAN.md at the repo root of this worktree: the goal, the files you +expect to touch, the approach, and the main risks. Do not implement anything +yet. Keep the plan under a page.`; + +const SPEC_INSTRUCTION = `Turn the plan in .t3/ticket/{{ticket.id}}/PLAN.md for ticket "{{ticket.title}}" into a concrete spec. + +Write .t3/ticket/{{ticket.id}}/SPEC.md at the repo root of this worktree containing: the exact behavior +to build, edge cases to handle, and a checklist of verifiable acceptance +criteria (including which tests or checks must pass). Adjust .t3/ticket/{{ticket.id}}/PLAN.md if your +investigation contradicts it. Do not implement anything yet.`; + +const IMPLEMENT_INSTRUCTION = `Implement ticket "{{ticket.title}}" in this worktree according to .t3/ticket/{{ticket.id}}/SPEC.md. + +If a .t3/ticket/{{ticket.id}}/REVIEW.md file exists at the repo root, a previous review requested +changes: address every issue listed there first, then delete .t3/ticket/{{ticket.id}}/REVIEW.md. + +Satisfy each acceptance criterion in .t3/ticket/{{ticket.id}}/SPEC.md, run the relevant tests/checks, +and fix what you break. Keep the change focused on the ticket.`; + +const REVIEW_INSTRUCTION = `Review the accumulated work for ticket "{{ticket.title}}". + +Diff the worktree against {{ticket.baseRef}} and judge it against .t3/ticket/{{ticket.id}}/SPEC.md. +Look for blocking correctness, reliability, or integration issues and unmet +acceptance criteria — ignore style nits. + +If changes are required, write the specific, actionable issues to .t3/ticket/{{ticket.id}}/REVIEW.md at +the repo root (overwrite it) so the next implementation pass can address them. +If the work is ready, make sure no .t3/ticket/{{ticket.id}}/REVIEW.md file remains.`; + +const REVIEW_OUTPUT_HINT = `Your result object must be {"verdict": "approve"} or {"verdict": "revise"}.`; + +/** + * Default board: Backlog → Planning → Specifying → Implementation (with an + * implement/review loop bounded by lane.runCount) → Owner Review → Land → + * Done. Failures park in a phase-specific issues lane — Planning Issues for + * plan/spec problems, Implementation Issues for build/land problems — and + * Manual Review holds tickets whose review loop budget is exhausted. The + * loop budget is the "3" in the Implementation transitions — edit it in the + * workflow editor to allow more or fewer passes. + */ +export const defaultBoardDefinition = (input: { + readonly name: string; + readonly agent: DefaultBoardAgent; +}): WorkflowDefinition => { + const agent = { instance: input.agent.instance, model: input.agent.model }; + return decodeWorkflowDefinition({ + name: input.name, + settings: { maxConcurrentTickets: 3 }, + lanes: [ + { + key: "backlog", + name: "Backlog", + entry: "manual", + actions: [ + { + label: "Start work", + to: "planning", + hint: "The agent plans, specs, implements and reviews the ticket.", + }, + ], + }, + { + key: "planning", + name: "Planning", + entry: "auto", + pipeline: [ + { + key: "plan", + type: "agent", + agent, + instruction: PLAN_INSTRUCTION, + retry: { maxAttempts: 2 }, + }, + ], + on: { success: "specifying", failure: "planning_issues", blocked: "planning_issues" }, + }, + { + key: "specifying", + name: "Specifying", + entry: "auto", + pipeline: [ + { + key: "spec", + type: "agent", + agent, + instruction: SPEC_INSTRUCTION, + retry: { maxAttempts: 2 }, + }, + ], + on: { success: "implementation", failure: "planning_issues", blocked: "planning_issues" }, + }, + { + key: "planning_issues", + name: "Planning Issues", + entry: "manual", + actions: [ + { + label: "Retry planning", + to: "planning", + hint: "Run planning and specification again.", + }, + { + label: "Back to backlog", + to: "backlog", + hint: "Park the ticket; nothing runs until you start it again.", + }, + ], + }, + { + key: "implementation", + name: "Implementation", + entry: "auto", + pipeline: [ + { + key: "implement", + type: "agent", + agent, + instruction: IMPLEMENT_INSTRUCTION, + retry: { maxAttempts: 2 }, + }, + { + key: "review", + type: "agent", + agent, + instruction: `${REVIEW_INSTRUCTION}\n\n${REVIEW_OUTPUT_HINT}`, + captureOutput: true, + }, + ], + transitions: [ + { + when: { + and: [ + { "==": [{ var: "steps.review.output.verdict" }, "revise"] }, + { "<": [{ var: "lane.runCount" }, 3] }, + ], + }, + to: "implementation", + }, + { + when: { "==": [{ var: "steps.review.output.verdict" }, "revise"] }, + to: "manual_review", + }, + { + when: { "==": [{ var: "steps.review.output.verdict" }, "approve"] }, + to: "owner_review", + }, + ], + // No transition matched means the review verdict was malformed or + // missing — that needs eyes, not an owner-review rubber stamp. + on: { + success: "implementation_issues", + failure: "implementation_issues", + blocked: "implementation_issues", + }, + }, + { + key: "owner_review", + name: "Owner Review", + entry: "manual", + actions: [ + { + label: "Approve & land", + to: "land", + hint: "Merge the ticket's work into the branch checked out in your repo.", + }, + { + label: "Send back", + to: "implementation", + hint: "Run another implement + review pass.", + }, + ], + }, + { + key: "land", + name: "Land", + entry: "manual", + pipeline: [ + { + key: "merge", + type: "merge", + cleanupPaths: [".t3/ticket/{{ticket.id}}"], + }, + ], + on: { success: "done", failure: "implementation_issues", blocked: "implementation_issues" }, + }, + { + key: "manual_review", + name: "Manual Review", + entry: "manual", + actions: [ + { + label: "Approve & land", + to: "land", + hint: "Merge the ticket's work into the branch checked out in your repo.", + }, + { + label: "Send back", + to: "implementation", + hint: "Run another implement + review pass with a fresh loop budget.", + }, + ], + }, + { + key: "implementation_issues", + name: "Implementation Issues", + entry: "manual", + actions: [ + { + label: "Retry implementation", + to: "implementation", + hint: "Run the implement + review pipeline again.", + }, + { + label: "Re-plan", + to: "planning", + hint: "Start over from planning with what you learned.", + }, + { + label: "Back to backlog", + to: "backlog", + hint: "Park the ticket; nothing runs until you start it again.", + }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true, retention: "14 days" }, + ], + }); +}; diff --git a/apps/server/src/workflow/dryRun.test.ts b/apps/server/src/workflow/dryRun.test.ts new file mode 100644 index 00000000000..e4d3d2ab6d7 --- /dev/null +++ b/apps/server/src/workflow/dryRun.test.ts @@ -0,0 +1,229 @@ +import type { WorkflowDefinition } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { PredicateEvaluatorLive } from "./Layers/PredicateEvaluator.ts"; +import { PredicateEvaluator } from "./Services/PredicateEvaluator.ts"; +import { simulateBoardRoute } from "./dryRun.ts"; + +const definition = { + name: "Dry run", + lanes: [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "work", + name: "Work", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + on: { success: "review", blocked: "stuck" }, + }, + ], + on: { failure: "stuck" }, + }, + { + key: "review", + name: "Review", + entry: "auto", + pipeline: [ + { + key: "check", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "review it", + }, + ], + // Self-loop twice (streak grows while runs stay in this lane), then + // fall through to done. + transitions: [{ when: { "<": [{ var: "lane.runCount" }, 3] }, to: "review" }], + on: { success: "done" }, + }, + { key: "stuck", name: "Stuck", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +} as unknown as WorkflowDefinition; + +const layer = it.layer(PredicateEvaluatorLive); + +layer("simulateBoardRoute", (it) => { + it.effect("walks step routes and bounded self-loop transitions to the terminal lane", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const run = yield* simulateBoardRoute({ + definition, + startLane: "work" as never, + scenario: "success", + evaluator, + }); + + // work →(step.on) review →(self-loop ×2 while runCount < 3) →(lane.on) done + assert.equal(run.end, "terminal"); + assert.equal(run.endLane, "done"); + assert.deepEqual( + run.hops.map((hop) => `${hop.fromLane}>${hop.toLane}:${hop.source}`), + [ + "work>review:step_on", + "review>review:lane_transition", + "review>review:lane_transition", + "review>done:lane_on", + ], + ); + assert.equal(run.hops[0]?.viaStepKey, "code"); + assert.equal(run.hops[1]?.matchedTransitionIndex, 0); + assert.lengthOf(run.notes, 0); + }), + ); + + it.effect("lane.runCount resets when another lane runs, exactly like the engine", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + // Review bounces back to work, so review's streak never exceeds 1 and + // `runCount < 3` matches forever — the live engine loops unboundedly, + // and the dry run must say so instead of claiming a bounded loop. + const alternating = { + ...definition, + lanes: (definition.lanes as ReadonlyArray>).map((lane) => + lane["key"] === "review" + ? { + ...lane, + transitions: [{ when: { "<": [{ var: "lane.runCount" }, 3] }, to: "work" }], + } + : lane, + ), + } as unknown as WorkflowDefinition; + const run = yield* simulateBoardRoute({ + definition: alternating, + startLane: "work" as never, + scenario: "success", + evaluator, + }); + assert.equal(run.end, "cycle_cap"); + assert.equal(run.hops.length, 25); + }), + ); + + it.effect("failure scenario falls through lane.on into a manual lane", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const run = yield* simulateBoardRoute({ + definition, + startLane: "work" as never, + scenario: "failure", + evaluator, + }); + assert.equal(run.end, "manual"); + assert.equal(run.endLane, "stuck"); + assert.deepEqual( + run.hops.map((hop) => `${hop.fromLane}>${hop.toLane}:${hop.source}`), + ["work>stuck:lane_on"], + ); + }), + ); + + it.effect("blocked scenario uses the step's blocked route", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const run = yield* simulateBoardRoute({ + definition, + startLane: "work" as never, + scenario: "blocked", + evaluator, + }); + assert.equal(run.hops[0]?.toLane, "stuck"); + assert.equal(run.hops[0]?.source, "step_on"); + assert.equal(run.end, "manual"); + }), + ); + + it.effect("a manual start lane without a pipeline ends immediately", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const run = yield* simulateBoardRoute({ + definition, + startLane: "backlog" as never, + scenario: "success", + evaluator, + }); + assert.equal(run.end, "manual"); + assert.equal(run.endLane, "backlog"); + assert.lengthOf(run.hops, 0); + }), + ); + + it.effect("an empty auto lane never routes, exactly like the engine", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + // The engine returns before starting a pipeline when there are no + // steps, so the lane.on fallback must NOT fire in the dry run either. + const noSteps = { + name: "No steps", + lanes: [ + { key: "only", name: "Only", entry: "auto", pipeline: [], on: { success: "done" } }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + } as unknown as WorkflowDefinition; + const run = yield* simulateBoardRoute({ + definition: noSteps, + startLane: "only" as never, + scenario: "success", + evaluator, + }); + assert.equal(run.end, "no_route"); + assert.equal(run.endLane, "only"); + assert.isTrue(run.notes.some((note) => note.includes("has no steps"))); + }), + ); + + it.effect("an unbounded loop stops at the hop cap", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const step = (key: string) => ({ key, type: "script", run: "true" }); + const looping = { + name: "Loop", + lanes: [ + { key: "a", name: "A", entry: "auto", pipeline: [step("sa")], on: { success: "b" } }, + { key: "b", name: "B", entry: "auto", pipeline: [step("sb")], on: { success: "a" } }, + ], + } as unknown as WorkflowDefinition; + const run = yield* simulateBoardRoute({ + definition: looping, + startLane: "a" as never, + scenario: "success", + evaluator, + }); + assert.equal(run.end, "cycle_cap"); + assert.equal(run.hops.length, 25); + }), + ); + + it.effect("notes when predicates read the approximated ticket status", () => + Effect.gen(function* () { + const evaluator = yield* PredicateEvaluator; + const statusBoard = { + name: "Status", + lanes: [ + { + key: "work", + name: "Work", + entry: "auto", + pipeline: [{ key: "s", type: "script", run: "true" }], + transitions: [{ when: { "==": [{ var: "status" }, "running"] }, to: "done" }], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + } as unknown as WorkflowDefinition; + const run = yield* simulateBoardRoute({ + definition: statusBoard, + startLane: "work" as never, + scenario: "success", + evaluator, + }); + assert.equal(run.end, "terminal"); + assert.isTrue(run.notes.some((note) => note.includes("approximates it"))); + }), + ); +}); diff --git a/apps/server/src/workflow/dryRun.ts b/apps/server/src/workflow/dryRun.ts new file mode 100644 index 00000000000..23e22f1e355 --- /dev/null +++ b/apps/server/src/workflow/dryRun.ts @@ -0,0 +1,190 @@ +import type { + LaneKey, + WorkflowDefinition, + WorkflowDryRunHop, + WorkflowDryRunResult, + WorkflowDryRunScenario, + WorkflowLane, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { inspectJsonLogicRule } from "./jsonLogicRule.ts"; +import type { PredicateEvaluatorShape } from "./Services/PredicateEvaluator.ts"; + +/** + * Simulates a hypothetical ticket's path through a board definition without + * touching any real state. Every agent/script/approval step is assumed to end + * with the chosen scenario outcome; routing then follows the engine's real + * precedence (step.on → lane transitions → lane.on). Transition predicates are + * evaluated against a synthetic context that mirrors the engine's: + * `lane.runCount` is the consecutive streak of pipeline runs in the lane + * (reset by a run elsewhere, exactly like `countLanePipelineRuns`), and data a + * dry run cannot know (captured outputs, ticket fields) reads as null — the + * same as missing data in the engine. + */ + +const MAX_HOPS = 25; + +export type DryRunPredicateEvaluator = Pick; + +const stepStatusForResult = (result: WorkflowDryRunScenario): string => + result === "success" ? "completed" : result === "failure" ? "failed" : "blocked"; + +// What the routing-context builder would read from the projection at decision +// time: tickets run as "running"; step failures and blocks project the ticket +// as "blocked" before the route is decided. +const ticketStatusForResult = (result: WorkflowDryRunScenario): string => + result === "success" ? "running" : "blocked"; + +export const simulateBoardRoute = ({ + definition, + startLane, + scenario, + evaluator, +}: { + readonly definition: WorkflowDefinition; + readonly startLane: LaneKey; + readonly scenario: WorkflowDryRunScenario; + readonly evaluator: DryRunPredicateEvaluator; +}): Effect.Effect => + Effect.gen(function* () { + const laneByKey = new Map( + definition.lanes.map((lane) => [lane.key as string, lane]), + ); + const hops: Array = []; + const notes: Array = []; + const pushNote = (note: string) => { + if (!notes.includes(note)) { + notes.push(note); + } + }; + const finish = (end: WorkflowDryRunResult["end"], endLane: LaneKey): WorkflowDryRunResult => ({ + startLane, + scenario, + hops, + end, + endLane, + notes, + }); + + // Mirrors countLanePipelineRuns: the streak only grows while consecutive + // pipeline runs stay in the same lane; a run elsewhere resets it. + let streakLane: string | null = null; + let streakCount = 0; + + let currentKey = startLane; + for (let hop = 0; hop <= MAX_HOPS; hop += 1) { + const lane = laneByKey.get(currentKey as string); + if (lane === undefined) { + pushNote(`Lane "${currentKey as string}" does not exist — the walk cannot continue.`); + return finish("no_route", currentKey); + } + if (lane.terminal === true) { + return finish("terminal", currentKey); + } + const isStart = hops.length === 0; + // A manual lane parks the ticket until a human acts. The start lane is + // the exception: simulate it as if the user pressed "Run lane". + if (lane.entry !== "auto" && !isStart) { + return finish("manual", currentKey); + } + const steps = lane.pipeline ?? []; + if (steps.length === 0) { + if (lane.entry !== "auto") { + return finish("manual", currentKey); + } + // The engine returns before starting a pipeline for a lane with no + // steps, so transitions and fallbacks are never evaluated. + pushNote( + `Auto lane "${currentKey as string}" has no steps — its pipeline never runs, so nothing routes out of it.`, + ); + return finish("no_route", currentKey); + } + if (hop === MAX_HOPS) { + return finish("cycle_cap", currentKey); + } + + streakCount = streakLane === (currentKey as string) ? streakCount + 1 : 1; + streakLane = currentKey as string; + + // Mirror of the engine's pipeline walk: each step ends with the + // scenario outcome; a step.on match (or a non-success) stops the run. + const stepsContext: Record = {}; + let result: WorkflowDryRunScenario = "success"; + let decision: WorkflowDryRunHop | null = null; + for (const step of steps) { + result = scenario; + stepsContext[step.key as string] = { + exitCode: result === "success" ? 0 : 1, + status: stepStatusForResult(result), + output: null, + }; + const target = step.on?.[result]; + if (target !== undefined) { + decision = { + fromLane: currentKey, + toLane: target, + source: "step_on", + viaStepKey: step.key, + result, + }; + break; + } + if (result !== "success") { + break; + } + } + + if (decision === null) { + const status = ticketStatusForResult(result); + const context = { + pipeline: { result }, + lane: { runCount: streakCount }, + status, + steps: stepsContext, + }; + for (const [index, transition] of (lane.transitions ?? []).entries()) { + if (inspectJsonLogicRule(transition.when).variablePaths.includes("status")) { + pushNote( + `Transition predicates read the ticket status — the dry run approximates it as "${status}".`, + ); + } + const evaluation = yield* evaluator + .evaluate(transition.when, context) + .pipe(Effect.orElseSucceed(() => null)); + if (evaluation === null) { + // The engine fails the whole routing path on a predicate error; + // there is nothing meaningful to simulate past this point. + pushNote( + `Lane "${currentKey as string}" transition #${index + 1} predicate failed to evaluate — live routing would error here.`, + ); + return finish("no_route", currentKey); + } + if (evaluation.result) { + decision = { + fromLane: currentKey, + toLane: transition.to, + source: "lane_transition", + matchedTransitionIndex: index, + result, + }; + break; + } + } + } + + if (decision === null) { + const target = lane.on?.[result]; + if (target !== undefined) { + decision = { fromLane: currentKey, toLane: target, source: "lane_on", result }; + } + } + + if (decision === null) { + return finish("no_route", currentKey); + } + hops.push(decision); + currentKey = decision.toLane; + } + return finish("cycle_cap", currentKey); + }); diff --git a/apps/server/src/workflow/externalEvent.ts b/apps/server/src/workflow/externalEvent.ts new file mode 100644 index 00000000000..d440e86671e --- /dev/null +++ b/apps/server/src/workflow/externalEvent.ts @@ -0,0 +1,49 @@ +/** + * Bound an inbound webhook payload before it enters predicates and the + * route-decision snapshot: JSON-aware (never truncated JSON strings), depth + * and breadth capped, long strings clipped. + */ +const MAX_DEPTH = 6; +const MAX_KEYS = 100; +const MAX_ARRAY = 100; +const MAX_STRING = 2_000; + +export const sanitizeExternalEventPayload = (value: unknown, depth = 0): unknown => { + if (value === null || typeof value === "boolean" || typeof value === "number") { + return value; + } + if (typeof value === "string") { + return value.length > MAX_STRING ? value.slice(0, MAX_STRING) : value; + } + if (depth >= MAX_DEPTH) { + return undefined; + } + if (Array.isArray(value)) { + return value + .slice(0, MAX_ARRAY) + .map((entry) => sanitizeExternalEventPayload(entry, depth + 1)) + .filter((entry) => entry !== undefined); + } + if (typeof value === "object") { + const out: Record = {}; + let keys = 0; + for (const [key, entry] of Object.entries(value)) { + if (keys >= MAX_KEYS) { + break; + } + // "__proto__" as an own key would mutate the prototype on assignment, + // letting predicates see values absent from the persisted snapshot. + if (key === "__proto__" || key === "prototype" || key === "constructor") { + continue; + } + const sanitized = sanitizeExternalEventPayload(entry, depth + 1); + if (sanitized !== undefined) { + out[key.slice(0, MAX_STRING)] = sanitized; + keys += 1; + } + } + return out; + } + // Functions, symbols, undefined — not representable. + return undefined; +}; diff --git a/apps/server/src/workflow/instructionPath.ts b/apps/server/src/workflow/instructionPath.ts new file mode 100644 index 00000000000..1e8a119f58e --- /dev/null +++ b/apps/server/src/workflow/instructionPath.ts @@ -0,0 +1,24 @@ +// @effect-diagnostics nodeBuiltinImport:off +import path from "node:path"; + +export const unsafeWorkflowInstructionPathMessage = (repoRelativePath: string): string => + `Instruction file path must be relative and stay within the project root: "${repoRelativePath}"`; + +export const isSafeWorkflowInstructionPath = (repoRelativePath: string): boolean => { + if (path.isAbsolute(repoRelativePath) || path.win32.isAbsolute(repoRelativePath)) { + return false; + } + + return !repoRelativePath.split(/[\\/]+/).some((segment) => segment === ".."); +}; + +export const resolveWorkflowInstructionPath = ( + repoRoot: string, + repoRelativePath: string, +): string | null => + isSafeWorkflowInstructionPath(repoRelativePath) ? path.resolve(repoRoot, repoRelativePath) : null; + +export const containsRealPath = (realRoot: string, realTarget: string): boolean => { + const relative = path.relative(realRoot, realTarget); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +}; diff --git a/apps/server/src/workflow/instructionTemplate.test.ts b/apps/server/src/workflow/instructionTemplate.test.ts new file mode 100644 index 00000000000..fdc96fe4821 --- /dev/null +++ b/apps/server/src/workflow/instructionTemplate.test.ts @@ -0,0 +1,144 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + applyInstructionTemplate, + renderTicketDiscussion, + unknownTicketPlaceholders, + type DiscussionMessage, +} from "./instructionTemplate.ts"; + +const vars = { + title: "Fix login bug", + description: "Users get logged out", + id: "ticket-42", + baseRef: "refs/t3/tickets/abc/base", + discussion: "(no discussion yet)", +}; + +describe("applyInstructionTemplate", () => { + it("substitutes known ticket placeholders", () => { + const result = applyInstructionTemplate( + "Review {{ticket.title}} ({{ticket.id}}): diff against {{ ticket.baseRef }}.", + vars, + ); + assert.equal( + result, + "Review Fix login bug (ticket-42): diff against refs/t3/tickets/abc/base.", + ); + }); + + it("substitutes description and tolerates repeated placeholders", () => { + const result = applyInstructionTemplate( + "{{ticket.description}} / {{ticket.description}}", + vars, + ); + assert.equal(result, "Users get logged out / Users get logged out"); + }); + + it("leaves unknown ticket placeholders literal", () => { + const result = applyInstructionTemplate("Check {{ticket.priority}}", vars); + assert.equal(result, "Check {{ticket.priority}}"); + }); + + it("ignores non-ticket handlebars text", () => { + const result = applyInstructionTemplate("Use {{value}} and {{ other.thing }}", vars); + assert.equal(result, "Use {{value}} and {{ other.thing }}"); + }); +}); + +describe("applyInstructionTemplate discussion", () => { + it("substitutes the discussion placeholder", () => { + const result = applyInstructionTemplate("Context:\n{{ticket.discussion}}", vars); + assert.equal(result, "Context:\n(no discussion yet)"); + }); +}); + +const message = (overrides: Partial): DiscussionMessage => ({ + author: "user", + body: "Looks good", + createdAt: "2026-06-09T10:00:00.000Z", + attachmentCount: 0, + ...overrides, +}); + +describe("renderTicketDiscussion", () => { + it("renders an empty string for no messages", () => { + assert.equal(renderTicketDiscussion([]), ""); + }); + + it("renders authors, timestamps, and bodies in order", () => { + const rendered = renderTicketDiscussion([ + message({ + author: "user", + body: "Use the retry helper", + createdAt: "2026-06-09T10:00:00.000Z", + }), + message({ author: "agent", body: "Will do", createdAt: "2026-06-09T10:05:00.000Z" }), + ]); + assert.equal( + rendered, + [ + "### User — 2026-06-09T10:00:00.000Z", + "Use the retry helper", + "", + "### Agent — 2026-06-09T10:05:00.000Z", + "Will do", + ].join("\n"), + ); + }); + + it("notes attachments without inlining them", () => { + const rendered = renderTicketDiscussion([ + message({ body: "See screenshot", attachmentCount: 2 }), + ]); + assert.include(rendered, "See screenshot"); + assert.include(rendered, "[2 attachments omitted]"); + }); + + it("notes a single attachment with singular wording", () => { + const rendered = renderTicketDiscussion([message({ attachmentCount: 1 })]); + assert.include(rendered, "[1 attachment omitted]"); + }); + + it("keeps only the newest messages past the message cap and flags truncation", () => { + const messages = Array.from({ length: 35 }, (_, index) => + message({ + body: `note ${index}`, + createdAt: `2026-06-09T10:00:${String(index).padStart(2, "0")}.000Z`, + }), + ); + const rendered = renderTicketDiscussion(messages); + assert.include(rendered, "_(earlier messages omitted)_"); + assert.notInclude(rendered, "note 4\n"); + assert.include(rendered, "note 34"); + assert.include(rendered, "note 5"); + }); + + it("keeps only the newest messages within the character budget", () => { + const big = "x".repeat(5000); + const messages = Array.from({ length: 6 }, (_, index) => + message({ body: `${big} tail-${index}`, createdAt: `2026-06-09T10:0${index}:00.000Z` }), + ); + const rendered = renderTicketDiscussion(messages); + assert.isAtMost(rendered.length, 13_000); + assert.include(rendered, "_(earlier messages omitted)_"); + assert.include(rendered, "tail-5"); + assert.notInclude(rendered, "tail-0"); + }); +}); + +describe("unknownTicketPlaceholders", () => { + it("reports unknown ticket fields once each", () => { + const unknown = unknownTicketPlaceholders( + "{{ticket.title}} {{ticket.priority}} {{ticket.priority}} {{ticket.owner.name}}", + ); + assert.deepEqual([...unknown].sort(), ["owner.name", "priority"]); + }); + + it("reports nothing for known fields or non-ticket braces", () => { + assert.deepEqual( + unknownTicketPlaceholders("{{ticket.title}} {{ticket.baseRef}} {{whatever}}"), + [], + ); + }); +}); diff --git a/apps/server/src/workflow/instructionTemplate.ts b/apps/server/src/workflow/instructionTemplate.ts new file mode 100644 index 00000000000..b24c8a9d334 --- /dev/null +++ b/apps/server/src/workflow/instructionTemplate.ts @@ -0,0 +1,90 @@ +/** + * Ticket-context placeholders usable inside agent step instructions. + * + * Only `{{ticket.}}` tokens participate in templating; any other + * `{{...}}` text is left untouched so instructions can freely contain + * handlebars-style examples. Unknown `ticket.*` fields are left literal at + * runtime and surfaced as lint errors at save time. + */ +export const TICKET_TEMPLATE_FIELDS = [ + "title", + "description", + "id", + "baseRef", + "discussion", +] as const; +export type TicketTemplateField = (typeof TICKET_TEMPLATE_FIELDS)[number]; + +export type TicketTemplateVars = Readonly>; + +const PLACEHOLDER_PATTERN = /\{\{\s*ticket\.([A-Za-z0-9_.]+)\s*\}\}/g; + +const isTemplateField = (field: string): field is TicketTemplateField => + (TICKET_TEMPLATE_FIELDS as ReadonlyArray).includes(field); + +export const applyInstructionTemplate = (instruction: string, vars: TicketTemplateVars): string => + instruction.replace(PLACEHOLDER_PATTERN, (match, field: string) => + isTemplateField(field) ? vars[field] : match, + ); + +export interface DiscussionMessage { + readonly author: "agent" | "user"; + readonly body: string; + readonly createdAt: string; + readonly attachmentCount: number; +} + +export const DISCUSSION_MESSAGE_CAP = 30; +const DISCUSSION_CHAR_BUDGET = 12_000; +const DISCUSSION_TRUNCATION_NOTE = "_(earlier messages omitted)_"; + +const renderDiscussionMessage = (message: DiscussionMessage): string => { + const author = message.author === "user" ? "User" : "Agent"; + const attachmentNote = + message.attachmentCount > 0 + ? `\n[${message.attachmentCount} attachment${message.attachmentCount === 1 ? "" : "s"} omitted]` + : ""; + return `### ${author} — ${message.createdAt}\n${message.body}${attachmentNote}`; +}; + +/** + * Render a ticket's message thread as a markdown transcript for agent + * instructions. Keeps the newest messages within a message count and + * character budget; attachments are noted, never inlined (they are data + * URLs). Returns the empty string when there is nothing to show. + */ +export const renderTicketDiscussion = (messages: ReadonlyArray): string => { + if (messages.length === 0) { + return ""; + } + const kept: string[] = []; + let used = 0; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const source = messages[index]; + const entry = source === undefined ? "" : renderDiscussionMessage(source); + if ( + kept.length >= DISCUSSION_MESSAGE_CAP || + (kept.length > 0 && used + entry.length > DISCUSSION_CHAR_BUDGET) + ) { + kept.unshift(DISCUSSION_TRUNCATION_NOTE); + break; + } + kept.unshift(entry); + used += entry.length + 2; + } + return kept.join("\n\n"); +}; + +export const hasDiscussionPlaceholder = (instruction: string): boolean => + /\{\{\s*ticket\.discussion\s*\}\}/.test(instruction); + +export const unknownTicketPlaceholders = (instruction: string): ReadonlyArray => { + const unknown = new Set(); + for (const match of instruction.matchAll(PLACEHOLDER_PATTERN)) { + const field = match[1]; + if (field !== undefined && !isTemplateField(field)) { + unknown.add(field); + } + } + return [...unknown]; +}; diff --git a/apps/server/src/workflow/jsonLogicRule.ts b/apps/server/src/workflow/jsonLogicRule.ts new file mode 100644 index 00000000000..45fcf3f55e6 --- /dev/null +++ b/apps/server/src/workflow/jsonLogicRule.ts @@ -0,0 +1,83 @@ +export const ALLOWED_JSON_LOGIC_OPERATORS = new Set([ + "==", + "!=", + ">", + ">=", + "<", + "<=", + "and", + "or", + "!", + "var", + "in", +] as const); + +export interface JsonLogicRuleIssue { + readonly message: string; +} + +export interface JsonLogicRuleInspection { + readonly variablePaths: ReadonlyArray; + readonly issues: ReadonlyArray; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const inspectNode = ( + node: unknown, + variablePaths: string[], + seenPaths: Set, + issues: JsonLogicRuleIssue[], +): void => { + if (Array.isArray(node)) { + for (const item of node) { + inspectNode(item, variablePaths, seenPaths, issues); + } + return; + } + if (!isRecord(node)) { + return; + } + + const entries = Object.entries(node); + if (entries.length !== 1) { + issues.push({ message: "JSONLogic rule objects must contain exactly one operator" }); + for (const value of Object.values(node)) { + inspectNode(value, variablePaths, seenPaths, issues); + } + return; + } + + const entry = entries[0]; + if (entry === undefined) { + return; + } + const [operator, operand] = entry; + if (!ALLOWED_JSON_LOGIC_OPERATORS.has(operator as never)) { + issues.push({ message: `unsupported JSONLogic operator: ${operator}` }); + inspectNode(operand, variablePaths, seenPaths, issues); + return; + } + + if (operator === "var") { + if (typeof operand !== "string") { + issues.push({ message: "JSONLogic var must be a string path without a default" }); + return; + } + if (!seenPaths.has(operand)) { + seenPaths.add(operand); + variablePaths.push(operand); + } + return; + } + + inspectNode(operand, variablePaths, seenPaths, issues); +}; + +export const inspectJsonLogicRule = (rule: unknown): JsonLogicRuleInspection => { + const variablePaths: string[] = []; + const issues: JsonLogicRuleIssue[] = []; + inspectNode(rule, variablePaths, new Set(), issues); + return { variablePaths, issues }; +}; diff --git a/apps/server/src/workflow/sampleBoardFile.test.ts b/apps/server/src/workflow/sampleBoardFile.test.ts new file mode 100644 index 00000000000..e67a28f2fde --- /dev/null +++ b/apps/server/src/workflow/sampleBoardFile.test.ts @@ -0,0 +1,33 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { WorkflowDefinition } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { lintWorkflowDefinition } from "./workflowFile.ts"; + +const decodeWorkflowDefinitionJson = Schema.decodeEffect(Schema.fromJsonString(WorkflowDefinition)); + +it.layer(NodeServices.layer)("sample delivery board", (it) => { + it.effect("decodes and lints for the default codex provider", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repoRoot = path.join(process.cwd(), "../.."); + const raw = yield* fileSystem.readFileString(path.join(repoRoot, ".t3/boards/delivery.json")); + const definition = yield* decodeWorkflowDefinitionJson(raw); + const lintErrors = lintWorkflowDefinition(definition, { + providerInstanceExists: (instanceId) => instanceId === "codex", + instructionFileExists: () => true, + }); + + assert.equal(definition.name, "Standard delivery"); + assert.deepEqual( + lintErrors.map((error) => error.code), + [], + ); + }), + ); +}); diff --git a/apps/server/src/workflow/ticketMessageBody.ts b/apps/server/src/workflow/ticketMessageBody.ts new file mode 100644 index 00000000000..9de90bc5e10 --- /dev/null +++ b/apps/server/src/workflow/ticketMessageBody.ts @@ -0,0 +1,13 @@ +export const MAX_TICKET_MESSAGE_BODY_LENGTH = 8_000; + +const TICKET_MESSAGE_TRUNCATION_SUFFIX = "..."; + +export function truncateTicketMessageBody(body: string): string { + if (body.length <= MAX_TICKET_MESSAGE_BODY_LENGTH) { + return body; + } + return `${body.slice( + 0, + MAX_TICKET_MESSAGE_BODY_LENGTH - TICKET_MESSAGE_TRUNCATION_SUFFIX.length, + )}${TICKET_MESSAGE_TRUNCATION_SUFFIX}`; +} diff --git a/apps/server/src/workflow/ticketRefs.test.ts b/apps/server/src/workflow/ticketRefs.test.ts new file mode 100644 index 00000000000..5df7c4ae6e8 --- /dev/null +++ b/apps/server/src/workflow/ticketRefs.test.ts @@ -0,0 +1,16 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { ticketBaseRef, ticketStepRef } from "./ticketRefs.ts"; + +describe("ticketRefs", () => { + it("builds a stable base ref", () => { + assert.equal(ticketBaseRef("t-1" as never), "refs/t3/tickets/dC0x/base"); + }); + + it("builds pre/post step refs", () => { + assert.equal( + ticketStepRef("t-1" as never, "sr-1" as never, "pre"), + "refs/t3/tickets/dC0x/step/c3ItMQ/pre", + ); + }); +}); diff --git a/apps/server/src/workflow/ticketRefs.ts b/apps/server/src/workflow/ticketRefs.ts new file mode 100644 index 00000000000..9a9902741e8 --- /dev/null +++ b/apps/server/src/workflow/ticketRefs.ts @@ -0,0 +1,20 @@ +import type { StepRunId, TicketId } from "@t3tools/contracts"; +import * as Encoding from "effect/Encoding"; + +export const TICKET_REFS_PREFIX = "refs/t3/tickets"; + +const encodeRefPart = (value: string) => Encoding.encodeBase64Url(value); + +export const ticketRefsPrefix = (ticketId: TicketId): string => + `${TICKET_REFS_PREFIX}/${encodeRefPart(ticketId as string)}`; + +export const ticketBaseRef = (ticketId: TicketId): string => `${ticketRefsPrefix(ticketId)}/base`; + +export const ticketStepRef = ( + ticketId: TicketId, + stepRunId: StepRunId, + kind: "pre" | "post", +): string => + `${TICKET_REFS_PREFIX}/${encodeRefPart(ticketId as string)}/step/${encodeRefPart( + stepRunId as string, + )}/${kind}`; diff --git a/apps/server/src/workflow/webhookRoute.ts b/apps/server/src/workflow/webhookRoute.ts new file mode 100644 index 00000000000..38df0a57477 --- /dev/null +++ b/apps/server/src/workflow/webhookRoute.ts @@ -0,0 +1,183 @@ +import * as Effect from "effect/Effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import { sanitizeExternalEventPayload } from "./externalEvent.ts"; +import { WorkflowEngine } from "./Services/WorkflowEngine.ts"; +import { WorkflowReadModel } from "./Services/WorkflowReadModel.ts"; +import { WorkflowWebhook } from "./Services/WorkflowWebhook.ts"; + +const MAX_BODY_BYTES = 64 * 1024; +const MAX_NAME_LENGTH = 100; +const MAX_DELIVERY_ID_LENGTH = 128; +const MAX_CORRELATION_LENGTH = 200; + +const notFound = HttpServerResponse.text("Not Found", { status: 404 }); +const unprocessable = (detail: string) => + HttpServerResponse.json({ error: detail }, { status: 422 }).pipe( + Effect.orElseSucceed(() => HttpServerResponse.text(detail, { status: 422 })), + ); + +interface ParsedHookBody { + readonly name: string; + readonly ticketId: string; + readonly payload: unknown; + readonly deliveryId: string | undefined; +} + +const parseHookBody = (raw: string): ParsedHookBody | string => { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return "body must be JSON"; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return "body must be a JSON object"; + } + const body = parsed as Record; + const name = typeof body["name"] === "string" ? body["name"].trim() : ""; + if (name === "" || name.length > MAX_NAME_LENGTH) { + return "name is required (1-100 chars)"; + } + const ticketId = typeof body["ticketId"] === "string" ? body["ticketId"].trim() : ""; + const branch = typeof body["branch"] === "string" ? body["branch"].trim() : ""; + if ((ticketId === "") === (branch === "")) { + return "exactly one of ticketId or branch is required"; + } + if (ticketId.length > MAX_CORRELATION_LENGTH || branch.length > MAX_CORRELATION_LENGTH) { + return "correlation value too long"; + } + let correlatedTicketId = ticketId; + if (branch !== "") { + const match = /^workflow\/(.+)$/.exec(branch); + if (match === null || match[1] === undefined) { + return 'branch must look like "workflow/"'; + } + correlatedTicketId = match[1]; + } + const rawDeliveryId = body["deliveryId"]; + if (rawDeliveryId !== undefined) { + if (typeof rawDeliveryId !== "string" || rawDeliveryId.length > MAX_DELIVERY_ID_LENGTH) { + return "deliveryId must be a string (max 128 chars)"; + } + } + return { + name, + ticketId: correlatedTicketId, + payload: sanitizeExternalEventPayload(body["payload"] ?? null), + deliveryId: typeof rawDeliveryId === "string" ? rawDeliveryId : undefined, + }; +}; + +/** + * Per-board webhook ingress: external systems (CI, PR automation, cron) POST + * events that move correlated tickets through their lane's onEvent matchers. + * Unknown board and bad token are indistinguishable (404, no oracle). + */ +export const workflowHooksRouteLayer = HttpRouter.add( + "POST", + "/hooks/workflow/:boardId", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (url._tag === "None") { + return HttpServerResponse.text("Bad Request", { status: 400 }); + } + const segments = url.value.pathname.split("/").filter((segment) => segment.length > 0); + const rawBoardId = segments[2] ?? ""; + let boardId: string; + try { + boardId = decodeURIComponent(rawBoardId); + } catch { + // Malformed percent-encoding — keep the no-oracle 404 discipline. + return notFound; + } + if (boardId === "" || boardId.length > MAX_CORRELATION_LENGTH) { + return notFound; + } + // Reject oversized bodies before buffering when the client declares a + // length; the post-read byte check below covers lying clients. + const declaredLength = Number(request.headers["content-length"] ?? "0"); + if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) { + return yield* unprocessable("body must be 1 byte to 64 KiB of JSON"); + } + + const headerToken = request.headers["x-t3-webhook-token"]; + const token = typeof headerToken === "string" ? headerToken : ""; + if (token === "") { + return notFound; + } + // Resolved optionally so servers composed without the workflow runtime + // (tests, trimmed deployments) simply 404 instead of failing to build. + const webhookOption = yield* Effect.serviceOption(WorkflowWebhook); + const engineOption = yield* Effect.serviceOption(WorkflowEngine); + const readModelOption = yield* Effect.serviceOption(WorkflowReadModel); + if ( + webhookOption._tag === "None" || + engineOption._tag === "None" || + readModelOption._tag === "None" + ) { + return notFound; + } + const webhook = webhookOption.value; + const validToken = yield* webhook + .verifyToken(boardId as never, token) + .pipe(Effect.orElseSucceed(() => false)); + if (!validToken) { + return notFound; + } + + const raw = yield* request.text.pipe(Effect.orElseSucceed(() => "")); + if (raw.length === 0 || Buffer.byteLength(raw, "utf8") > MAX_BODY_BYTES) { + return yield* unprocessable("body must be 1 byte to 64 KiB of JSON"); + } + const parsed = parseHookBody(raw); + if (typeof parsed === "string") { + return yield* unprocessable(parsed); + } + + // Board must exist and own the ticket; the engine re-verifies, but a + // cheap read keeps error shapes clean. + const board = yield* readModelOption.value + .getBoard(boardId as never) + .pipe(Effect.orElseSucceed(() => null)); + if (board === null) { + return notFound; + } + + if (parsed.deliveryId !== undefined) { + // Fail closed: if dedupe state cannot be recorded, a retried delivery + // could route twice — surface a retryable 5xx instead of ingesting. + const recorded = yield* webhook + .recordDelivery(boardId as never, parsed.deliveryId) + .pipe(Effect.result); + if (recorded._tag === "Failure") { + return HttpServerResponse.text("delivery could not be recorded", { status: 503 }); + } + if (recorded.success) { + return yield* HttpServerResponse.json({ outcome: "duplicate" }, { status: 202 }).pipe( + Effect.orElseSucceed(() => HttpServerResponse.text("duplicate", { status: 202 })), + ); + } + } + + const result = yield* engineOption.value + .ingestExternalEvent({ + boardId: boardId as never, + name: parsed.name, + ticketId: parsed.ticketId as never, + payload: parsed.payload, + }) + .pipe(Effect.result); + if (result._tag === "Failure") { + return yield* unprocessable("event could not be correlated to a ticket on this board"); + } + return yield* HttpServerResponse.json( + { + outcome: result.success.outcome, + ...(result.success.toLane === undefined ? {} : { toLane: result.success.toLane }), + }, + { status: 202 }, + ).pipe(Effect.orElseSucceed(() => HttpServerResponse.text("accepted", { status: 202 }))); + }), +); diff --git a/apps/server/src/workflow/workflowFile.test.ts b/apps/server/src/workflow/workflowFile.test.ts new file mode 100644 index 00000000000..23d6bbc55a3 --- /dev/null +++ b/apps/server/src/workflow/workflowFile.test.ts @@ -0,0 +1,666 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + WorkflowDefinition, + type WorkflowDefinition as WorkflowDefinitionType, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { encodeWorkflowDefinitionJson, lintWorkflowDefinition } from "./workflowFile.ts"; + +const base = (lanes: unknown): WorkflowDefinitionType => + ({ name: "wf", lanes }) as unknown as WorkflowDefinitionType; + +const ctx = { + providerInstanceExists: (id: string) => id === "claude_main", + instructionFileExists: (path: string) => path === "prompts/ok.md", +}; + +const decodeWorkflowDefinition = Schema.decodeUnknownEffect(WorkflowDefinition); +const decodeWorkflowDefinitionJson = Schema.decodeEffect(Schema.fromJsonString(WorkflowDefinition)); + +describe("lintWorkflowDefinition", () => { + it.effect("exports an encoder that serializes decodable workflow JSON", () => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition( + base([ + { + key: "implement", + name: "Implement", + entry: "auto", + pipeline: [{ key: "tests", type: "script", run: "pnpm test", timeout: "5 minutes" }], + }, + ]), + ); + const contents = encodeWorkflowDefinitionJson(definition); + const decoded = yield* decodeWorkflowDefinitionJson(contents); + assert.equal(decoded.name, "wf"); + assert.equal((decoded.lanes[0]?.pipeline?.[0] as any)?.type, "script"); + }), + ); + + it("passes a valid definition", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "s", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: { file: "prompts/ok.md" }, + }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ctx, + ); + assert.deepEqual(errors, []); + }); + + it("flags duplicate lane keys", () => { + const errors = lintWorkflowDefinition( + base([ + { key: "a", name: "A", entry: "manual" }, + { key: "a", name: "A2", entry: "manual" }, + ]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "duplicate_lane_key")); + }); + + it("flags routing to a missing lane", () => { + const errors = lintWorkflowDefinition( + base([{ key: "a", name: "A", entry: "auto", on: { success: "ghost" } }]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "missing_lane_ref")); + }); + + it("flags step routing and transition targets that reference missing lanes", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "review", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "hi", + on: { failure: "missing-step-target" }, + }, + ], + transitions: [{ when: { "==": [{ var: "pipeline.result" }, "success"] }, to: "ghost" }], + }, + ]), + ctx, + ); + + assert.isTrue( + errors.some( + (e) => + e.code === "missing_lane_ref" && + e.stepKey === "review" && + e.message.includes("missing-step-target"), + ), + ); + assert.isTrue( + errors.some( + (e) => + e.code === "missing_lane_ref" && + e.message.includes("ghost") && + (e as any).transitionIndex === 0, + ), + ); + }); + + it("accepts well-formed predicate paths and explicit step precedence", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "tests", + type: "script", + run: "pnpm test", + on: { failure: "needs" }, + }, + { + key: "review", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "hi", + captureOutput: true, + }, + ], + transitions: [ + { + when: { + and: [ + { "!=": [{ var: "steps.tests.exitCode" }, 0] }, + { "==": [{ var: "steps.review.output.verdict" }, "block"] }, + { in: [{ var: "pipeline.result" }, ["success", "failure"]] }, + { "!": { var: "status" } }, + ], + }, + to: "needs", + }, + ], + on: { success: "done", failure: "needs" }, + }, + { key: "needs", name: "Needs", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ctx, + ); + + assert.deepEqual(errors, []); + }); + + it("flags disallowed predicate operators and invalid var forms", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [{ key: "tests", type: "script", run: "pnpm test" }], + transitions: [ + { when: { cat: ["a", "b"] }, to: "done" }, + { when: { var: ["steps.tests.exitCode", 0] }, to: "done" }, + { when: { var: 123 }, to: "done" }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ctx, + ); + + assert.deepEqual( + errors.filter((e) => e.code === "invalid_json_logic").map((e) => (e as any).transitionIndex), + [0, 1, 2], + ); + }); + + it("flags unknown and ill-typed predicate paths", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "review", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "hi", + }, + { key: "approval", type: "approval", prompt: "Ship?" }, + ], + transitions: [ + { when: { var: "steps.missing.status" }, to: "done" }, + { when: { var: "steps.review.exitCode" }, to: "done" }, + { when: { var: "steps.review.output.verdict" }, to: "done" }, + { when: { var: "steps.approval.output.verdict" }, to: "done" }, + { when: { var: "pipeline.unknown" }, to: "done" }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ctx, + ); + + assert.deepEqual( + errors + .filter((e) => e.code === "unknown_predicate_path") + .map((e) => (e as any).transitionIndex), + [0, 1, 2, 3, 4], + ); + }); + + it("flags path-unsafe step keys when predicates are present", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "bad.key", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "hi", + captureOutput: true, + }, + ], + transitions: [{ when: { var: "steps.bad.key.output.verdict" }, to: "done" }], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ctx, + ); + + assert.isTrue( + errors.some( + (e) => + e.code === "unsafe_step_key" && + e.stepKey === "bad.key" && + (e as any).transitionIndex === 0, + ), + ); + }); + + it("flags an unknown provider instance", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "s", + type: "agent", + agent: { instance: "nope", model: "x" }, + instruction: "hi", + }, + ], + }, + ]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "unknown_provider_instance")); + }); + + it("flags a missing instruction file", () => { + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "s", + type: "agent", + agent: { instance: "claude_main", model: "x" }, + instruction: { file: "prompts/missing.md" }, + }, + ], + }, + ]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "missing_instruction_file")); + }); + + it("flags unsafe instruction file paths before checking file existence", () => { + let existenceChecks = 0; + const errors = lintWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { + key: "s", + type: "agent", + agent: { instance: "claude_main", model: "x" }, + instruction: { file: "../escape.md" }, + }, + ], + }, + ]), + { + providerInstanceExists: ctx.providerInstanceExists, + instructionFileExists: () => { + existenceChecks += 1; + return true; + }, + }, + ); + + assert.deepEqual( + errors.map((error) => ({ + code: error.code, + laneKey: error.laneKey, + stepKey: error.stepKey, + })), + [{ code: "unsafe_instruction_path", laneKey: "a", stepKey: "s" }], + ); + assert.equal(existenceChecks, 0); + }); + + it("flags an auto-lane cycle with no human/terminal break", () => { + const errors = lintWorkflowDefinition( + base([ + { key: "a", name: "A", entry: "auto", on: { success: "b" } }, + { key: "b", name: "B", entry: "auto", on: { success: "a" } }, + ]), + ctx, + ); + assert.isTrue(errors.some((e) => e.code === "auto_lane_cycle")); + }); + + it("flags invalid WIP limits and accepts positive limits on non-terminal lanes", () => { + const invalidErrors = lintWorkflowDefinition( + base([ + { key: "zero", name: "Zero", entry: "manual", wipLimit: 0 }, + { key: "done", name: "Done", entry: "manual", terminal: true, wipLimit: 1 }, + ]), + ctx, + ); + + assert.deepEqual( + invalidErrors + .filter((error) => error.code === "invalid_wip_limit") + .map((error) => error.laneKey), + ["zero", "done"], + ); + + const validErrors = lintWorkflowDefinition( + base([ + { key: "backlog", name: "Backlog", entry: "manual", wipLimit: 2 }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ctx, + ); + assert.deepEqual(validErrors, []); + }); + + it.effect("accepts retention only on terminal lanes with positive duration", () => + Effect.gen(function* () { + const valid = yield* decodeWorkflowDefinition( + base([ + { key: "backlog", name: "Backlog", entry: "manual" }, + { + key: "done", + name: "Done", + entry: "manual", + terminal: true, + retention: "7 days", + }, + ]), + ); + assert.deepEqual(lintWorkflowDefinition(valid, ctx), []); + + const nonTerminal = yield* decodeWorkflowDefinition( + base([ + { + key: "backlog", + name: "Backlog", + entry: "manual", + retention: "7 days", + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ); + assert.deepEqual( + lintWorkflowDefinition(nonTerminal, ctx).map((error) => error.code), + ["invalid_retention"], + ); + + const zeroRetention = yield* decodeWorkflowDefinition( + base([ + { + key: "done", + name: "Done", + entry: "manual", + terminal: true, + retention: "0 millis", + }, + ]), + ); + assert.deepEqual( + lintWorkflowDefinition(zeroRetention, ctx).map((error) => error.code), + ["invalid_retention"], + ); + }), + ); +}); + +describe("lintWorkflowDefinition retry + templates", () => { + const agentStep = (retry?: unknown, instruction: unknown = "Do the work.") => ({ + key: "s", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction, + ...(retry === undefined ? {} : { retry }), + }); + + const lintLane = (pipeline: ReadonlyArray) => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition( + base([{ key: "a", name: "A", entry: "manual", pipeline }]), + ); + return lintWorkflowDefinition(definition, ctx); + }); + + it.effect("accepts retry within 2..5 on agent and script steps", () => + Effect.gen(function* () { + const errors = yield* lintLane([ + agentStep({ maxAttempts: 3, escalate: { model: "opus" } }), + { key: "t", type: "script", run: "pnpm test", retry: { maxAttempts: 2 } }, + ]); + assert.deepEqual(errors, []); + }), + ); + + it.effect("rejects maxAttempts outside 2..5", () => + Effect.gen(function* () { + const tooLow = yield* lintLane([agentStep({ maxAttempts: 1 })]); + assert.deepEqual( + tooLow.map((error) => error.code), + ["invalid_retry"], + ); + + const tooHigh = yield* lintLane([agentStep({ maxAttempts: 6 })]); + assert.deepEqual( + tooHigh.map((error) => error.code), + ["invalid_retry"], + ); + }), + ); + + it.effect("rejects escalation on script steps", () => + Effect.gen(function* () { + const errors = yield* lintLane([ + { + key: "t", + type: "script", + run: "pnpm test", + retry: { maxAttempts: 2, escalate: { model: "opus" } }, + }, + ]); + assert.deepEqual( + errors.map((error) => error.code), + ["invalid_retry"], + ); + }), + ); + + it.effect("rejects unknown escalation provider instances", () => + Effect.gen(function* () { + const errors = yield* lintLane([ + agentStep({ maxAttempts: 2, escalate: { instance: "nope" } }), + ]); + assert.deepEqual( + errors.map((error) => error.code), + ["unknown_provider_instance"], + ); + assert.match(errors[0]?.message ?? "", /retry escalation/); + }), + ); + + it.effect("accepts known ticket placeholders in inline instructions", () => + Effect.gen(function* () { + const errors = yield* lintLane([ + agentStep( + undefined, + "Review {{ticket.title}} ({{ticket.id}}): {{ticket.description}} vs {{ticket.baseRef}} and {{not.a.template}}", + ), + ]); + assert.deepEqual(errors, []); + }), + ); + + it.effect("flags unknown ticket placeholders in inline instructions", () => + Effect.gen(function* () { + const errors = yield* lintLane([agentStep(undefined, "Check {{ticket.priority}}")]); + assert.deepEqual( + errors.map((error) => error.code), + ["unknown_template_placeholder"], + ); + assert.match(errors[0]?.message ?? "", /ticket\.priority/); + }), + ); +}); + +describe("lintWorkflowDefinition file instruction templates", () => { + it.effect("flags unknown placeholders inside instruction files when content is available", () => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "manual", + pipeline: [ + { + key: "s", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: { file: "prompts/ok.md" }, + }, + ], + }, + ]), + ); + + const withBadContent = lintWorkflowDefinition(definition, { + ...ctx, + readInstructionFile: (path) => + path === "prompts/ok.md" ? "Review {{ticket.titel}}" : null, + }); + assert.deepEqual( + withBadContent.map((error) => error.code), + ["unknown_template_placeholder"], + ); + + const withGoodContent = lintWorkflowDefinition(definition, { + ...ctx, + readInstructionFile: (path) => + path === "prompts/ok.md" ? "Review {{ticket.title}} vs {{ticket.baseRef}}" : null, + }); + assert.deepEqual(withGoodContent, []); + + const withoutContent = lintWorkflowDefinition(definition, ctx); + assert.deepEqual(withoutContent, []); + }), + ); +}); + +describe("lintWorkflowDefinition auto self-loop bounds", () => { + const selfLoopLane = (when: unknown) => [ + { + key: "impl", + name: "Impl", + entry: "auto", + pipeline: [ + { + key: "review", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "review", + captureOutput: true, + }, + ], + transitions: [{ when, to: "impl" }], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]; + + it.effect("rejects unbounded auto self-transitions", () => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition( + base(selfLoopLane({ "==": [{ var: "steps.review.output.verdict" }, "revise"] })), + ); + const errors = lintWorkflowDefinition(definition, ctx); + assert.deepEqual( + errors.map((error) => error.code), + ["auto_lane_cycle"], + ); + }), + ); + + it.effect("accepts auto self-transitions bounded by lane.runCount", () => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition( + base( + selfLoopLane({ + and: [ + { "==": [{ var: "steps.review.output.verdict" }, "revise"] }, + { "<": [{ var: "lane.runCount" }, 3] }, + ], + }), + ), + ); + assert.deepEqual(lintWorkflowDefinition(definition, ctx), []); + }), + ); +}); + +describe("lintWorkflowDefinition lane actions", () => { + it.effect("rejects actions targeting missing lanes", () => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition( + base([ + { + key: "review", + name: "Review", + entry: "manual", + actions: [{ label: "Land it", to: "nope" }], + }, + ]), + ); + const errors = lintWorkflowDefinition(definition, ctx); + assert.deepEqual( + errors.map((error) => error.code), + ["missing_lane_ref"], + ); + assert.match(errors[0]?.message ?? "", /Land it/); + }), + ); + + it.effect("accepts actions targeting real lanes", () => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition( + base([ + { + key: "review", + name: "Review", + entry: "manual", + actions: [{ label: "Land it", to: "done", hint: "Merge the work." }], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ); + assert.deepEqual(lintWorkflowDefinition(definition, ctx), []); + }), + ); +}); diff --git a/apps/server/src/workflow/workflowFile.ts b/apps/server/src/workflow/workflowFile.ts new file mode 100644 index 00000000000..b7de39c6e14 --- /dev/null +++ b/apps/server/src/workflow/workflowFile.ts @@ -0,0 +1,444 @@ +import { WorkflowDefinition, type WorkflowLane, type WorkflowStep } from "@t3tools/contracts"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import * as Duration from "effect/Duration"; +import * as Schema from "effect/Schema"; + +import { + isSafeWorkflowInstructionPath, + unsafeWorkflowInstructionPathMessage, +} from "./instructionPath.ts"; +import { unknownTicketPlaceholders } from "./instructionTemplate.ts"; +import { inspectJsonLogicRule } from "./jsonLogicRule.ts"; + +export type LintCode = + | "duplicate_lane_key" + | "duplicate_step_key" + | "missing_lane_ref" + | "unknown_provider_instance" + | "missing_instruction_file" + | "unsafe_instruction_path" + | "auto_lane_cycle" + | "unreachable_terminal" + | "invalid_wip_limit" + | "invalid_json_logic" + | "unknown_predicate_path" + | "unsafe_step_key" + | "invalid_retention" + | "invalid_retry" + | "invalid_panel" + | "unknown_template_placeholder"; + +export interface LintError { + readonly code: LintCode; + readonly message: string; + readonly laneKey?: string; + readonly stepKey?: string; + readonly transitionIndex?: number; +} + +export interface LintContext { + readonly providerInstanceExists: (instanceId: string) => boolean; + readonly instructionFileExists: (repoRelativePath: string) => boolean; + // Returns the contents of an existing instruction file so template + // placeholders inside it can be linted; null/absent skips that check. + readonly readInstructionFile?: (repoRelativePath: string) => string | null; +} + +const routingTargets = (lane: WorkflowLane): ReadonlyArray => { + const on = lane.on; + if (!on) { + return []; + } + return [on.success, on.failure, on.blocked].flatMap((target) => + target === undefined ? [] : [target as string], + ); +}; + +const stepRoutingTargets = (step: WorkflowStep): ReadonlyArray => { + const on = step.on; + if (!on) { + return []; + } + return [on.success, on.failure, on.blocked].flatMap((target) => + target === undefined ? [] : [target as string], + ); +}; + +export const encodeWorkflowDefinitionJson = Schema.encodeSync( + fromJsonStringPretty(WorkflowDefinition), +); + +export const MIN_STEP_RETRY_ATTEMPTS = 2; +export const MAX_STEP_RETRY_ATTEMPTS = 5; + +const PATH_SAFE_STEP_KEY = /^[A-Za-z0-9_-]+$/; + +const isReferencedStepPath = (path: string, stepKey: string) => + path === `steps.${stepKey}` || path.startsWith(`steps.${stepKey}.`); + +const predicatePathError = ( + path: string, + stepsByKey: ReadonlyMap, +): string | null => { + if (path === "status" || path === "pipeline.result" || path === "lane.runCount") { + return null; + } + if (path.startsWith("pipeline.") || path.startsWith("lane.")) { + return `Unknown predicate path "${path}"`; + } + + const parts = path.split("."); + if (parts[0] !== "steps" || parts[1] === undefined || parts[1] === "") { + return `Unknown predicate path "${path}"`; + } + + const step = stepsByKey.get(parts[1]); + if (!step) { + return `Unknown predicate path "${path}"`; + } + + const field = parts[2]; + if (field === undefined) { + return null; + } + if (field === "status") { + return parts.length === 3 ? null : `Unknown predicate path "${path}"`; + } + if (field === "exitCode") { + return step.type === "script" && parts.length === 3 + ? null + : `Predicate path "${path}" can only read exitCode from script steps`; + } + if (field === "output") { + return step.type === "agent" && step.captureOutput === true + ? null + : `Predicate path "${path}" can only read output from captureOutput agent steps`; + } + + return `Unknown predicate path "${path}"`; +}; + +export const lintWorkflowDefinition = ( + def: WorkflowDefinition, + ctx: LintContext, +): ReadonlyArray => { + const errors: LintError[] = []; + const laneKeys = new Set(); + const allKeys = new Set(def.lanes.map((lane) => lane.key as string)); + + for (const lane of def.lanes) { + const laneKey = lane.key as string; + if (laneKeys.has(laneKey)) { + errors.push({ + code: "duplicate_lane_key", + laneKey, + message: `Duplicate lane key "${laneKey}"`, + }); + } + laneKeys.add(laneKey); + + if (lane.wipLimit !== undefined) { + if (lane.wipLimit < 1) { + errors.push({ + code: "invalid_wip_limit", + laneKey, + message: `Lane "${laneKey}" wipLimit must be at least 1`, + }); + } + if (lane.terminal === true) { + errors.push({ + code: "invalid_wip_limit", + laneKey, + message: `Terminal lane "${laneKey}" cannot define a wipLimit`, + }); + } + } + + if (lane.retention !== undefined) { + if (lane.terminal !== true) { + errors.push({ + code: "invalid_retention", + laneKey, + message: `Lane "${laneKey}" retention is only valid on terminal lanes`, + }); + } + if (Duration.toMillis(lane.retention) <= 0) { + errors.push({ + code: "invalid_retention", + laneKey, + message: `Terminal lane "${laneKey}" retention must be a positive duration`, + }); + } + } + + const stepKeys = new Set(); + const stepsByKey = new Map(); + for (const step of lane.pipeline ?? []) { + const stepKey = step.key as string; + if (stepKeys.has(stepKey)) { + errors.push({ + code: "duplicate_step_key", + laneKey, + stepKey, + message: `Duplicate step key "${stepKey}" in lane "${laneKey}"`, + }); + } + stepKeys.add(stepKey); + stepsByKey.set(stepKey, step); + + for (const target of stepRoutingTargets(step)) { + if (!allKeys.has(target)) { + errors.push({ + code: "missing_lane_ref", + laneKey, + stepKey, + message: `Step "${stepKey}" in lane "${laneKey}" routes to missing lane "${target}"`, + }); + } + } + + if (step.type === "agent" && step.panel !== undefined) { + if (step.panel < 2 || step.panel > 5) { + errors.push({ + code: "invalid_panel", + laneKey, + stepKey, + message: `Step "${stepKey}" panel must be between 2 and 5 reviewers`, + }); + } + if (step.captureOutput !== true) { + errors.push({ + code: "invalid_panel", + laneKey, + stepKey, + message: `Step "${stepKey}" panel requires captureOutput so verdicts can be compared`, + }); + } + } + + if ((step.type === "agent" || step.type === "script") && step.retry !== undefined) { + if ( + step.retry.maxAttempts < MIN_STEP_RETRY_ATTEMPTS || + step.retry.maxAttempts > MAX_STEP_RETRY_ATTEMPTS + ) { + errors.push({ + code: "invalid_retry", + laneKey, + stepKey, + message: `Step "${stepKey}" retry maxAttempts must be between ${MIN_STEP_RETRY_ATTEMPTS} and ${MAX_STEP_RETRY_ATTEMPTS}`, + }); + } + if (step.type === "script" && step.retry.escalate !== undefined) { + errors.push({ + code: "invalid_retry", + laneKey, + stepKey, + message: `Script step "${stepKey}" cannot define a retry escalation`, + }); + } + if ( + step.type === "agent" && + step.retry.escalate?.instance !== undefined && + !ctx.providerInstanceExists(step.retry.escalate.instance) + ) { + errors.push({ + code: "unknown_provider_instance", + laneKey, + stepKey, + message: `Unknown provider instance "${step.retry.escalate.instance}" in retry escalation`, + }); + } + } + + if (step.type !== "agent") { + continue; + } + + const instructionText = + typeof step.instruction === "string" + ? step.instruction + : (ctx.readInstructionFile?.(step.instruction.file) ?? null); + if (instructionText !== null) { + for (const placeholder of unknownTicketPlaceholders(instructionText)) { + errors.push({ + code: "unknown_template_placeholder", + laneKey, + stepKey, + message: `Step "${stepKey}" instruction references unknown placeholder "{{ticket.${placeholder}}}"`, + }); + } + } + + if (!ctx.providerInstanceExists(step.agent.instance)) { + errors.push({ + code: "unknown_provider_instance", + laneKey, + stepKey, + message: `Unknown provider instance "${step.agent.instance}"`, + }); + } + + if (typeof step.instruction === "object") { + if (!isSafeWorkflowInstructionPath(step.instruction.file)) { + errors.push({ + code: "unsafe_instruction_path", + laneKey, + stepKey, + message: unsafeWorkflowInstructionPathMessage(step.instruction.file), + }); + } else if (!ctx.instructionFileExists(step.instruction.file)) { + errors.push({ + code: "missing_instruction_file", + laneKey, + stepKey, + message: `Instruction file not found: "${step.instruction.file}"`, + }); + } + } + } + + for (const target of routingTargets(lane)) { + if (!allKeys.has(target)) { + errors.push({ + code: "missing_lane_ref", + laneKey, + message: `Lane "${laneKey}" routes to missing lane "${target}"`, + }); + } + } + + for (const action of lane.actions ?? []) { + if (!allKeys.has(action.to as string)) { + errors.push({ + code: "missing_lane_ref", + laneKey, + message: `Lane "${laneKey}" action "${action.label}" targets missing lane "${action.to}"`, + }); + } + } + + for (const [eventIndex, eventMatcher] of (lane.onEvent ?? []).entries()) { + if (!allKeys.has(eventMatcher.to as string)) { + errors.push({ + code: "missing_lane_ref", + laneKey, + message: `Lane "${laneKey}" onEvent ${eventIndex} ("${eventMatcher.name}") targets missing lane "${eventMatcher.to}"`, + }); + } + if (eventMatcher.when !== undefined) { + const inspection = inspectJsonLogicRule(eventMatcher.when); + for (const issue of inspection.issues) { + errors.push({ + code: "invalid_json_logic", + laneKey, + message: `Lane "${laneKey}" onEvent ${eventIndex}: ${issue.message}`, + }); + } + // Event predicates see only the inbound event — not pipeline state. + for (const path of inspection.variablePaths) { + if ( + path !== "event.name" && + path !== "event.payload" && + !path.startsWith("event.payload.") + ) { + errors.push({ + code: "unknown_predicate_path", + laneKey, + message: `Lane "${laneKey}" onEvent ${eventIndex}: unknown predicate path "${path}" (event predicates may read event.name and event.payload.*)`, + }); + } + } + } + } + + for (const [transitionIndex, transition] of (lane.transitions ?? []).entries()) { + if (!allKeys.has(transition.to as string)) { + errors.push({ + code: "missing_lane_ref", + laneKey, + transitionIndex, + message: `Lane "${laneKey}" transition ${transitionIndex} routes to missing lane "${transition.to}"`, + }); + } + + const inspection = inspectJsonLogicRule(transition.when); + for (const issue of inspection.issues) { + errors.push({ + code: "invalid_json_logic", + laneKey, + transitionIndex, + message: `Lane "${laneKey}" transition ${transitionIndex}: ${issue.message}`, + }); + } + + // An auto lane that transitions back into itself re-runs its pipeline + // every time the predicate matches; without lane.runCount in the + // predicate that loop has no bound and burns agent runs forever. + if ( + lane.entry === "auto" && + (transition.to as string) === laneKey && + !inspection.variablePaths.includes("lane.runCount") + ) { + errors.push({ + code: "auto_lane_cycle", + laneKey, + transitionIndex, + message: `Auto lane "${laneKey}" transitions to itself without bounding the loop on lane.runCount`, + }); + } + + for (const path of inspection.variablePaths) { + for (const step of lane.pipeline ?? []) { + const stepKey = step.key as string; + if (!PATH_SAFE_STEP_KEY.test(stepKey) && isReferencedStepPath(path, stepKey)) { + errors.push({ + code: "unsafe_step_key", + laneKey, + stepKey, + transitionIndex, + message: `Step key "${stepKey}" must match [A-Za-z0-9_-]+ to be used in predicate paths`, + }); + } + } + + const message = predicatePathError(path, stepsByKey); + if (message !== null) { + errors.push({ + code: "unknown_predicate_path", + laneKey, + transitionIndex, + message, + }); + } + } + } + } + + const byKey = new Map( + def.lanes.map((lane) => [lane.key as string, lane] as const), + ); + for (const lane of def.lanes) { + if (lane.entry !== "auto") { + continue; + } + + const seen = new Set(); + let cursor: WorkflowLane | undefined = lane; + while (cursor && cursor.entry === "auto" && !cursor.terminal) { + const cursorKey = cursor.key as string; + if (seen.has(cursorKey)) { + errors.push({ + code: "auto_lane_cycle", + laneKey: lane.key as string, + message: `Auto-lane cycle detected starting at "${lane.key}"`, + }); + break; + } + seen.add(cursorKey); + const next = cursor.on?.success as string | undefined; + cursor = next ? byKey.get(next) : undefined; + } + } + + return errors; +}; diff --git a/apps/server/src/workflow/workflowVersionHash.ts b/apps/server/src/workflow/workflowVersionHash.ts new file mode 100644 index 00000000000..3eb2c02da58 --- /dev/null +++ b/apps/server/src/workflow/workflowVersionHash.ts @@ -0,0 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { createHash } from "node:crypto"; + +export const sha256Hex = (value: string) => createHash("sha256").update(value).digest("hex"); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index 9b93b1e863b..0b0e3f14c39 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -136,5 +136,215 @@ it.layer(TestLayer)("WorkspaceFileSystemLive", (it) => { expect(escapedStat).toBeNull(); }), ); + + it.effect( + "rejects board file writes when the board path is a symlink outside the workspace", + () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const outsidePath = path.join(outsideDir, "outside-board.json"); + const boardPath = path.join(cwd, ".t3/boards/foo.json"); + + yield* fileSystem.makeDirectory(path.dirname(boardPath), { recursive: true }); + yield* fileSystem.writeFileString(outsidePath, '{"name":"outside-before"}\n'); + yield* fileSystem.symlink(outsidePath, boardPath); + + const error = yield* workspaceFileSystem + .writeFile({ + cwd, + relativePath: ".t3/boards/foo.json", + contents: '{"name":"outside-after"}\n', + }) + .pipe(Effect.flip); + + expect(error._tag).toBe("WorkspaceFileSystemError"); + const outside = yield* fileSystem.readFileString(outsidePath); + expect(outside).toBe('{"name":"outside-before"}\n'); + }), + ); + + it.effect("writes normal board files under the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + yield* workspaceFileSystem.writeFile({ + cwd, + relativePath: ".t3/boards/foo.json", + contents: '{"name":"inside"}\n', + }); + + const saved = yield* fileSystem.readFileString(path.join(cwd, ".t3/boards/foo.json")); + expect(saved).toBe('{"name":"inside"}\n'); + }), + ); + + it.effect("createFileExclusive creates once and rejects an existing file", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const created = yield* workspaceFileSystem.createFileExclusive({ + projectRoot: cwd, + relativePath: ".t3/boards/workflow-board.json", + contents: "{}\n", + }); + expect(created).toEqual({ relativePath: ".t3/boards/workflow-board.json" }); + + const error = yield* workspaceFileSystem + .createFileExclusive({ + projectRoot: cwd, + relativePath: ".t3/boards/workflow-board.json", + contents: '{"overwritten":true}\n', + }) + .pipe(Effect.flip); + expect(error._tag).toBe("WorkspaceFileSystemError"); + if (error._tag === "WorkspaceFileSystemError") { + expect(error.operation).toBe("workspaceFileSystem.createFileExclusive"); + } + + const saved = yield* fileSystem.readFileString( + path.join(cwd, ".t3/boards/workflow-board.json"), + ); + expect(saved).toBe("{}\n"); + }), + ); + }); + + describe("deleteFile", () => { + it.effect("deletes files relative to the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const boardPath = path.join(cwd, ".t3/boards/delete-me.json"); + + yield* writeTextFile(cwd, ".t3/boards/delete-me.json", "{}\n"); + yield* workspaceFileSystem.deleteFile({ + cwd, + relativePath: ".t3/boards/delete-me.json", + }); + + const stat = yield* fileSystem.stat(boardPath).pipe(Effect.orElseSucceed(() => null)); + expect(stat).toBeNull(); + }), + ); + + it.effect("treats missing files as successful deletes", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + + yield* workspaceFileSystem.deleteFile({ + cwd, + relativePath: ".t3/boards/already-gone.json", + }); + }), + ); + + it.effect("rejects deletes outside the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + + const error = yield* workspaceFileSystem + .deleteFile({ + cwd, + relativePath: "../escape.md", + }) + .pipe(Effect.flip); + + expect(error.message).toContain( + "Workspace file path must be relative to the project root: ../escape.md", + ); + }), + ); + + it.effect( + "rejects board file deletes when the board path is a symlink outside the workspace", + () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const outsidePath = path.join(outsideDir, "outside-board.json"); + const boardPath = path.join(cwd, ".t3/boards/foo.json"); + + yield* fileSystem.makeDirectory(path.dirname(boardPath), { recursive: true }); + yield* fileSystem.writeFileString(outsidePath, '{"name":"outside"}\n'); + yield* fileSystem.symlink(outsidePath, boardPath); + + const error = yield* workspaceFileSystem + .deleteFile({ + cwd, + relativePath: ".t3/boards/foo.json", + }) + .pipe(Effect.flip); + + expect(error._tag).toBe("WorkspaceFileSystemError"); + const outside = yield* fileSystem.readFileString(outsidePath); + expect(outside).toBe('{"name":"outside"}\n'); + const symlinkTarget = yield* fileSystem.readFileString(boardPath); + expect(symlinkTarget).toBe('{"name":"outside"}\n'); + }), + ); + + it.effect("deletes dangling symlinks whose entries are inside the workspace", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const boardPath = path.join(cwd, ".t3/boards/dangling.json"); + const missingTarget = path.join(cwd, ".t3/boards/missing-target.json"); + + yield* fileSystem.makeDirectory(path.dirname(boardPath), { recursive: true }); + yield* fileSystem.symlink(missingTarget, boardPath); + + yield* workspaceFileSystem.deleteFile({ + cwd, + relativePath: ".t3/boards/dangling.json", + }); + + const linkTarget = yield* fileSystem + .readLink(boardPath) + .pipe(Effect.orElseSucceed(() => null)); + expect(linkTarget).toBeNull(); + }), + ); + + it.effect("deletes in-workspace symlink loops by unlinking the entry", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const boardPath = path.join(cwd, ".t3/boards/loop.json"); + + yield* fileSystem.makeDirectory(path.dirname(boardPath), { recursive: true }); + yield* fileSystem.symlink(boardPath, boardPath); + + yield* workspaceFileSystem.deleteFile({ + cwd, + relativePath: ".t3/boards/loop.json", + }); + + const symlinkTarget = yield* fileSystem + .readLink(boardPath) + .pipe(Effect.orElseSucceed(() => null)); + expect(symlinkTarget).toBeNull(); + }), + ); }); }); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts index 9f53ade1bb9..e33b50927b8 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.ts @@ -17,9 +17,239 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths; const workspaceEntries = yield* WorkspaceEntries; + const containsRealPath = (realRoot: string, realTarget: string) => { + const relative = path.relative(realRoot, realTarget); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + }; + + const containmentError = ( + input: { readonly cwd: string; readonly relativePath: string }, + operation: string, + detail: string, + ) => + new WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation, + detail, + }); + + const mapFileSystemError = + (input: { readonly cwd: string; readonly relativePath: string }, operation: string) => + (cause: unknown) => + new WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation, + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }); + + const isNotFoundError = (cause: unknown): boolean => { + if (typeof cause !== "object" || cause === null || !("reason" in cause)) { + return false; + } + const reason = (cause as { readonly reason?: unknown }).reason; + return ( + typeof reason === "object" && + reason !== null && + "_tag" in reason && + (reason as { readonly _tag?: unknown })._tag === "NotFound" + ); + }; + + const realWorkspaceRoot = ( + input: { readonly cwd: string; readonly relativePath: string }, + operation: string, + ) => fileSystem.realPath(input.cwd).pipe(Effect.mapError(mapFileSystemError(input, operation))); + + const existingRealTargetWithinWorkspace = ( + input: { readonly cwd: string; readonly relativePath: string }, + absolutePath: string, + operation: string, + ) => + Effect.gen(function* () { + const realRoot = yield* realWorkspaceRoot(input, operation); + const realTarget = yield* fileSystem + .realPath(absolutePath) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + if (!containsRealPath(realRoot, realTarget)) { + return yield* containmentError( + input, + operation, + "Workspace file target resolves outside the workspace root.", + ); + } + return realTarget; + }); + + const writableRealTargetWithinWorkspace = ( + input: { readonly cwd: string; readonly relativePath: string }, + absolutePath: string, + operation: string, + ) => + Effect.gen(function* () { + const realRoot = yield* realWorkspaceRoot(input, operation); + const targetDirectory = path.dirname(absolutePath); + const realParent = yield* fileSystem + .realPath(targetDirectory) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + if (!containsRealPath(realRoot, realParent)) { + return yield* containmentError( + input, + operation, + "Workspace file parent resolves outside the workspace root.", + ); + } + + const realTarget = yield* fileSystem + .realPath(absolutePath) + .pipe(Effect.orElseSucceed(() => path.resolve(realParent, path.basename(absolutePath)))); + if (!containsRealPath(realRoot, realTarget)) { + return yield* containmentError( + input, + operation, + "Workspace file target resolves outside the workspace root.", + ); + } + return realTarget; + }); + + const deletableTargetWithinWorkspace = ( + input: { readonly cwd: string; readonly relativePath: string }, + absolutePath: string, + operation: string, + ) => + Effect.gen(function* () { + const realRoot = yield* realWorkspaceRoot(input, operation); + const symlinkTarget = yield* fileSystem + .readLink(absolutePath) + .pipe(Effect.orElseSucceed(() => null)); + + if (symlinkTarget !== null) { + const targetDirectory = path.dirname(absolutePath); + const realParent = yield* fileSystem + .realPath(targetDirectory) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + if (!containsRealPath(realRoot, realParent)) { + return yield* containmentError( + input, + operation, + "Workspace file parent resolves outside the workspace root.", + ); + } + + const absoluteLinkTarget = path.isAbsolute(symlinkTarget) + ? symlinkTarget + : path.resolve(targetDirectory, symlinkTarget); + const logicalRoot = path.resolve(input.cwd); + const logicalTarget = path.resolve(absoluteLinkTarget); + if ( + !containsRealPath(logicalRoot, logicalTarget) && + !containsRealPath(realRoot, logicalTarget) + ) { + return yield* containmentError( + input, + operation, + "Workspace file target resolves outside the workspace root.", + ); + } + + const realTarget = yield* fileSystem + .realPath(absoluteLinkTarget) + .pipe(Effect.orElseSucceed(() => null)); + if (realTarget !== null && !containsRealPath(realRoot, realTarget)) { + return yield* containmentError( + input, + operation, + "Workspace file target resolves outside the workspace root.", + ); + } + return true; + } + + const targetExists = yield* fileSystem.stat(absolutePath).pipe( + Effect.as(true), + Effect.catch((cause) => + isNotFoundError(cause) + ? Effect.succeed(false) + : Effect.fail(mapFileSystemError(input, operation)(cause)), + ), + ); + if (!targetExists) { + return false; + } + + const realTarget = yield* fileSystem + .realPath(absolutePath) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + if (!containsRealPath(realRoot, realTarget)) { + return yield* containmentError( + input, + operation, + "Workspace file target resolves outside the workspace root.", + ); + } + return true; + }); + + const readFileString: WorkspaceFileSystemShape["readFileString"] = Effect.fn( + "WorkspaceFileSystem.readFileString", + )(function* (input) { + const operation = "workspaceFileSystem.readFileString"; + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + const realTarget = yield* existingRealTargetWithinWorkspace( + input, + target.absolutePath, + operation, + ); + + return yield* fileSystem + .readFileString(realTarget) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + }); + + const listFiles: WorkspaceFileSystemShape["listFiles"] = Effect.fn( + "WorkspaceFileSystem.listFiles", + )(function* (input) { + const operation = "workspaceFileSystem.listFiles"; + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + const exists = yield* fileSystem + .exists(target.absolutePath) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + if (!exists) { + return []; + } + const realTarget = yield* existingRealTargetWithinWorkspace( + input, + target.absolutePath, + operation, + ); + const entries = yield* fileSystem + .readDirectory(realTarget) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + const files: string[] = []; + for (const entry of entries) { + const info = yield* fileSystem + .stat(path.join(realTarget, entry)) + .pipe(Effect.orElseSucceed(() => null)); + if (info?.type === "File") { + files.push(entry); + } + } + return files.sort((left, right) => left.localeCompare(right)); + }); + const writeFile: WorkspaceFileSystemShape["writeFile"] = Effect.fn( "WorkspaceFileSystem.writeFile", )(function* (input) { + const operation = "workspaceFileSystem.writeFile"; const target = yield* workspacePaths.resolveRelativePathWithinRoot({ workspaceRoot: input.cwd, relativePath: input.relativePath, @@ -37,22 +267,79 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { }), ), ); - yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( + yield* writableRealTargetWithinWorkspace(input, target.absolutePath, operation); + yield* fileSystem + .writeFileString(target.absolutePath, input.contents) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + yield* workspaceEntries.invalidate(input.cwd); + return { relativePath: target.relativePath }; + }); + + const createFileExclusive: WorkspaceFileSystemShape["createFileExclusive"] = Effect.fn( + "WorkspaceFileSystem.createFileExclusive", + )(function* (input) { + const operation = "workspaceFileSystem.createFileExclusive"; + const fileInput = { cwd: input.projectRoot, relativePath: input.relativePath }; + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.projectRoot, + relativePath: input.relativePath, + }); + + yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( Effect.mapError( (cause) => new WorkspaceFileSystemError({ - cwd: input.cwd, + cwd: input.projectRoot, relativePath: input.relativePath, - operation: "workspaceFileSystem.writeFile", + operation: "workspaceFileSystem.makeDirectory", detail: cause.message, cause, }), ), ); - yield* workspaceEntries.invalidate(input.cwd); + yield* writableRealTargetWithinWorkspace(fileInput, target.absolutePath, operation); + yield* fileSystem.writeFileString(target.absolutePath, input.contents, { flag: "wx" }).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemError({ + cwd: input.projectRoot, + relativePath: input.relativePath, + operation: "workspaceFileSystem.createFileExclusive", + detail: cause.message, + cause, + }), + ), + ); + yield* workspaceEntries.invalidate(input.projectRoot); return { relativePath: target.relativePath }; }); - return { writeFile } satisfies WorkspaceFileSystemShape; + + const deleteFile: WorkspaceFileSystemShape["deleteFile"] = Effect.fn( + "WorkspaceFileSystem.deleteFile", + )(function* (input) { + const operation = "workspaceFileSystem.deleteFile"; + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + }); + const exists = yield* deletableTargetWithinWorkspace(input, target.absolutePath, operation); + if (!exists) { + return; + } + + yield* fileSystem + .remove(target.absolutePath, { force: true }) + .pipe(Effect.mapError(mapFileSystemError(input, operation))); + yield* workspaceEntries.invalidate(input.cwd); + }); + + return { + readFileString, + listFiles, + writeFile, + createFileExclusive, + deleteFile, + } satisfies WorkspaceFileSystemShape; }); export const WorkspaceFileSystemLive = Layer.effect(WorkspaceFileSystem, makeWorkspaceFileSystem); diff --git a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts index 16fcdf0b57f..16e1234b568 100644 --- a/apps/server/src/workspace/Services/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/Services/WorkspaceFileSystem.ts @@ -28,6 +28,28 @@ export class WorkspaceFileSystemError extends Schema.TaggedErrorClass Effect.Effect; + + /** + * List the regular files directly inside a directory relative to the + * workspace root (sorted by name). A missing directory lists as empty. + */ + readonly listFiles: (input: { + readonly cwd: string; + readonly relativePath: string; + }) => Effect.Effect< + ReadonlyArray, + WorkspaceFileSystemError | WorkspacePathOutsideRootError + >; + /** * Write a file relative to the workspace root. * @@ -40,6 +62,31 @@ export interface WorkspaceFileSystemShape { ProjectWriteFileResult, WorkspaceFileSystemError | WorkspacePathOutsideRootError >; + /** + * Create a file relative to the workspace root, failing if it already exists. + * + * Creates parent directories as needed and rejects paths that escape the + * workspace root. + */ + readonly createFileExclusive: (input: { + readonly projectRoot: string; + readonly relativePath: string; + readonly contents: string; + }) => Effect.Effect< + ProjectWriteFileResult, + WorkspaceFileSystemError | WorkspacePathOutsideRootError + >; + + /** + * Delete a file relative to the workspace root. + * + * Rejects paths that escape the workspace root. Missing files are treated as + * already deleted so callers can retry safely. + */ + readonly deleteFile: (input: { + readonly cwd: string; + readonly relativePath: string; + }) => Effect.Effect; } /** diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0f2a8f790bf..3d080deb43a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -4,6 +4,7 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Context from "effect/Context"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -13,6 +14,8 @@ import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + AuthWorkflowOperateScope, + AuthWorkflowReadScope, AuthReviewWriteScope, AuthRelayWriteScope, AuthTerminalOperateScope, @@ -29,6 +32,7 @@ import { OrchestrationDispatchCommandError, type OrchestrationEvent, type OrchestrationShellStreamEvent, + type OrchestrationThreadShell, OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, @@ -41,10 +45,14 @@ import { FilesystemBrowseError, EnvironmentAuthorizationError, ThreadId, + type TicketId, type TerminalAttachStreamEvent, type TerminalError, type TerminalEvent, + type TerminalHistoryAttachStreamEvent, type TerminalMetadataStreamEvent, + WORKFLOW_WS_METHODS, + WorkflowRpcError, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -99,6 +107,25 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; +import { BoardDiscovery } from "./workflow/Services/BoardDiscovery.ts"; +import { BoardRegistry } from "./workflow/Services/BoardRegistry.ts"; +import { ProjectScriptTrust } from "./workflow/Services/ProjectScriptTrust.ts"; +import { ProjectWorkspaceResolver } from "./workflow/Services/ProjectWorkspaceResolver.ts"; +import { TicketDiffQuery } from "./workflow/Services/TicketDiffQuery.ts"; +import { WorkflowBoardEvents } from "./workflow/Services/WorkflowBoardEvents.ts"; +import { WorkflowBoardSaveLocks } from "./workflow/Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowBoardVersionStore } from "./workflow/Services/WorkflowBoardVersionStore.ts"; +import { WorkflowEngine } from "./workflow/Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "./workflow/Services/WorkflowEventStore.ts"; +import { WorkflowIntakeService } from "./workflow/Services/WorkflowIntake.ts"; +import { WorkflowThreadJanitor } from "./workflow/Services/WorkflowThreadJanitor.ts"; +import { PredicateEvaluator } from "./workflow/Services/PredicateEvaluator.ts"; +import { WorkflowWebhook } from "./workflow/Services/WorkflowWebhook.ts"; +import { WorkflowWorktreeJanitor } from "./workflow/Services/WorkflowWorktreeJanitor.ts"; +import { WorkflowFileLoader } from "./workflow/Services/WorkflowFileLoader.ts"; +import { WorkflowReadModel } from "./workflow/Services/WorkflowReadModel.ts"; +import { workflowRpcHandlers } from "./workflow/Layers/WorkflowRpcHandlers.ts"; +import { ticketBaseRef } from "./workflow/ticketRefs.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); @@ -137,6 +164,32 @@ const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], + [WORKFLOW_WS_METHODS.listBoards, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.createBoard, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.deleteBoard, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.renameBoard, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.subscribeBoard, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.getBoard, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.getBoardDefinition, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.saveBoardDefinition, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.listBoardVersions, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.getBoardVersion, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.getTicketDetail, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.getTicketDiff, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.createTicket, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.editTicket, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.moveTicket, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.runLane, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.resolveApproval, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.answerTicketStep, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.postTicketMessage, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.setProjectScriptTrust, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.cancelStep, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.intakeTickets, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.listTicketArtifacts, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.getWebhookConfig, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.getBoardDigest, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.dryRunBoard, AuthWorkflowReadScope], [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], @@ -173,6 +226,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.reviewGetDiffPreview, AuthReviewWriteScope], [WS_METHODS.terminalOpen, AuthTerminalOperateScope], [WS_METHODS.terminalAttach, AuthTerminalOperateScope], + [WS_METHODS.terminalAttachHistory, AuthTerminalOperateScope], [WS_METHODS.terminalWrite, AuthTerminalOperateScope], [WS_METHODS.terminalResize, AuthTerminalOperateScope], [WS_METHODS.terminalClear, AuthTerminalOperateScope], @@ -267,6 +321,38 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const relayClient = yield* RelayClient.RelayClient; + const workflowEngine = yield* WorkflowEngine; + const workflowEventStore = yield* WorkflowEventStore; + const workflowWorktreeJanitor = Context.getOption( + (yield* Effect.context()) as Context.Context, + WorkflowWorktreeJanitor, + ); + const workflowIntake = Context.getOption( + (yield* Effect.context()) as Context.Context, + WorkflowIntakeService, + ); + const workflowThreadJanitor = Context.getOption( + (yield* Effect.context()) as Context.Context, + WorkflowThreadJanitor, + ); + const workflowWebhook = Context.getOption( + (yield* Effect.context()) as Context.Context, + WorkflowWebhook, + ); + const workflowPredicates = Context.getOption( + (yield* Effect.context()) as Context.Context, + PredicateEvaluator, + ); + const workflowReadModel = yield* WorkflowReadModel; + const workflowBoardRegistry = yield* BoardRegistry; + const workflowTicketDiff = yield* TicketDiffQuery; + const workflowBoardEvents = yield* WorkflowBoardEvents; + const workflowBoardSaveLocks = yield* WorkflowBoardSaveLocks; + const workflowBoardVersions = yield* WorkflowBoardVersionStore; + const workflowFileLoader = yield* WorkflowFileLoader; + const workflowBoardDiscovery = yield* BoardDiscovery; + const workflowProjectWorkspaceResolver = yield* ProjectWorkspaceResolver; + const projectScriptTrust = yield* ProjectScriptTrust; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, @@ -493,18 +579,24 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => if (event.aggregateKind !== "thread") { return Effect.succeed(Option.none()); } - return projectionSnapshotQuery - .getThreadShellById(ThreadId.make(event.aggregateId)) - .pipe( - Effect.map((thread) => - Option.map(thread, (nextThread) => ({ - kind: "thread-upserted" as const, - sequence: event.sequence, - thread: nextThread, - })), - ), - Effect.orElseSucceed(() => Option.none()), - ); + // Hidden (workflow-internal) threads never reach the sidebar. + return projectionSnapshotQuery.isThreadHidden(ThreadId.make(event.aggregateId)).pipe( + Effect.flatMap((hidden) => + hidden + ? Effect.succeed(Option.none()) + : projectionSnapshotQuery + .getThreadShellById(ThreadId.make(event.aggregateId)) + .pipe(Effect.orElseSucceed(() => Option.none())), + ), + Effect.map((thread) => + Option.map(thread, (nextThread) => ({ + kind: "thread-upserted" as const, + sequence: event.sequence, + thread: nextThread, + })), + ), + Effect.orElseSucceed(() => Option.none()), + ); } }; @@ -759,7 +851,73 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => .refreshStatus(cwd) .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); + const ticketWorktrees = { + resolveForTicket: (ticketId: TicketId) => + Effect.gen(function* () { + const refName = `workflow/${ticketId as string}`; + const refs = yield* gitWorkflow + .listRefs({ + cwd: config.cwd, + query: refName, + limit: 100, + }) + .pipe( + Effect.mapError( + (cause) => + new WorkflowRpcError({ + message: "Failed to resolve workflow ticket worktree refs", + cause, + }), + ), + ); + const ref = refs.refs.find( + (candidate) => + candidate.name === refName && + candidate.isRemote !== true && + candidate.worktreePath !== null, + ); + if (!ref?.worktreePath) { + return yield* new WorkflowRpcError({ + message: `Workflow ticket ${ticketId} does not have an attached worktree`, + }); + } + return { + cwd: ref.worktreePath, + baseRef: ticketBaseRef(ticketId), + }; + }), + }; + + const workflowHandlers = workflowRpcHandlers({ + engine: workflowEngine, + eventStore: workflowEventStore, + readModel: workflowReadModel, + boardRegistry: workflowBoardRegistry, + boardDiscovery: workflowBoardDiscovery, + projectWorkspaceResolver: workflowProjectWorkspaceResolver, + workspaceFileSystem, + ticketDiff: workflowTicketDiff, + ticketWorktrees, + boardEvents: workflowBoardEvents, + saveLocks: workflowBoardSaveLocks, + versionStore: workflowBoardVersions, + ...(Option.isSome(workflowWorktreeJanitor) + ? { worktreeJanitor: workflowWorktreeJanitor.value } + : {}), + ...(Option.isSome(workflowIntake) ? { intake: workflowIntake.value } : {}), + ...(Option.isSome(workflowThreadJanitor) + ? { threadJanitor: workflowThreadJanitor.value } + : {}), + ...(Option.isSome(workflowWebhook) ? { webhook: workflowWebhook.value } : {}), + ...(Option.isSome(workflowPredicates) ? { predicates: workflowPredicates.value } : {}), + fileLoader: workflowFileLoader, + projectScriptTrust, + observeRpcEffect, + observeRpcStreamEffect, + }); + return WsRpcGroup.of({ + ...workflowHandlers, [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => observeRpcEffect( ORCHESTRATION_WS_METHODS.dispatchCommand, @@ -1308,6 +1466,17 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ), { "rpc.aggregate": "terminal" }, ), + [WS_METHODS.terminalAttachHistory]: (input) => + observeRpcStream( + WS_METHODS.terminalAttachHistory, + Stream.callback((queue) => + Effect.acquireRelease( + terminalManager.attachHistoryStream(input, (event) => Queue.offer(queue, event)), + (unsubscribe) => Effect.sync(unsubscribe), + ), + ), + { "rpc.aggregate": "terminal" }, + ), [WS_METHODS.terminalWrite]: (input) => observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { "rpc.aggregate": "terminal", diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 5a92a244c52..a1774a92056 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -4,10 +4,13 @@ import "../index.css"; import { EventId, ORCHESTRATION_WS_METHODS, + BoardId, EnvironmentId, type EnvironmentApi, type MessageId, type OrchestrationReadModel, + type BoardListEntry, + type BoardSnapshot, type ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -242,6 +245,7 @@ function createBaseServerConfig(): ServerConfig { function createMockEnvironmentApi(input: { browse: EnvironmentApi["filesystem"]["browse"]; dispatchCommand: EnvironmentApi["orchestration"]["dispatchCommand"]; + workflow?: Partial; }): EnvironmentApi { return { terminal: {} as EnvironmentApi["terminal"], @@ -253,6 +257,9 @@ function createMockEnvironmentApi(input: { vcs: {} as EnvironmentApi["vcs"], git: {} as EnvironmentApi["git"], review: {} as EnvironmentApi["review"], + workflow: { + ...input.workflow, + } as EnvironmentApi["workflow"], orchestration: { dispatchCommand: input.dispatchCommand, getTurnDiff: (() => { @@ -1765,6 +1772,8 @@ describe("ChatView timeline estimator parity (full app)", () => { useStore.setState({ activeEnvironmentId: null, environmentStateById: {}, + boardStateById: {}, + boardsByScopedProjectKey: {}, }); useUiStateStore.setState({ projectExpandedById: {}, @@ -4033,6 +4042,590 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("confirms workflow board deletion from the sidebar and refreshes the board list", async () => { + const boardId = BoardId.make(`${PROJECT_ID}__delivery`); + let boards: BoardListEntry[] = [ + { + boardId, + name: "Delivery", + filePath: ".t3/boards/delivery.json", + error: null, + }, + ]; + const listBoardsMock = vi.fn(async () => boards); + const deleteBoardMock = vi.fn(async (input) => { + expect(input).toEqual({ boardId }); + boards = []; + }); + __setEnvironmentApiOverrideForTests( + LOCAL_ENVIRONMENT_ID, + createMockEnvironmentApi({ + browse: vi.fn(async () => ({ parentPath: "~/", entries: [] })), + dispatchCommand: vi.fn(async () => ({ + sequence: fixture.snapshot.snapshotSequence + 1, + })), + workflow: { + listBoards: listBoardsMock, + deleteBoard: deleteBoardMock, + }, + }), + ); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-board-delete-test" as MessageId, + targetText: "board delete target", + }), + }); + + try { + const boardRow = page.getByTestId(`board-row-${boardId}`); + await expect.element(boardRow).toBeInTheDocument(); + await boardRow.hover(); + + const deleteButton = document.querySelector( + `[data-testid="board-delete-${boardId}"]`, + ); + expect(deleteButton, "Expected a delete button on the workflow board row.").not.toBeNull(); + deleteButton?.click(); + + await expect.element(page.getByText('Delete board "Delivery"?')).toBeInTheDocument(); + await expect + .element( + page.getByText( + "This permanently deletes the board file, its tickets, and version history.", + ), + ) + .toBeInTheDocument(); + + await page.getByRole("button", { name: "Delete board", exact: true }).click(); + + await vi.waitFor( + () => { + expect(deleteBoardMock).toHaveBeenCalledWith({ boardId }); + }, + { timeout: 8_000, interval: 16 }, + ); + await vi.waitFor( + () => { + expect(document.querySelector(`[data-testid="board-row-${boardId}"]`)).toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + expect( + listBoardsMock.mock.calls.filter(([input]) => input.projectId === PROJECT_ID).length, + ).toBeGreaterThanOrEqual(2); + } finally { + __resetEnvironmentApiOverridesForTests(); + await mounted.cleanup(); + } + }); + + it("renames workflow boards inline from the sidebar and refreshes the board list", async () => { + const boardId = BoardId.make(`${PROJECT_ID}__delivery`); + let boards: BoardListEntry[] = [ + { + boardId, + name: "Delivery", + filePath: ".t3/boards/delivery.json", + error: null, + }, + ]; + const listBoardsMock = vi.fn(async () => boards); + const renameBoardMock = vi.fn(async (input) => { + expect(input).toEqual({ boardId, name: "Renamed Delivery" }); + boards = [{ ...boards[0]!, name: input.name }]; + }); + const getBoardMock = vi.fn(async () => ({ + projectId: PROJECT_ID, + board: { + boardId, + name: boards[0]!.name, + lanes: [], + }, + tickets: [], + })); + __setEnvironmentApiOverrideForTests( + LOCAL_ENVIRONMENT_ID, + createMockEnvironmentApi({ + browse: vi.fn(async () => ({ parentPath: "~/", entries: [] })), + dispatchCommand: vi.fn(async () => ({ + sequence: fixture.snapshot.snapshotSequence + 1, + })), + workflow: { + listBoards: listBoardsMock, + renameBoard: renameBoardMock, + getBoard: getBoardMock, + }, + }), + ); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-board-rename-test" as MessageId, + targetText: "board rename target", + }), + }); + + try { + const boardRow = page.getByTestId(`board-row-${boardId}`); + await expect.element(boardRow).toBeInTheDocument(); + await boardRow.hover(); + + const renameButton = document.querySelector( + `[data-testid="board-rename-${boardId}"]`, + ); + expect(renameButton, "Expected a rename button on the workflow board row.").not.toBeNull(); + renameButton?.click(); + + const input = page.getByTestId(`board-rename-input-${boardId}`); + await input.fill("Renamed Delivery"); + document + .querySelector(`[data-testid="board-rename-input-${boardId}"]`) + ?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + await vi.waitFor( + () => { + expect(renameBoardMock).toHaveBeenCalledWith({ boardId, name: "Renamed Delivery" }); + }, + { timeout: 8_000, interval: 16 }, + ); + await expect.element(page.getByText("Renamed Delivery")).toBeInTheDocument(); + expect( + listBoardsMock.mock.calls.filter(([input]) => input.projectId === PROJECT_ID).length, + ).toBeGreaterThanOrEqual(2); + } finally { + __resetEnvironmentApiOverridesForTests(); + await mounted.cleanup(); + } + }); + + it("keeps workflow board inline rename open when the rename fails", async () => { + const boardId = BoardId.make(`${PROJECT_ID}__delivery`); + const boards: BoardListEntry[] = [ + { + boardId, + name: "Delivery", + filePath: ".t3/boards/delivery.json", + error: null, + }, + ]; + const listBoardsMock = vi.fn(async () => boards); + let rejectRename: (error: Error) => void = () => { + throw new Error("rename promise was not started"); + }; + let resolveRenameStarted: () => void = () => {}; + const renameStarted = new Promise((resolve) => { + resolveRenameStarted = resolve; + }); + const renameBoardMock = vi.fn( + (input) => + new Promise((_resolve, reject) => { + expect(input).toEqual({ boardId, name: "Renamed Delivery" }); + rejectRename = reject; + resolveRenameStarted(); + }), + ); + __setEnvironmentApiOverrideForTests( + LOCAL_ENVIRONMENT_ID, + createMockEnvironmentApi({ + browse: vi.fn(async () => ({ parentPath: "~/", entries: [] })), + dispatchCommand: vi.fn(async () => ({ + sequence: fixture.snapshot.snapshotSequence + 1, + })), + workflow: { + listBoards: listBoardsMock, + renameBoard: renameBoardMock, + }, + }), + ); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-board-rename-failure-test" as MessageId, + targetText: "board rename failure target", + }), + }); + + try { + const boardRow = page.getByTestId(`board-row-${boardId}`); + await expect.element(boardRow).toBeInTheDocument(); + await boardRow.hover(); + + const renameButton = document.querySelector( + `[data-testid="board-rename-${boardId}"]`, + ); + expect(renameButton, "Expected a rename button on the workflow board row.").not.toBeNull(); + renameButton?.click(); + + const input = page.getByTestId(`board-rename-input-${boardId}`); + await input.fill("Renamed Delivery"); + document + .querySelector(`[data-testid="board-rename-input-${boardId}"]`) + ?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + await renameStarted; + await vi.waitFor( + () => { + const renameInput = document.querySelector( + `[data-testid="board-rename-input-${boardId}"]`, + ); + expect(renameInput?.disabled).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + rejectRename(new Error("rename unavailable")); + + await vi.waitFor( + () => { + expect(renameBoardMock).toHaveBeenCalledWith({ boardId, name: "Renamed Delivery" }); + const renameInput = document.querySelector( + `[data-testid="board-rename-input-${boardId}"]`, + ); + expect(renameInput).not.toBeNull(); + expect(renameInput?.disabled).toBe(false); + }, + { timeout: 8_000, interval: 16 }, + ); + await expect.element(page.getByText("Renamed Delivery")).not.toBeInTheDocument(); + } finally { + __resetEnvironmentApiOverridesForTests(); + await mounted.cleanup(); + } + }); + + it("updates the active workflow board header after sidebar rename", async () => { + const boardId = BoardId.make(`${PROJECT_ID}__delivery`); + let boardSnapshot = { + projectId: PROJECT_ID, + board: { + boardId, + name: "Delivery", + lanes: [], + }, + tickets: [], + } satisfies BoardSnapshot; + let boards: BoardListEntry[] = [ + { + boardId, + name: "Delivery", + filePath: ".t3/boards/delivery.json", + error: null, + }, + ]; + const listBoardsMock = vi.fn(async () => boards); + const renameBoardMock = vi.fn(async (input) => { + expect(input).toEqual({ boardId, name: "Renamed Delivery" }); + boards = [{ ...boards[0]!, name: input.name }]; + boardSnapshot = { + ...boardSnapshot, + board: { + ...boardSnapshot.board, + name: input.name, + }, + }; + }); + const getBoardMock = vi.fn(async () => boardSnapshot); + const subscribeBoardMock = vi.fn( + (_input, callback) => { + callback({ kind: "snapshot", snapshot: boardSnapshot }); + return () => undefined; + }, + ); + __setEnvironmentApiOverrideForTests( + LOCAL_ENVIRONMENT_ID, + createMockEnvironmentApi({ + browse: vi.fn(async () => ({ parentPath: "~/", entries: [] })), + dispatchCommand: vi.fn(async () => ({ + sequence: fixture.snapshot.snapshotSequence + 1, + })), + workflow: { + listBoards: listBoardsMock, + renameBoard: renameBoardMock, + getBoard: getBoardMock, + subscribeBoard: subscribeBoardMock, + }, + }), + ); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-board-rename-active-test" as MessageId, + targetText: "active board rename target", + }), + initialPath: `/${LOCAL_ENVIRONMENT_ID}/board?boardId=${boardId}`, + }); + + try { + await expect.element(page.getByRole("heading", { name: "Delivery" })).toBeInTheDocument(); + const boardRow = page.getByTestId(`board-row-${boardId}`); + await expect.element(boardRow).toBeInTheDocument(); + await boardRow.hover(); + + const renameButton = document.querySelector( + `[data-testid="board-rename-${boardId}"]`, + ); + expect(renameButton, "Expected a rename button on the workflow board row.").not.toBeNull(); + renameButton?.click(); + + const input = page.getByTestId(`board-rename-input-${boardId}`); + await input.fill("Renamed Delivery"); + document + .querySelector(`[data-testid="board-rename-input-${boardId}"]`) + ?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + await vi.waitFor( + () => { + expect(renameBoardMock).toHaveBeenCalledWith({ boardId, name: "Renamed Delivery" }); + expect(useStore.getState().boardStateById[boardId]?.boardName).toBe("Renamed Delivery"); + }, + { timeout: 8_000, interval: 16 }, + ); + await expect + .element(page.getByRole("heading", { name: "Renamed Delivery" })) + .toBeInTheDocument(); + expect(mounted.router.state.location.pathname).toBe(`/${LOCAL_ENVIRONMENT_ID}/board`); + } finally { + __resetEnvironmentApiOverridesForTests(); + await mounted.cleanup(); + } + }); + + it("deletes workflow boards through the environment that owns the board row", async () => { + const sharedRepositoryIdentity = { + canonicalKey: "github.com/example/shared-project", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-project.git", + }, + }; + const localBoardId = BoardId.make(`${PROJECT_ID}__local`); + const remoteBoardId = BoardId.make(`${PROJECT_ID}__remote`); + let localBoards: BoardListEntry[] = [ + { + boardId: localBoardId, + name: "Local board", + filePath: ".t3/boards/local.json", + error: null, + }, + ]; + let remoteBoards: BoardListEntry[] = [ + { + boardId: remoteBoardId, + name: "Remote board", + filePath: ".t3/boards/remote.json", + error: null, + }, + ]; + const localListBoardsMock = vi.fn( + async () => localBoards, + ); + const remoteListBoardsMock = vi.fn( + async () => remoteBoards, + ); + const localDeleteBoardMock = vi.fn(async () => { + localBoards = []; + }); + const remoteDeleteBoardMock = vi.fn( + async (input) => { + expect(input).toEqual({ boardId: remoteBoardId }); + remoteBoards = []; + }, + ); + + __setEnvironmentApiOverrideForTests( + LOCAL_ENVIRONMENT_ID, + createMockEnvironmentApi({ + browse: vi.fn(async () => ({ parentPath: "~/", entries: [] })), + dispatchCommand: vi.fn(async () => ({ + sequence: fixture.snapshot.snapshotSequence + 1, + })), + workflow: { + listBoards: localListBoardsMock, + deleteBoard: localDeleteBoardMock, + }, + }), + ); + __setEnvironmentApiOverrideForTests( + REMOTE_ENVIRONMENT_ID, + createMockEnvironmentApi({ + browse: vi.fn(async () => ({ parentPath: "~/", entries: [] })), + dispatchCommand: vi.fn(async () => ({ + sequence: fixture.snapshot.snapshotSequence + 1, + })), + workflow: { + listBoards: remoteListBoardsMock, + deleteBoard: remoteDeleteBoardMock, + }, + }), + ); + + const localSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-board-delete-scoped-env-test" as MessageId, + targetText: "board delete scoped env target", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...localSnapshot, + projects: localSnapshot.projects.map((project) => ({ + ...project, + repositoryIdentity: sharedRepositoryIdentity, + })), + }, + }); + + try { + useSavedEnvironmentRegistryStore.getState().upsert({ + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Remote", + httpBaseUrl: "https://remote.example.test", + wsBaseUrl: "wss://remote.example.test/ws", + createdAt: NOW_ISO, + lastConnectedAt: NOW_ISO, + }); + useSavedEnvironmentRuntimeStore.getState().patch(REMOTE_ENVIRONMENT_ID, { + connectionState: "connected", + authState: "authenticated", + descriptor: { + ...fixture.serverConfig.environment, + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Remote", + }, + serverConfig: { + ...fixture.serverConfig, + environment: { + ...fixture.serverConfig.environment, + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Remote", + }, + }, + connectedAt: NOW_ISO, + }); + useStore.getState().syncServerShellSnapshot( + toShellSnapshot({ + ...localSnapshot, + projects: localSnapshot.projects.map((project) => ({ + ...project, + repositoryIdentity: sharedRepositoryIdentity, + })), + threads: [], + }), + REMOTE_ENVIRONMENT_ID, + ); + + const remoteBoardRow = page.getByTestId(`board-row-${remoteBoardId}`); + await expect.element(remoteBoardRow).toBeInTheDocument(); + await remoteBoardRow.hover(); + + const deleteButton = document.querySelector( + `[data-testid="board-delete-${remoteBoardId}"]`, + ); + expect( + deleteButton, + "Expected a delete button on the remote workflow board row.", + ).not.toBeNull(); + deleteButton?.click(); + + await page.getByRole("button", { name: "Delete board", exact: true }).click(); + + await vi.waitFor( + () => { + expect(remoteDeleteBoardMock).toHaveBeenCalledWith({ boardId: remoteBoardId }); + }, + { timeout: 8_000, interval: 16 }, + ); + expect(localDeleteBoardMock).not.toHaveBeenCalled(); + } finally { + __resetEnvironmentApiOverridesForTests(); + await mounted.cleanup(); + } + }); + + it("navigates away after deleting the currently open workflow board", async () => { + const boardId = BoardId.make(`${PROJECT_ID}__delivery`); + const boardSnapshot = { + projectId: PROJECT_ID, + board: { + boardId, + name: "Delivery", + lanes: [], + }, + tickets: [], + } satisfies BoardSnapshot; + let boards: BoardListEntry[] = [ + { + boardId, + name: "Delivery", + filePath: ".t3/boards/delivery.json", + error: null, + }, + ]; + const listBoardsMock = vi.fn(async () => boards); + const deleteBoardMock = vi.fn(async () => { + boards = []; + }); + const getBoardMock = vi.fn(async () => boardSnapshot); + const subscribeBoardMock = vi.fn( + (_input, callback) => { + callback({ kind: "snapshot", snapshot: boardSnapshot }); + return () => undefined; + }, + ); + __setEnvironmentApiOverrideForTests( + LOCAL_ENVIRONMENT_ID, + createMockEnvironmentApi({ + browse: vi.fn(async () => ({ parentPath: "~/", entries: [] })), + dispatchCommand: vi.fn(async () => ({ + sequence: fixture.snapshot.snapshotSequence + 1, + })), + workflow: { + listBoards: listBoardsMock, + deleteBoard: deleteBoardMock, + getBoard: getBoardMock, + subscribeBoard: subscribeBoardMock, + }, + }), + ); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-active-board-delete-test" as MessageId, + targetText: "active board delete target", + }), + initialPath: `/${LOCAL_ENVIRONMENT_ID}/board?boardId=${boardId}`, + }); + + try { + const boardRow = page.getByTestId(`board-row-${boardId}`); + await expect.element(boardRow).toBeInTheDocument(); + + const deleteButton = await waitForElement( + () => document.querySelector(`[data-testid="board-delete-${boardId}"]`), + "Expected a delete button on the workflow board row.", + ); + deleteButton.click(); + + await page.getByRole("button", { name: "Delete board", exact: true }).click(); + + await vi.waitFor( + () => { + expect(deleteBoardMock).toHaveBeenCalledWith({ boardId }); + }, + { timeout: 8_000, interval: 16 }, + ); + await waitForURL( + mounted.router, + (pathname) => pathname === "/", + "Deleting the active workflow board should navigate to the no-board route.", + ); + } finally { + __resetEnvironmentApiOverridesForTests(); + await mounted.cleanup(); + } + }); + it("shows the sidebar terminal indicator from terminal metadata activity", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/RightPanelSheet.tsx b/apps/web/src/components/RightPanelSheet.tsx index ebc4aa0a698..ddf4b3ed56e 100644 --- a/apps/web/src/components/RightPanelSheet.tsx +++ b/apps/web/src/components/RightPanelSheet.tsx @@ -7,6 +7,7 @@ export function RightPanelSheet(props: { children: ReactNode; open: boolean; onClose: () => void; + className?: string; }) { return ( {props.children} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index bdbbf6f8491..81977898223 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -3,14 +3,17 @@ import { ProviderDriverKind } from "@t3tools/contracts"; import { createThreadJumpHintVisibilityController, + getSidebarBoardRowKey, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, + nextDefaultBoardName, resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, isContextMenuPointerDown, + isSidebarBoardRouteActive, orderItemsByPreferredIds, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, @@ -37,6 +40,46 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("sidebar board identity", () => { + it("includes environment in board row keys", () => { + expect( + getSidebarBoardRowKey({ + environmentId: "environment-local", + projectId: "project-1", + boardId: "project-1__delivery", + }), + ).not.toBe( + getSidebarBoardRowKey({ + environmentId: "environment-remote", + projectId: "project-1", + boardId: "project-1__delivery", + }), + ); + }); + + it("matches active board routes by environment and board id", () => { + const activeRouteBoard = { + environmentId: "environment-local", + boardId: "project-1__delivery", + }; + + expect( + isSidebarBoardRouteActive(activeRouteBoard, { + environmentId: "environment-local", + projectId: "project-1", + boardId: "project-1__delivery", + }), + ).toBe(true); + expect( + isSidebarBoardRouteActive(activeRouteBoard, { + environmentId: "environment-remote", + projectId: "project-1", + boardId: "project-1__delivery", + }), + ).toBe(false); + }); +}); + function makeLatestTurn(overrides?: { completedAt?: string | null; startedAt?: string | null; @@ -274,6 +317,14 @@ describe("resolveSidebarNewThreadSeedContext", () => { }); }); +describe("nextDefaultBoardName", () => { + it("chooses the first unused Workflow board name", () => { + expect(nextDefaultBoardName([])).toBe("Workflow board"); + expect(nextDefaultBoardName(["Workflow board"])).toBe("Workflow board 2"); + expect(nextDefaultBoardName(["Workflow board", "Workflow board 2"])).toBe("Workflow board 3"); + }); +}); + describe("orderItemsByPreferredIds", () => { it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => { const ordered = orderItemsByPreferredIds({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index b9dd27dfb03..6d63c936b9c 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -23,6 +23,30 @@ type SidebarProject = { updatedAt?: string | undefined; }; +export interface SidebarBoardRouteIdentity { + readonly environmentId: string; + readonly boardId: string; +} + +export interface SidebarBoardIdentity extends SidebarBoardRouteIdentity { + readonly projectId: string; +} + +export function getSidebarBoardRowKey(board: SidebarBoardIdentity): string { + return `${board.environmentId}:${board.projectId}:${board.boardId}`; +} + +export function isSidebarBoardRouteActive( + activeRouteBoard: SidebarBoardRouteIdentity | null, + board: SidebarBoardIdentity, +): boolean { + return ( + activeRouteBoard !== null && + activeRouteBoard.environmentId === board.environmentId && + activeRouteBoard.boardId === board.boardId + ); +} + export type ThreadTraversalDirection = "previous" | "next"; export interface ThreadStatusPill { @@ -213,6 +237,20 @@ export function resolveSidebarNewThreadSeedContext(input: { }; } +export function nextDefaultBoardName(existingNames: readonly string[]): string { + const existing = new Set(existingNames); + const baseName = "Workflow board"; + if (!existing.has(baseName)) { + return baseName; + } + for (let index = 2; ; index += 1) { + const candidate = `${baseName} ${index}`; + if (!existing.has(candidate)) { + return candidate; + } + } +} + export function orderItemsByPreferredIds(input: { items: readonly TItem[]; preferredIds: readonly TId[]; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index dc5acaaadc7..1f98830ce9c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4,10 +4,13 @@ import { ChevronRightIcon, CloudIcon, FolderPlusIcon, + PencilIcon, SearchIcon, SettingsIcon, + SquareKanbanIcon, SquarePenIcon, TerminalIcon, + Trash2Icon, TriangleAlertIcon, } from "lucide-react"; import { @@ -40,6 +43,8 @@ import { type ContextMenuItem, type DesktopUpdateState, ProjectId, + type BoardListEntry, + type EnvironmentId, type ScopedThreadRef, type SidebarProjectGroupingMode, type ThreadEnvMode, @@ -67,6 +72,7 @@ import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform, newCommandId } from "../lib/utils"; import { selectProjectByRef, + selectBoardsForProject, selectProjectsAcrossEnvironments, selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, @@ -112,6 +118,15 @@ import { shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; import { Button } from "./ui/button"; import { Dialog, @@ -159,25 +174,31 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { + getSidebarBoardRowKey, getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, + isSidebarBoardRouteActive, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, + nextDefaultBoardName, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, useThreadJumpHintVisibility, - ThreadStatusPill, + type SidebarBoardRouteIdentity, + type ThreadStatusPill, } from "./Sidebar.logic"; import { sortThreads } from "../lib/threadSort"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; +import { createBoard, deleteBoard, listBoards, renameBoard } from "../workflow/boardRpc"; +import { resolveRecentAgent } from "../workflow/resolveRecentAgent"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; import { @@ -734,13 +755,272 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ); }); +interface SidebarBoardRowProps { + entry: BoardListEntry; + environmentId: EnvironmentId; + projectId: ProjectId; + isActive: boolean; + deleteBoardForProjectMember: (board: SidebarProjectBoardRow) => Promise; + renameBoardForProjectMember: (board: SidebarProjectBoardRow, name: string) => Promise; +} + +const SidebarBoardRow = memo(function SidebarBoardRow(props: SidebarBoardRowProps) { + const { + entry, + environmentId, + projectId, + isActive, + deleteBoardForProjectMember, + renameBoardForProjectMember, + } = props; + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); + const [renameName, setRenameName] = useState(entry.name); + const [isRenameSaving, setIsRenameSaving] = useState(false); + const renameCommittedRef = useRef(false); + const renameInputRef = useRef(null); + const linkRender = useMemo( + () => ( + + ), + [entry.boardId, environmentId], + ); + const renameRowRender = useMemo(() =>
, []); + const rowRender = isRenaming ? renameRowRender : linkRender; + useEffect(() => { + if (!isRenaming) { + setRenameName(entry.name); + } + }, [entry.name, isRenaming]); + const cancelRename = useCallback(() => { + renameCommittedRef.current = true; + renameInputRef.current = null; + setIsRenaming(false); + setRenameName(entry.name); + }, [entry.name]); + const startRename = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + renameCommittedRef.current = false; + setRenameName(entry.name); + setIsRenaming(true); + }, + [entry.name], + ); + const handleRenameInputRef = useCallback((element: HTMLInputElement | null) => { + if (element && renameInputRef.current !== element) { + renameInputRef.current = element; + element.focus(); + element.select(); + } + }, []); + const commitRename = useCallback(async () => { + const trimmed = renameName.trim(); + if (trimmed.length === 0) { + toastManager.add({ + type: "warning", + title: "Board name cannot be empty", + }); + cancelRename(); + return; + } + if (trimmed === entry.name) { + cancelRename(); + return; + } + setIsRenameSaving(true); + try { + const renamed = await renameBoardForProjectMember( + { entry, environmentId, projectId }, + trimmed, + ); + if (renamed) { + setIsRenaming(false); + renameInputRef.current = null; + } + } finally { + setIsRenameSaving(false); + } + }, [cancelRename, entry, environmentId, projectId, renameBoardForProjectMember, renameName]); + const handleRenameInputChange = useCallback((event: React.ChangeEvent) => { + setRenameName(event.target.value); + }, []); + const handleRenameInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + renameCommittedRef.current = true; + void commitRename(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelRename(); + } + }, + [cancelRename, commitRename], + ); + const handleRenameInputBlur = useCallback(() => { + if (!renameCommittedRef.current) { + cancelRename(); + } + }, [cancelRename]); + const stopRenameInputPropagation = useCallback( + (event: React.SyntheticEvent) => { + event.stopPropagation(); + }, + [], + ); + const openDeleteConfirmation = useCallback((event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setDeleteConfirmOpen(true); + }, []); + const confirmDelete = useCallback(async () => { + setIsDeleting(true); + try { + await deleteBoardForProjectMember({ entry, environmentId, projectId }); + setDeleteConfirmOpen(false); + } finally { + setIsDeleting(false); + } + }, [deleteBoardForProjectMember, entry, environmentId, projectId]); + + return ( + + + + + {isRenaming ? ( + + ) : ( + + {entry.name}} + /> + + {entry.name} + + + )} + + {entry.error ? ( + + + + + } + /> + + {entry.error} + + + ) : null} + + {!isRenaming ? ( + + + + + } + /> + Rename board + + ) : null} + + + + + } + /> + Delete board + + + + + Delete board "{entry.name}"? + + This permanently deletes the board file, its tickets, and version history. + + + + }>Cancel + + + + + + ); +}); + +interface SidebarProjectBoardRow { + readonly entry: BoardListEntry; + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; +} + interface SidebarProjectThreadListProps { projectKey: string; projectExpanded: boolean; + renderedBoards: readonly SidebarProjectBoardRow[]; hasOverflowingThreads: boolean; hiddenThreadStatus: ThreadStatusPill | null; orderedProjectThreadKeys: readonly string[]; renderedThreads: readonly SidebarThreadSummary[]; + activeRouteBoardRef: SidebarBoardRouteIdentity | null; showEmptyThreadState: boolean; shouldShowThreadPanel: boolean; isThreadListExpanded: boolean; @@ -756,6 +1036,8 @@ interface SidebarProjectThreadListProps { confirmingArchiveThreadKey: string | null; setConfirmingArchiveThreadKey: React.Dispatch>; confirmArchiveButtonRefs: React.RefObject>; + deleteBoardForProjectMember: (board: SidebarProjectBoardRow) => Promise; + renameBoardForProjectMember: (board: SidebarProjectBoardRow, name: string) => Promise; attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; handleThreadClick: ( event: React.MouseEvent, @@ -787,10 +1069,12 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( const { projectKey, projectExpanded, + renderedBoards, hasOverflowingThreads, hiddenThreadStatus, orderedProjectThreadKeys, renderedThreads, + activeRouteBoardRef, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, @@ -806,6 +1090,8 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( confirmingArchiveThreadKey, setConfirmingArchiveThreadKey, confirmArchiveButtonRefs, + deleteBoardForProjectMember, + renameBoardForProjectMember, attachThreadListAutoAnimateRef, handleThreadClick, navigateToThread, @@ -837,6 +1123,26 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList(
) : null} + {shouldShowThreadPanel && + renderedBoards.map((board) => ( + + ))} {shouldShowThreadPanel && renderedThreads.map((thread) => { const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); @@ -911,6 +1217,7 @@ interface SidebarProjectItemProps { project: SidebarProjectSnapshot; isThreadListExpanded: boolean; activeRouteThreadKey: string | null; + activeRouteBoardRef: SidebarBoardRouteIdentity | null; newThreadShortcutLabel: string | null; handleNewThread: ReturnType["handleNewThread"]; archiveThread: ReturnType["archiveThread"]; @@ -931,6 +1238,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec project, isThreadListExpanded, activeRouteThreadKey, + activeRouteBoardRef, newThreadShortcutLabel, handleNewThread, archiveThread, @@ -1043,6 +1351,28 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ), ), ); + const projectBoardLists = useStore( + useShallow( + useMemo( + () => (state: import("../store").AppState) => + project.memberProjects.map((member) => + selectBoardsForProject(state, scopeProjectRef(member.environmentId, member.id)), + ), + [project.memberProjects], + ), + ), + ); + const projectBoards = useMemo( + () => + project.memberProjects.flatMap((member, index) => + (projectBoardLists[index] ?? []).map((entry) => ({ + entry, + environmentId: member.environmentId, + projectId: member.id, + })), + ), + [project.memberProjects, projectBoardLists], + ); const sidebarThreadByKey = useMemo( () => new Map( @@ -1062,6 +1392,53 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const projectExpanded = useUiStateStore( (state) => state.projectExpandedById[project.projectKey] ?? true, ); + const fetchBoardsForProjectMember = useCallback(async (member: SidebarProjectGroupMember) => { + const api = readEnvironmentApi(member.environmentId); + if (!api) { + return []; + } + const entries = await listBoards(api, member.id); + useStore.getState().setProjectBoards(scopeProjectRef(member.environmentId, member.id), entries); + return entries; + }, []); + + useEffect(() => { + if (!projectExpanded) { + return; + } + + let cancelled = false; + for (const member of project.memberProjects) { + const api = readEnvironmentApi(member.environmentId); + if (!api) { + continue; + } + void listBoards(api, member.id) + .then((entries) => { + if (!cancelled) { + useStore + .getState() + .setProjectBoards(scopeProjectRef(member.environmentId, member.id), entries); + } + }) + .catch((error) => { + if (cancelled) { + return; + } + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to load boards for ${member.name}`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + } + + return () => { + cancelled = true; + }; + }, [project.memberProjects, projectExpanded]); const threadLastVisitedAts = useUiStateStore( useShallow((state) => projectThreads.map( @@ -1209,12 +1586,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), ), renderedThreads, - showEmptyThreadState: projectExpanded && visibleProjectThreads.length === 0, + showEmptyThreadState: + projectExpanded && visibleProjectThreads.length === 0 && projectBoards.length === 0, shouldShowThreadPanel: projectExpanded || pinnedCollapsedThread !== null, }; }, [ isThreadListExpanded, pinnedCollapsedThread, + projectBoards.length, projectExpanded, projectThreads, sidebarThreadPreviewCount, @@ -1720,6 +2099,165 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [defaultThreadEnvMode, handleNewThread, isMobile, router, setOpenMobile], ); + const createBoardForProjectMember = useCallback( + async (member: SidebarProjectGroupMember) => { + const agent = resolveRecentAgent(); + if (!agent) { + toastManager.add({ + type: "error", + title: "No available agent", + description: "Enable an installed agent before creating a workflow board.", + }); + return; + } + + const api = readEnvironmentApi(member.environmentId); + if (!api) { + toastManager.add({ + type: "error", + title: "Project API unavailable", + }); + return; + } + + const existingBoards = selectBoardsForProject( + useStore.getState(), + scopeProjectRef(member.environmentId, member.id), + ); + const name = nextDefaultBoardName(existingBoards.map((entry) => entry.name)); + + try { + const created = await createBoard(api, { + projectId: member.id, + name, + agent, + }); + await fetchBoardsForProjectMember(member); + if (isMobile) { + setOpenMobile(false); + } + void router.navigate({ + to: "/$environmentId/board", + params: { environmentId: member.environmentId }, + search: { boardId: created.boardId }, + }); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to create board for ${member.name}`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [fetchBoardsForProjectMember, isMobile, router, setOpenMobile], + ); + + const deleteBoardForProjectMember = useCallback( + async (board: SidebarProjectBoardRow) => { + const member = project.memberProjects.find( + (candidate) => + candidate.id === board.projectId && candidate.environmentId === board.environmentId, + ); + if (!member) { + toastManager.add({ + type: "error", + title: "Project API unavailable", + }); + return; + } + + const api = readEnvironmentApi(board.environmentId); + if (!api) { + toastManager.add({ + type: "error", + title: "Project API unavailable", + }); + return; + } + + try { + await deleteBoard(api, board.entry.boardId); + await fetchBoardsForProjectMember(member); + if ( + isSidebarBoardRouteActive(activeRouteBoardRef, { + environmentId: board.environmentId, + projectId: board.projectId, + boardId: board.entry.boardId, + }) + ) { + if (isMobile) { + setOpenMobile(false); + } + void router.navigate({ to: "/", replace: true }); + } + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to delete board for ${member.name}`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [ + activeRouteBoardRef, + fetchBoardsForProjectMember, + isMobile, + project.memberProjects, + router, + setOpenMobile, + ], + ); + + const renameBoardForProjectMember = useCallback( + async (board: SidebarProjectBoardRow, name: string) => { + const member = project.memberProjects.find( + (candidate) => + candidate.id === board.projectId && candidate.environmentId === board.environmentId, + ); + if (!member) { + toastManager.add({ + type: "error", + title: "Project API unavailable", + }); + return false; + } + + const api = readEnvironmentApi(board.environmentId); + if (!api) { + toastManager.add({ + type: "error", + title: "Project API unavailable", + }); + return false; + } + + try { + await renameBoard(api, board.entry.boardId, name); + const snapshot = await api.workflow.getBoard({ boardId: board.entry.boardId }); + useStore.getState().applyBoardStreamItem(snapshot.board.boardId, { + kind: "snapshot", + snapshot, + }); + await fetchBoardsForProjectMember(member); + return true; + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to rename board for ${member.name}`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return false; + } + }, + [fetchBoardsForProjectMember, project.memberProjects], + ); + const handleCreateThreadClick = useCallback( (event: React.MouseEvent) => { event.preventDefault(); @@ -1760,6 +2298,46 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [createThreadForProjectMember, project.groupedProjectCount, project.memberProjects], ); + const handleAddBoardClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (project.memberProjects.length === 1) { + void createBoardForProjectMember(project.memberProjects[0]!); + return; + } + + void (async () => { + const api = readLocalApi(); + if (!api) { + return; + } + const clicked = await api.contextMenu.show( + project.memberProjects.map((member) => ({ + id: member.physicalProjectKey, + label: formatProjectMemberActionLabel(member, project.groupedProjectCount), + })), + { + x: event.clientX, + y: event.clientY, + }, + ); + if (!clicked) { + return; + } + const targetMember = project.memberProjects.find( + (member) => member.physicalProjectKey === clicked, + ); + if (!targetMember) { + return; + } + await createBoardForProjectMember(targetMember); + })(); + }, + [createBoardForProjectMember, project.groupedProjectCount, project.memberProjects], + ); + const attemptArchiveThread = useCallback( async (threadRef: ScopedThreadRef) => { try { @@ -1994,6 +2572,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ], ); + const canCreateBoard = resolveRecentAgent() !== null; + return ( <>
@@ -2074,10 +2654,31 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec )} - - +
+ + + + + } + /> + + {canCreateBoard ? "Add board" : "No available agent"} + + + + -
- } - /> - - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} - -
+ } + /> + + {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} + + +
(typeof params.environmentId === "string" ? params.environmentId : null), + }); + const activeRouteBoardId = useLocation({ + select: (loc) => { + const search = loc.search as { readonly boardId?: unknown }; + return typeof search.boardId === "string" ? search.boardId : null; + }, + }); + const activeRouteBoardRef = useMemo( + () => + activeRouteEnvironmentId && activeRouteBoardId + ? { + environmentId: activeRouteEnvironmentId, + boardId: activeRouteBoardId, + } + : null, + [activeRouteBoardId, activeRouteEnvironmentId], + ); return ( @@ -2750,6 +3375,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( activeRouteThreadKey={ activeRouteProjectKey === project.projectKey ? routeThreadKey : null } + activeRouteBoardRef={activeRouteBoardRef} newThreadShortcutLabel={newThreadShortcutLabel} handleNewThread={handleNewThread} archiveThread={archiveThread} @@ -2782,6 +3408,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( activeRouteThreadKey={ activeRouteProjectKey === project.projectKey ? routeThreadKey : null } + activeRouteBoardRef={activeRouteBoardRef} newThreadShortcutLabel={newThreadShortcutLabel} handleNewThread={handleNewThread} archiveThread={archiveThread} diff --git a/apps/web/src/components/board/AgentSessionDialog.tsx b/apps/web/src/components/board/AgentSessionDialog.tsx new file mode 100644 index 00000000000..01615ccb37e --- /dev/null +++ b/apps/web/src/components/board/AgentSessionDialog.tsx @@ -0,0 +1,145 @@ +import type { + EnvironmentApi, + OrchestrationMessage, + OrchestrationThreadActivity, + OrchestrationThreadStreamItem, + ThreadId, +} from "@t3tools/contracts"; +import { MessagesSquareIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; +import { cn } from "~/lib/utils"; + +interface SessionState { + readonly messages: ReadonlyArray; + readonly activities: ReadonlyArray; +} + +/** + * Read-only view of the hidden orchestration thread behind an agent step — + * the full conversation (instruction, assistant replies) plus the activity + * log. Total transparency into what the agent actually did. + */ +export function AgentSessionDialog({ + api, + threadId, + stepKey, +}: { + readonly api: EnvironmentApi | null | undefined; + readonly threadId: ThreadId; + readonly stepKey: string; +}) { + const [open, setOpen] = useState(false); + const [session, setSession] = useState(null); + + useEffect(() => { + if (!open || !api) { + return; + } + setSession(null); + return api.orchestration.subscribeThread( + { threadId }, + (item: OrchestrationThreadStreamItem) => { + if (item.kind === "snapshot") { + setSession({ + messages: item.snapshot.thread.messages, + activities: item.snapshot.thread.activities, + }); + } + }, + ); + }, [api, open, threadId]); + + return ( + + + +
+ + Agent session · {stepKey} + + Read-only transcript of the agent run behind this step. + + +
+ {session === null ? ( +

Loading session…

+ ) : ( + <> + {session.messages.length === 0 ? ( +

No messages recorded.

+ ) : ( +
    + {session.messages.map((message) => ( +
  1. +
    + + {message.role === "user" ? "Instruction" : "Agent"} + + +
    +

    + {message.text} +

    +
  2. + ))} +
+ )} + {session.activities.length > 0 ? ( +
+ + Activity log ({session.activities.length}) + +
    + {session.activities.map((activity) => ( +
  1. + {activity.kind} + {activity.summary} +
  2. + ))} +
+
+ ) : null} + + )} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/board/BoardDigestDialog.tsx b/apps/web/src/components/board/BoardDigestDialog.tsx new file mode 100644 index 00000000000..41a25459715 --- /dev/null +++ b/apps/web/src/components/board/BoardDigestDialog.tsx @@ -0,0 +1,161 @@ +import type { WorkflowBoardDigest } from "@t3tools/contracts"; +import { NewspaperIcon } from "lucide-react"; +import { useRef, useState } from "react"; + +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; +import { formatDuration } from "~/session-logic"; +import { formatTokenCount } from "~/workflow/usageFormat"; + +/** + * The board's stand-up summary: what moved, what shipped, what it cost, and + * which tickets have been waiting on a human the longest. + */ +export function BoardDigestDialog({ + disabled, + needsAttentionCount, + onFetchDigest, +}: { + readonly disabled: boolean; + readonly needsAttentionCount: number; + readonly onFetchDigest: () => Promise; +}) { + const [open, setOpen] = useState(false); + const [digest, setDigest] = useState(null); + const [error, setError] = useState(null); + // A close (or re-open) invalidates in-flight fetches so a slow response + // can never repopulate the dialog with stale content. + const requestRef = useRef(0); + + const load = async () => { + const requestId = ++requestRef.current; + setError(null); + setDigest(null); + try { + const next = await onFetchDigest(); + if (requestRef.current === requestId) { + setDigest(next); + } + } catch (cause) { + if (requestRef.current === requestId) { + setError(cause instanceof Error ? cause.message : "Failed to load the digest."); + } + } + }; + + return ( + { + setOpen(nextOpen); + if (!nextOpen) { + requestRef.current += 1; + setDigest(null); + } + }} + > + + +
+ + Board digest + + The last {digest?.windowHours ?? 24} hours on this board. + + +
+ {error !== null ? ( +

+ {error} +

+ ) : digest === null ? ( +

Loading…

+ ) : ( + <> +
+
+
Shipped
+
{digest.shippedCount}
+
+
+
Created
+
{digest.createdCount}
+
+
+
Tokens spent
+
+ {digest.totalTokens > 0 ? formatTokenCount(digest.totalTokens) : "0"} +
+
+
+
Agent time
+
+ {digest.totalDurationMs > 0 ? formatDuration(digest.totalDurationMs) : "0"} +
+
+
+
+

+ Waiting on you +

+ {digest.needsAttention.length === 0 ? ( +

+ Nothing — the board is running itself. +

+ ) : ( +
    + {digest.needsAttention.map((ticket) => ( +
  1. + + {ticket.title} + + + {ticket.status === "blocked" ? "blocked" : "waiting"} ·{" "} + {formatDuration(ticket.sinceMs)} + +
  2. + ))} +
+ )} +
+ + )} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/board/BoardHeaderControls.browser.tsx b/apps/web/src/components/board/BoardHeaderControls.browser.tsx new file mode 100644 index 00000000000..4c10c7dd4b8 --- /dev/null +++ b/apps/web/src/components/board/BoardHeaderControls.browser.tsx @@ -0,0 +1,59 @@ +import "../../index.css"; + +import { page } from "vite-plus/test/browser"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { BoardHeaderControls } from "./BoardHeaderControls"; + +const lanes = [ + { key: "backlog", name: "Backlog", entry: "manual", pipelineStepCount: 0 }, + { key: "implement", name: "Implement", entry: "auto", pipelineStepCount: 2 }, +] as const; + +describe("BoardHeaderControls", () => { + it("opens a create-ticket dialog and submits title plus description", async () => { + const onCreateTicket = vi.fn(); + render( + , + ); + + await expect.element(page.getByLabelText("New ticket title")).not.toBeInTheDocument(); + + await page.getByRole("button", { name: "New ticket" }).click(); + await expect.element(page.getByRole("heading", { name: "New ticket" })).toBeInTheDocument(); + + await page.getByLabelText("Ticket title").fill("Ship workflow modal"); + await page + .getByLabelText("Ticket description") + .fill("Acceptance criteria and implementation notes."); + await page.getByRole("button", { name: "Create ticket" }).click(); + + await vi.waitFor(() => { + expect(onCreateTicket).toHaveBeenCalledWith({ + title: "Ship workflow modal", + description: "Acceptance criteria and implementation notes.", + initialLane: "backlog", + }); + }); + }); + + it("toggles the workflow editor from the board header", async () => { + const onToggleWorkflowEditor = vi.fn(); + render( + {}} + onToggleWorkflowEditor={onToggleWorkflowEditor} + />, + ); + + await page.getByRole("button", { name: "Edit workflow" }).click(); + + await vi.waitFor(() => { + expect(onToggleWorkflowEditor).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/apps/web/src/components/board/BoardHeaderControls.test.tsx b/apps/web/src/components/board/BoardHeaderControls.test.tsx new file mode 100644 index 00000000000..378b3e420a9 --- /dev/null +++ b/apps/web/src/components/board/BoardHeaderControls.test.tsx @@ -0,0 +1,70 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { BoardHeaderControls, getDefaultInitialLane } from "./BoardHeaderControls"; + +const lanes = [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { key: "implement", name: "Implement", entry: "auto" }, +] as const; + +describe("BoardHeaderControls", () => { + it("defaults new tickets to the first board lane", () => { + expect(getDefaultInitialLane(lanes)).toBe("backlog"); + expect(getDefaultInitialLane([])).toBeNull(); + }); + + it("renders only closed board action triggers in the board header", () => { + const markup = renderToStaticMarkup( + {}} />, + ); + + expect(markup).not.toContain("Register board"); + expect(markup).toContain("New ticket"); + expect(markup).not.toContain("Edit workflow"); + expect(markup).not.toContain("New ticket title"); + expect(markup).not.toContain("Backlog"); + expect(markup).not.toContain("Implement"); + }); + + it("renders the intake trigger only when proposing is wired", () => { + const without = renderToStaticMarkup( + {}} />, + ); + expect(without).not.toContain("Intake"); + + const withIntake = renderToStaticMarkup( + {}} + onProposeTickets={async () => []} + />, + ); + expect(withIntake).toContain("Intake"); + }); + + it("renders the workflow editor toggle when provided", () => { + const markup = renderToStaticMarkup( + {}} + onToggleWorkflowEditor={() => {}} + />, + ); + + expect(markup).toMatch(/]*type="button"[^>]*>.*Edit workflow<\/button>/s); + expect(markup).not.toContain("New ticket title"); + expect(markup).not.toContain("Backlog"); + }); + + it("renders the New ticket action as a dialog trigger button", () => { + const markup = renderToStaticMarkup( + {}} />, + ); + + expect(markup).toMatch(/]*type="button"[^>]*>.*New ticket<\/button>/s); + }); +}); diff --git a/apps/web/src/components/board/BoardHeaderControls.tsx b/apps/web/src/components/board/BoardHeaderControls.tsx new file mode 100644 index 00000000000..81e63fdb136 --- /dev/null +++ b/apps/web/src/components/board/BoardHeaderControls.tsx @@ -0,0 +1,310 @@ +import type { + AgentSelection, + WorkflowBoardDigest, + WorkflowWebhookConfig, +} from "@t3tools/contracts"; +import { PencilIcon, PlusIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, +} from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; +import { Textarea } from "~/components/ui/textarea"; +import type { IntakeTicketInput } from "~/workflow/intakeState"; + +import { BoardDigestDialog } from "./BoardDigestDialog"; +import { IntakeDialog } from "./IntakeDialog"; +import { WebhookConfigDialog } from "./WebhookConfigDialog"; + +export interface BoardHeaderLane { + readonly key: string; + readonly name: string; +} + +export interface NewTicketInput { + readonly title: string; + readonly description?: string | undefined; + readonly initialLane: string; + readonly dependsOn?: ReadonlyArray | undefined; + readonly tokenBudget?: number | undefined; +} + +export interface BoardHeaderTicketOption { + readonly ticketId: string; + readonly title: string; +} + +export const getDefaultInitialLane = (lanes: ReadonlyArray): string | null => + lanes[0]?.key ?? null; + +export function BoardHeaderControls({ + boardId, + lanes, + tickets = [], + workflowEditorOpen = false, + intakeDisabledReason, + needsAttentionCount = 0, + onCreateTicket, + onCreateTicketAsync, + onProposeTickets, + onToggleWorkflowEditor, + onFetchDigest, + onFetchWebhookConfig, +}: { + readonly boardId: string | null; + readonly lanes: ReadonlyArray; + readonly tickets?: ReadonlyArray; + readonly workflowEditorOpen?: boolean | undefined; + readonly intakeDisabledReason?: string | undefined; + readonly onCreateTicket: (input: NewTicketInput) => void; + readonly onCreateTicketAsync?: ((input: NewTicketInput) => Promise) | undefined; + readonly onProposeTickets?: + | ((braindump: string, agent: AgentSelection) => Promise>) + | undefined; + readonly onToggleWorkflowEditor?: (() => void) | undefined; + readonly needsAttentionCount?: number | undefined; + readonly onFetchDigest?: (() => Promise) | undefined; + readonly onFetchWebhookConfig?: ((rotate: boolean) => Promise) | undefined; +}) { + const [open, setOpen] = useState(false); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [initialLane, setInitialLane] = useState(() => getDefaultInitialLane(lanes) ?? ""); + const [dependsOn, setDependsOn] = useState>([]); + const [tokenBudget, setTokenBudget] = useState(""); + + useEffect(() => { + if (lanes.some((lane) => lane.key === initialLane)) { + return; + } + setInitialLane(getDefaultInitialLane(lanes) ?? ""); + }, [initialLane, lanes]); + + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + const canCreateTicket = Boolean(boardId && initialLane && trimmedTitle); + + const resetForm = () => { + setTitle(""); + setDescription(""); + setInitialLane(getDefaultInitialLane(lanes) ?? ""); + setDependsOn([]); + setTokenBudget(""); + }; + + return ( +
+ {onFetchWebhookConfig ? ( + + ) : null} + {onFetchDigest ? ( + + ) : null} + {onToggleWorkflowEditor ? ( + + ) : null} + {onProposeTickets ? ( + { + const lane = getDefaultInitialLane(lanes); + if (lane === null) { + return; + } + // Sequential so dependency edges can reference the ids of the + // tickets created earlier in this same batch. + const createdIds: Array = []; + for (const ticket of tickets) { + const dependsOn = ticket.dependsOnIndices + .map((index) => createdIds[index]) + .filter((ticketId): ticketId is string => ticketId !== undefined); + const input = { + title: ticket.title, + ...(ticket.description === undefined ? {} : { description: ticket.description }), + initialLane: lane, + ...(dependsOn.length > 0 ? { dependsOn } : {}), + }; + if (onCreateTicketAsync) { + createdIds.push((await onCreateTicketAsync(input)) ?? undefined); + } else { + onCreateTicket(input); + createdIds.push(undefined); + } + } + }} + /> + ) : null} + { + setOpen(nextOpen); + if (!nextOpen) { + resetForm(); + } + }} + > + + +
{ + event.preventDefault(); + if (!canCreateTicket) { + return; + } + + const parsedBudget = Number.parseInt(tokenBudget, 10); + onCreateTicket({ + title: trimmedTitle, + ...(trimmedDescription ? { description: trimmedDescription } : {}), + initialLane, + ...(dependsOn.length > 0 ? { dependsOn } : {}), + ...(Number.isFinite(parsedBudget) && parsedBudget > 0 + ? { tokenBudget: parsedBudget } + : {}), + }); + resetForm(); + setOpen(false); + }} + > + + New ticket + + Capture the work request, context, and acceptance criteria before adding it to the + board. + + +
+ +