From 9ca88d42fdcc76632377e26cba88b174d2d5735a Mon Sep 17 00:00:00 2001 From: Christopher Dwyer Date: Sat, 13 Jun 2026 18:42:39 -0400 Subject: [PATCH] Workflow boards: kanban state machines that drive coding agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-project boards-as-state-machines: lanes hold pipelines of agent / approval / script steps; one git worktree per ticket; event-sourced bounded context (apps/server/src/workflow/**) with projections, durable saga, worktree leases, lane-entry tokens, and durable approvals. Includes: - v1 boards + board-creation UX + script steps + smart routing (captured step output, JSON-logic predicates, on:{success,failure,blocked}, lane transitions/onEvent) + WIP enforcement (queue + FIFO auto-admit) + visual editor (form + canvas) + version history/revert + delete-board. - GitHub PR loop: PullRequestStep + a poller that observes CI/review/merge and routes tickets via synthetic external events (two-phase durable outbox). - Board notifications + minimal mobile surface: server outbox → recovery-gated dispatcher → env-signed relay publish → APNs → deep-link to a ticket action sheet + a "Needs you" inbox. - Work arrives by itself: one-way GitHub Issues + Asana task sync. Board declares sync sources (PAT in the secret store); a recovery-gated syncer pulls items, diffs against a durable mapping (content-hash + scan completeness), and reconciles through a lock-safe source committer (admission->save->tx, atomic create+map, version-gated edit, source-aware close, orphan-then-confirm) so synced tickets flow through all the existing board automation. Read-only "Synced from" badge on web + mobile. Single migration (033_WorkflowSchema). The live device-push (APNs to a physical phone) and the live GitHub+Asana sync against real credentials are the remaining human gates. --- .gitignore | 1 + .t3/boards/delivery.json | 73 + apps/mobile/src/app/_layout.tsx | 10 + apps/mobile/src/app/needs-you.tsx | 5 + .../[boardId]/[ticketId]/index.tsx | 5 + .../notificationPayload.test.ts | 290 + .../agent-awareness/notificationPayload.ts | 69 +- .../agent-awareness/registrationPayload.ts | 1 + .../remoteRegistration.test.ts | 2 + .../features/board/NeedsYouInboxScreen.tsx | 190 + .../board/TicketActionSheetScreen.tsx | 408 ++ .../features/board/ticketAffordance.test.ts | 268 + .../src/features/board/ticketAffordance.ts | 118 + apps/mobile/src/lib/routes.ts | 12 +- apps/server/package.json | 1 + apps/server/src/auth/EnvironmentAuth.test.ts | 21 +- .../src/auth/EnvironmentAuthAdmin.test.ts | 23 +- .../server/src/auth/PairingGrantStore.test.ts | 20 +- apps/server/src/auth/SessionStore.test.ts | 9 +- apps/server/src/auth/http.ts | 4 + apps/server/src/bin.test.ts | 24 +- .../Layers/CheckpointDiffQuery.test.ts | 5 + apps/server/src/git/GitManager.test.ts | 35 + .../Layers/OrchestrationEngine.test.ts | 1 + .../Layers/ProjectionPipeline.ts | 1 + .../Layers/ProjectionSnapshotQuery.test.ts | 117 + .../Layers/ProjectionSnapshotQuery.ts | 58 +- .../Services/ProjectionSnapshotQuery.ts | 8 + apps/server/src/orchestration/decider.ts | 1 + .../persistence/Layers/ProjectionThreads.ts | 15 +- apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/033_WorkflowSchema.test.ts | 466 ++ .../Migrations/033_WorkflowSchema.ts | 413 ++ .../persistence/Services/ProjectionThreads.ts | 4 + .../Layers/ProjectSetupScriptRunner.test.ts | 5 + .../src/provider/Layers/ClaudeAdapter.test.ts | 84 + .../src/provider/Layers/ClaudeAdapter.ts | 12 +- .../src/provider/Layers/CodexProvider.test.ts | 53 + .../src/provider/Layers/CodexProvider.ts | 18 +- .../provider/Layers/OpenCodeAdapter.test.ts | 146 +- .../src/provider/Layers/OpenCodeAdapter.ts | 68 +- .../Layers/ProviderSessionReaper.test.ts | 1 + .../src/relay/AgentAwarenessRelay.test.ts | 2 + apps/server/src/relay/AgentAwarenessRelay.ts | 6 +- apps/server/src/server.test.ts | 106 +- apps/server/src/server.ts | 32 +- apps/server/src/serverRuntimeStartup.test.ts | 4 + apps/server/src/serverRuntimeStartup.ts | 65 + .../src/sourceControl/GitHubCli.test.ts | 322 ++ apps/server/src/sourceControl/GitHubCli.ts | 248 +- .../src/terminal/Layers/Manager.test.ts | 178 + apps/server/src/terminal/Layers/Manager.ts | 119 + apps/server/src/terminal/Services/Manager.ts | 20 + .../src/workflow/Layers/ApprovalGate.test.ts | 21 + .../src/workflow/Layers/ApprovalGate.ts | 69 + .../src/workflow/Layers/AsanaProvider.test.ts | 483 ++ .../src/workflow/Layers/AsanaProvider.ts | 324 ++ .../workflow/Layers/BoardDiscovery.test.ts | 551 ++ .../src/workflow/Layers/BoardDiscovery.ts | 255 + .../src/workflow/Layers/BoardRegistry.test.ts | 109 + .../src/workflow/Layers/BoardRegistry.ts | 80 + .../Layers/CapturedStepOutputReader.test.ts | 242 + .../Layers/CapturedStepOutputReader.ts | 94 + .../Layers/DurableApprovalResume.test.ts | 418 ++ .../workflow/Layers/DurableApprovalResume.ts | 90 + .../src/workflow/Layers/GitHubPort.test.ts | 794 +++ apps/server/src/workflow/Layers/GitHubPort.ts | 374 ++ .../Layers/GithubIssuesProvider.test.ts | 446 ++ .../workflow/Layers/GithubIssuesProvider.ts | 331 ++ .../src/workflow/Layers/MockAcpProvider.ts | 94 + .../Layers/PredicateEvaluator.test.ts | 69 + .../src/workflow/Layers/PredicateEvaluator.ts | 53 + .../Layers/ProjectScriptTrust.test.ts | 27 + .../src/workflow/Layers/ProjectScriptTrust.ts | 52 + .../Layers/ProjectWorkspaceResolver.test.ts | 70 + .../Layers/ProjectWorkspaceResolver.ts | 37 + .../Layers/ProviderDispatchOutbox.test.ts | 393 ++ .../workflow/Layers/ProviderDispatchOutbox.ts | 481 ++ .../Layers/ProviderResponsePort.test.ts | 94 + .../workflow/Layers/ProviderResponsePort.ts | 55 + .../workflow/Layers/ProviderTurnPort.test.ts | 171 + .../workflow/Layers/RealStepExecutor.test.ts | 1555 ++++++ .../src/workflow/Layers/RealStepExecutor.ts | 698 +++ .../Layers/ScriptCancelRegistry.test.ts | 43 + .../workflow/Layers/ScriptCancelRegistry.ts | 44 + .../Layers/ScriptCommandRunner.test.ts | 243 + .../workflow/Layers/ScriptCommandRunner.ts | 104 + .../Layers/ScriptStepExecutor.test.ts | 250 + .../src/workflow/Layers/ScriptStepExecutor.ts | 150 + .../workflow/Layers/SetupRunService.test.ts | 131 + .../src/workflow/Layers/SetupRunService.ts | 195 + .../workflow/Layers/StepUsageReader.test.ts | 94 + .../src/workflow/Layers/StepUsageReader.ts | 47 + .../workflow/Layers/StubStepExecutor.test.ts | 29 + .../src/workflow/Layers/StubStepExecutor.ts | 15 + .../TicketCheckpointService.migration.test.ts | 21 + .../Layers/TicketCheckpointService.test.ts | 99 + .../Layers/TicketCheckpointService.ts | 61 + .../workflow/Layers/TicketDiffQuery.test.ts | 125 + .../src/workflow/Layers/TicketDiffQuery.ts | 99 + .../Layers/TicketMergeService.test.ts | 253 + .../src/workflow/Layers/TicketMergeService.ts | 170 + .../Layers/TicketPullRequestService.test.ts | 408 ++ .../Layers/TicketPullRequestService.ts | 166 + .../workflow/Layers/TurnStateReader.test.ts | 102 + .../src/workflow/Layers/TurnStateReader.ts | 160 + .../Layers/WorkSourceConnectionStore.test.ts | 188 + .../Layers/WorkSourceConnectionStore.ts | 191 + .../Layers/WorkSourceProviderRegistry.test.ts | 46 + .../Layers/WorkSourceProviderRegistry.ts | 23 + .../Layers/WorkflowBoardEvents.test.ts | 66 + .../workflow/Layers/WorkflowBoardEvents.ts | 23 + ...orkflowBoardNotificationDispatcher.test.ts | 379 ++ .../WorkflowBoardNotificationDispatcher.ts | 250 + .../WorkflowBoardNotificationRelay.test.ts | 349 ++ .../Layers/WorkflowBoardNotificationRelay.ts | 165 + .../workflow/Layers/WorkflowBoardSaveLocks.ts | 44 + .../Layers/WorkflowBoardVersionStore.test.ts | 88 + .../Layers/WorkflowBoardVersionStore.ts | 100 + .../Layers/WorkflowEngine.concurrency.test.ts | 701 +++ .../WorkflowEngine.dependencies.test.ts | 212 + .../WorkflowEngine.externalEvents.test.ts | 481 ++ .../Layers/WorkflowEngine.integration.test.ts | 2029 +++++++ .../Layers/WorkflowEngine.retry.test.ts | 614 ++ .../WorkflowEngine.token-migration.test.ts | 21 + .../src/workflow/Layers/WorkflowEngine.ts | 2586 +++++++++ .../Layers/WorkflowEngine.wip.test.ts | 557 ++ .../Layers/WorkflowEngine.workSource.test.ts | 611 ++ .../Layers/WorkflowEventCommitter.test.ts | 938 ++++ .../workflow/Layers/WorkflowEventCommitter.ts | 414 ++ .../Layers/WorkflowEventStore.test.ts | 179 + .../src/workflow/Layers/WorkflowEventStore.ts | 165 + .../Layers/WorkflowFileLoader.test.ts | 469 ++ .../src/workflow/Layers/WorkflowFileLoader.ts | 199 + .../Layers/WorkflowGitHubPoller.test.ts | 983 ++++ .../workflow/Layers/WorkflowGitHubPoller.ts | 633 +++ .../src/workflow/Layers/WorkflowIds.test.ts | 19 + .../server/src/workflow/Layers/WorkflowIds.ts | 61 + .../workflow/Layers/WorkflowIntake.test.ts | 188 + .../src/workflow/Layers/WorkflowIntake.ts | 228 + .../Layers/WorkflowProjectionPipeline.test.ts | 1131 ++++ .../Layers/WorkflowProjectionPipeline.ts | 512 ++ .../workflow/Layers/WorkflowReadModel.test.ts | 2085 +++++++ .../src/workflow/Layers/WorkflowReadModel.ts | 1132 ++++ .../workflow/Layers/WorkflowRecovery.test.ts | 3084 ++++++++++ .../src/workflow/Layers/WorkflowRecovery.ts | 1112 ++++ .../WorkflowRoutingContextBuilder.test.ts | 156 + .../Layers/WorkflowRoutingContextBuilder.ts | 47 + .../Layers/WorkflowRpcHandlers.test.ts | 4973 +++++++++++++++++ .../workflow/Layers/WorkflowRpcHandlers.ts | 1384 +++++ .../WorkflowRuntime.integration.test.ts | 1102 ++++ .../Layers/WorkflowRuntime.realpath.test.ts | 1468 +++++ .../Layers/WorkflowSourceCommitter.test.ts | 759 +++ .../Layers/WorkflowSourceCommitter.ts | 400 ++ .../Layers/WorkflowSourceSyncer.test.ts | 603 ++ .../workflow/Layers/WorkflowSourceSyncer.ts | 432 ++ .../WorkflowTerminalRetentionSweeper.test.ts | 563 ++ .../WorkflowTerminalRetentionSweeper.ts | 346 ++ .../workflow/Layers/WorkflowThreadJanitor.ts | 67 + .../workflow/Layers/WorkflowWebhook.test.ts | 138 + .../src/workflow/Layers/WorkflowWebhook.ts | 112 + .../Layers/WorkflowWorktreeJanitor.test.ts | 113 + .../Layers/WorkflowWorktreeJanitor.ts | 183 + .../WorktreeLeaseService.migration.test.ts | 23 + .../Layers/WorktreeLeaseService.test.ts | 40 + .../workflow/Layers/WorktreeLeaseService.ts | 96 + .../src/workflow/Services/ApprovalGate.ts | 13 + .../src/workflow/Services/BoardDiscovery.ts | 17 + .../src/workflow/Services/BoardRegistry.ts | 29 + .../Services/CapturedStepOutputReader.ts | 22 + .../Services/DurableApprovalResume.ts | 13 + apps/server/src/workflow/Services/Errors.ts | 9 + .../src/workflow/Services/GitHubPort.ts | 70 + .../workflow/Services/PredicateEvaluator.ts | 28 + .../workflow/Services/ProjectScriptTrust.ts | 24 + .../Services/ProjectWorkspaceResolver.ts | 21 + .../Services/ProviderDispatchOutbox.ts | 84 + .../workflow/Services/ProviderResponsePort.ts | 25 + .../workflow/Services/ScriptCancelRegistry.ts | 21 + .../workflow/Services/ScriptCommandRunner.ts | 33 + .../workflow/Services/ScriptStepExecutor.ts | 26 + .../src/workflow/Services/SetupRunService.ts | 45 + .../src/workflow/Services/StepExecutor.ts | 28 + .../src/workflow/Services/StepUsageReader.ts | 16 + .../Services/TicketCheckpointService.ts | 27 + .../src/workflow/Services/TicketDiffQuery.ts | 31 + .../workflow/Services/TicketMergeService.ts | 40 + .../Services/TicketPullRequestService.ts | 24 + .../src/workflow/Services/TurnStateReader.ts | 35 + .../Services/WorkSourceConnectionStore.ts | 50 + .../workflow/Services/WorkSourceProvider.ts | 92 + .../workflow/Services/WorkflowBoardEvents.ts | 14 + .../WorkflowBoardNotificationDispatcher.ts | 20 + .../WorkflowBoardNotificationRelay.ts | 20 + .../Services/WorkflowBoardSaveLocks.ts | 15 + .../Services/WorkflowBoardVersionStore.ts | 44 + .../src/workflow/Services/WorkflowEngine.ts | 139 + .../Services/WorkflowEventCommitter.ts | 37 + .../workflow/Services/WorkflowEventStore.ts | 32 + .../workflow/Services/WorkflowFileLoader.ts | 46 + .../workflow/Services/WorkflowGitHubPoller.ts | 24 + .../src/workflow/Services/WorkflowIds.ts | 28 + .../src/workflow/Services/WorkflowIntake.ts | 27 + .../Services/WorkflowProjectionPipeline.ts | 14 + .../workflow/Services/WorkflowReadModel.ts | 312 ++ .../src/workflow/Services/WorkflowRecovery.ts | 12 + .../Services/WorkflowRoutingContextBuilder.ts | 40 + .../Services/WorkflowSourceCommitter.ts | 91 + .../workflow/Services/WorkflowSourceSyncer.ts | 22 + .../WorkflowTerminalRetentionSweeper.ts | 19 + .../Services/WorkflowThreadJanitor.ts | 29 + .../src/workflow/Services/WorkflowWebhook.ts | 61 + .../Services/WorkflowWorktreeJanitor.ts | 25 + .../workflow/Services/WorktreeLeaseService.ts | 29 + .../src/workflow/Services/WorktreePort.ts | 24 + .../src/workflow/WorkflowEngineLive.test.ts | 70 + .../server/src/workflow/WorkflowEngineLive.ts | 23 + .../workflow/WorkflowFoundationLive.test.ts | 74 + .../src/workflow/WorkflowFoundationLive.ts | 20 + .../src/workflow/WorkflowRuntimeLive.ts | 186 + .../server/src/workflow/boardDeletion.test.ts | 347 ++ apps/server/src/workflow/boardDeletion.ts | 109 + apps/server/src/workflow/boardSlug.test.ts | 16 + apps/server/src/workflow/boardSlug.ts | 15 + apps/server/src/workflow/defaultBoard.test.ts | 83 + apps/server/src/workflow/defaultBoard.ts | 244 + apps/server/src/workflow/dryRun.test.ts | 229 + apps/server/src/workflow/dryRun.ts | 190 + apps/server/src/workflow/externalEvent.ts | 49 + apps/server/src/workflow/instructionPath.ts | 24 + .../src/workflow/instructionTemplate.test.ts | 144 + .../src/workflow/instructionTemplate.ts | 90 + apps/server/src/workflow/jsonLogicRule.ts | 83 + .../src/workflow/redactSensitiveText.test.ts | 216 + .../src/workflow/redactSensitiveText.ts | 91 + .../src/workflow/sampleBoardFile.test.ts | 57 + .../src/workflow/sourceReconcileDiff.test.ts | 380 ++ .../src/workflow/sourceReconcileDiff.ts | 193 + apps/server/src/workflow/ticketMessageBody.ts | 13 + apps/server/src/workflow/ticketRefs.test.ts | 16 + apps/server/src/workflow/ticketRefs.ts | 20 + apps/server/src/workflow/webhookRoute.ts | 192 + apps/server/src/workflow/workflowFile.test.ts | 995 ++++ apps/server/src/workflow/workflowFile.ts | 583 ++ .../src/workflow/workflowVersionHash.ts | 4 + .../Layers/WorkspaceFileSystem.test.ts | 210 + .../workspace/Layers/WorkspaceFileSystem.ts | 297 +- .../workspace/Services/WorkspaceFileSystem.ts | 47 + apps/server/src/ws.ts | 212 +- apps/web/src/components/ChatView.browser.tsx | 593 ++ apps/web/src/components/ChatView.tsx | 14 + apps/web/src/components/RightPanelSheet.tsx | 3 +- apps/web/src/components/Sidebar.logic.test.ts | 51 + apps/web/src/components/Sidebar.logic.ts | 38 + apps/web/src/components/Sidebar.tsx | 662 ++- .../components/board/AgentSessionDialog.tsx | 145 + .../components/board/BoardDigestDialog.tsx | 161 + .../board/BoardHeaderControls.browser.tsx | 59 + .../board/BoardHeaderControls.test.tsx | 70 + .../components/board/BoardHeaderControls.tsx | 310 + .../src/components/board/BoardView.test.tsx | 74 + apps/web/src/components/board/BoardView.tsx | 105 + .../web/src/components/board/IntakeDialog.tsx | 338 ++ apps/web/src/components/board/LaneColumn.tsx | 82 + .../board/StepActivityFeed.browser.tsx | 95 + .../src/components/board/StepActivityFeed.tsx | 107 + .../src/components/board/TicketArtifacts.tsx | 87 + .../src/components/board/TicketCard.test.tsx | 196 + apps/web/src/components/board/TicketCard.tsx | 202 + apps/web/src/components/board/TicketDiff.tsx | 138 + .../components/board/TicketDrawer.browser.tsx | 515 ++ .../components/board/TicketDrawer.test.tsx | 408 ++ .../web/src/components/board/TicketDrawer.tsx | 1034 ++++ .../components/board/WebhookConfigDialog.tsx | 182 + .../components/board/editor/DryRunPanel.tsx | 150 + .../src/components/board/editor/LaneForm.tsx | 288 + .../src/components/board/editor/LaneList.tsx | 81 + .../board/editor/PipelineEditor.tsx | 164 + .../components/board/editor/RoutingEditor.tsx | 427 ++ .../board/editor/SourcesSection.tsx | 681 +++ .../board/editor/StepFields.browser.tsx | 435 ++ .../components/board/editor/StepFields.tsx | 824 +++ .../board/editor/WorkflowEditor.browser.tsx | 936 ++++ .../board/editor/WorkflowEditor.tsx | 599 ++ .../editor/WorkflowEditorFullscreen.test.tsx | 30 + .../board/editor/WorkflowEditorFullscreen.tsx | 52 + .../board/editor/agentStepSelection.test.ts | 49 + .../board/editor/agentStepSelection.ts | 91 + .../editor/canvas/CanvasView.browser.tsx | 880 +++ .../board/editor/canvas/CanvasView.tsx | 580 ++ .../board/editor/canvas/LaneCard.tsx | 239 + .../board/editor/canvas/RoutingEdges.tsx | 492 ++ .../editor/canvas/RoutingHandles.test.ts | 92 + .../board/editor/canvas/RoutingHandles.tsx | 218 + .../board/editor/canvas/StepBlock.tsx | 163 + .../board/editor/canvas/canvasLayout.test.ts | 126 + .../board/editor/canvas/canvasLayout.ts | 225 + .../board/editor/canvas/edgeRouting.test.ts | 156 + .../board/editor/canvas/edgeRouting.ts | 174 + .../board/editor/history/DiffView.browser.tsx | 35 + .../board/editor/history/DiffView.test.ts | 22 + .../board/editor/history/DiffView.tsx | 126 + .../editor/history/VersionHistoryPanel.tsx | 218 + apps/web/src/components/chat/TraitsPicker.tsx | 6 + .../settings/SettingsSidebarNav.tsx | 3 + .../WorkSourceConnectionsSettings.tsx | 340 ++ apps/web/src/environmentApi.ts | 34 + apps/web/src/environmentGrouping.test.ts | 2 + .../service.threadSubscriptions.test.ts | 33 + apps/web/src/localApi.test.ts | 63 +- apps/web/src/routeTree.gen.ts | 42 + apps/web/src/routes/-boardRouteState.test.ts | 195 + .../src/routes/-workflowEditorSurface.test.ts | 10 + .../src/routes/_chat.$environmentId.board.tsx | 655 +++ apps/web/src/routes/settings.work-sources.tsx | 7 + apps/web/src/store.test.ts | 6 + apps/web/src/store.ts | 57 + apps/web/src/vite-raw.d.ts | 4 + apps/web/src/workflow/agingFormat.test.ts | 37 + apps/web/src/workflow/agingFormat.ts | 43 + apps/web/src/workflow/boardListState.test.ts | 54 + apps/web/src/workflow/boardRpc.test.ts | 91 + apps/web/src/workflow/boardRpc.ts | 81 + apps/web/src/workflow/boardState.test.ts | 64 + apps/web/src/workflow/boardState.ts | 162 + apps/web/src/workflow/dryRunFormat.test.ts | 69 + apps/web/src/workflow/dryRunFormat.ts | 33 + apps/web/src/workflow/editorModel.test.ts | 501 ++ apps/web/src/workflow/editorModel.ts | 586 ++ apps/web/src/workflow/intakeState.test.ts | 53 + apps/web/src/workflow/intakeState.ts | 73 + .../src/workflow/resolveRecentAgent.test.ts | 236 + apps/web/src/workflow/resolveRecentAgent.ts | 92 + apps/web/src/workflow/routeDecision.test.ts | 103 + apps/web/src/workflow/routeDecision.ts | 93 + apps/web/src/workflow/usageFormat.ts | 49 + apps/web/src/workflow/useNowTick.ts | 15 + docs/workflow-boards/github-flow-example.json | 127 + .../src/agentActivity/ApnsClient.test.ts | 69 + infra/relay/src/agentActivity/ApnsClient.ts | 10 +- .../relay/src/agentActivity/ApnsDeliveries.ts | 8 +- .../src/agentActivity/ApnsDeliveryQueue.ts | 9 +- .../BoardTicketPublisher.test.ts | 479 ++ .../src/agentActivity/BoardTicketPublisher.ts | 189 + infra/relay/src/agentActivity/Devices.test.ts | 83 + infra/relay/src/agentActivity/Devices.ts | 1 + .../src/agentActivity/apnsDeliveryJobs.ts | 6 +- .../EnvironmentPublishSignatures.test.ts | 89 + .../EnvironmentPublishSignatures.ts | 90 + infra/relay/src/http/Api.ts | 52 + infra/relay/src/worker.ts | 3 +- .../client-runtime/src/managedRelay.test.ts | 1 + .../src/managedRelayState.test.ts | 1 + .../client-runtime/src/wsRpcClient.test.ts | 108 +- packages/client-runtime/src/wsRpcClient.ts | 122 + packages/contracts/package.json | 4 + packages/contracts/src/auth.test.ts | 26 + packages/contracts/src/auth.ts | 6 + packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 137 +- packages/contracts/src/orchestration.ts | 4 + packages/contracts/src/relay.test.ts | 134 +- packages/contracts/src/relay.ts | 52 + packages/contracts/src/rpc.ts | 310 + packages/contracts/src/terminal.test.ts | 27 + packages/contracts/src/terminal.ts | 30 + packages/contracts/src/workSource.test.ts | 29 + packages/contracts/src/workSource.ts | 42 + packages/contracts/src/workflow.test.ts | 1556 ++++++ packages/contracts/src/workflow.ts | 1063 ++++ packages/contracts/src/workflowRpc.test.ts | 160 + .../effect-codex-app-server/src/client.ts | 20 +- pnpm-lock.yaml | 8 + 373 files changed, 82417 insertions(+), 191 deletions(-) create mode 100644 .t3/boards/delivery.json create mode 100644 apps/mobile/src/app/needs-you.tsx create mode 100644 apps/mobile/src/app/tickets/[environmentId]/[boardId]/[ticketId]/index.tsx create mode 100644 apps/mobile/src/features/agent-awareness/notificationPayload.test.ts create mode 100644 apps/mobile/src/features/board/NeedsYouInboxScreen.tsx create mode 100644 apps/mobile/src/features/board/TicketActionSheetScreen.tsx create mode 100644 apps/mobile/src/features/board/ticketAffordance.test.ts create mode 100644 apps/mobile/src/features/board/ticketAffordance.ts create mode 100644 apps/server/src/persistence/Migrations/033_WorkflowSchema.test.ts create mode 100644 apps/server/src/persistence/Migrations/033_WorkflowSchema.ts create mode 100644 apps/server/src/workflow/Layers/ApprovalGate.test.ts create mode 100644 apps/server/src/workflow/Layers/ApprovalGate.ts create mode 100644 apps/server/src/workflow/Layers/AsanaProvider.test.ts create mode 100644 apps/server/src/workflow/Layers/AsanaProvider.ts create mode 100644 apps/server/src/workflow/Layers/BoardDiscovery.test.ts create mode 100644 apps/server/src/workflow/Layers/BoardDiscovery.ts create mode 100644 apps/server/src/workflow/Layers/BoardRegistry.test.ts create mode 100644 apps/server/src/workflow/Layers/BoardRegistry.ts create mode 100644 apps/server/src/workflow/Layers/CapturedStepOutputReader.test.ts create mode 100644 apps/server/src/workflow/Layers/CapturedStepOutputReader.ts create mode 100644 apps/server/src/workflow/Layers/DurableApprovalResume.test.ts create mode 100644 apps/server/src/workflow/Layers/DurableApprovalResume.ts create mode 100644 apps/server/src/workflow/Layers/GitHubPort.test.ts create mode 100644 apps/server/src/workflow/Layers/GitHubPort.ts create mode 100644 apps/server/src/workflow/Layers/GithubIssuesProvider.test.ts create mode 100644 apps/server/src/workflow/Layers/GithubIssuesProvider.ts create mode 100644 apps/server/src/workflow/Layers/MockAcpProvider.ts create mode 100644 apps/server/src/workflow/Layers/PredicateEvaluator.test.ts create mode 100644 apps/server/src/workflow/Layers/PredicateEvaluator.ts create mode 100644 apps/server/src/workflow/Layers/ProjectScriptTrust.test.ts create mode 100644 apps/server/src/workflow/Layers/ProjectScriptTrust.ts create mode 100644 apps/server/src/workflow/Layers/ProjectWorkspaceResolver.test.ts create mode 100644 apps/server/src/workflow/Layers/ProjectWorkspaceResolver.ts create mode 100644 apps/server/src/workflow/Layers/ProviderDispatchOutbox.test.ts create mode 100644 apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts create mode 100644 apps/server/src/workflow/Layers/ProviderResponsePort.test.ts create mode 100644 apps/server/src/workflow/Layers/ProviderResponsePort.ts create mode 100644 apps/server/src/workflow/Layers/ProviderTurnPort.test.ts create mode 100644 apps/server/src/workflow/Layers/RealStepExecutor.test.ts create mode 100644 apps/server/src/workflow/Layers/RealStepExecutor.ts create mode 100644 apps/server/src/workflow/Layers/ScriptCancelRegistry.test.ts create mode 100644 apps/server/src/workflow/Layers/ScriptCancelRegistry.ts create mode 100644 apps/server/src/workflow/Layers/ScriptCommandRunner.test.ts create mode 100644 apps/server/src/workflow/Layers/ScriptCommandRunner.ts create mode 100644 apps/server/src/workflow/Layers/ScriptStepExecutor.test.ts create mode 100644 apps/server/src/workflow/Layers/ScriptStepExecutor.ts create mode 100644 apps/server/src/workflow/Layers/SetupRunService.test.ts create mode 100644 apps/server/src/workflow/Layers/SetupRunService.ts create mode 100644 apps/server/src/workflow/Layers/StepUsageReader.test.ts create mode 100644 apps/server/src/workflow/Layers/StepUsageReader.ts create mode 100644 apps/server/src/workflow/Layers/StubStepExecutor.test.ts create mode 100644 apps/server/src/workflow/Layers/StubStepExecutor.ts create mode 100644 apps/server/src/workflow/Layers/TicketCheckpointService.migration.test.ts create mode 100644 apps/server/src/workflow/Layers/TicketCheckpointService.test.ts create mode 100644 apps/server/src/workflow/Layers/TicketCheckpointService.ts create mode 100644 apps/server/src/workflow/Layers/TicketDiffQuery.test.ts create mode 100644 apps/server/src/workflow/Layers/TicketDiffQuery.ts create mode 100644 apps/server/src/workflow/Layers/TicketMergeService.test.ts create mode 100644 apps/server/src/workflow/Layers/TicketMergeService.ts create mode 100644 apps/server/src/workflow/Layers/TicketPullRequestService.test.ts create mode 100644 apps/server/src/workflow/Layers/TicketPullRequestService.ts create mode 100644 apps/server/src/workflow/Layers/TurnStateReader.test.ts create mode 100644 apps/server/src/workflow/Layers/TurnStateReader.ts create mode 100644 apps/server/src/workflow/Layers/WorkSourceConnectionStore.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkSourceConnectionStore.ts create mode 100644 apps/server/src/workflow/Layers/WorkSourceProviderRegistry.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkSourceProviderRegistry.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowBoardEvents.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowBoardEvents.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowBoardNotificationDispatcher.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowBoardNotificationDispatcher.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowBoardNotificationRelay.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowBoardNotificationRelay.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowBoardSaveLocks.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowBoardVersionStore.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowBoardVersionStore.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowEngine.concurrency.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowEngine.dependencies.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowEngine.externalEvents.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowEngine.retry.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowEngine.token-migration.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowEngine.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowEngine.wip.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowEngine.workSource.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowEventCommitter.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowEventCommitter.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowEventStore.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowEventStore.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowFileLoader.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowFileLoader.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowGitHubPoller.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowGitHubPoller.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowIds.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowIds.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowIntake.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowIntake.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowReadModel.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowReadModel.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowRecovery.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowRecovery.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowRoutingContextBuilder.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowRoutingContextBuilder.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowRuntime.realpath.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowSourceCommitter.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowSourceCommitter.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowSourceSyncer.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowSourceSyncer.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowTerminalRetentionSweeper.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowTerminalRetentionSweeper.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowThreadJanitor.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowWebhook.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowWebhook.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowWorktreeJanitor.test.ts create mode 100644 apps/server/src/workflow/Layers/WorkflowWorktreeJanitor.ts create mode 100644 apps/server/src/workflow/Layers/WorktreeLeaseService.migration.test.ts create mode 100644 apps/server/src/workflow/Layers/WorktreeLeaseService.test.ts create mode 100644 apps/server/src/workflow/Layers/WorktreeLeaseService.ts create mode 100644 apps/server/src/workflow/Services/ApprovalGate.ts create mode 100644 apps/server/src/workflow/Services/BoardDiscovery.ts create mode 100644 apps/server/src/workflow/Services/BoardRegistry.ts create mode 100644 apps/server/src/workflow/Services/CapturedStepOutputReader.ts create mode 100644 apps/server/src/workflow/Services/DurableApprovalResume.ts create mode 100644 apps/server/src/workflow/Services/Errors.ts create mode 100644 apps/server/src/workflow/Services/GitHubPort.ts create mode 100644 apps/server/src/workflow/Services/PredicateEvaluator.ts create mode 100644 apps/server/src/workflow/Services/ProjectScriptTrust.ts create mode 100644 apps/server/src/workflow/Services/ProjectWorkspaceResolver.ts create mode 100644 apps/server/src/workflow/Services/ProviderDispatchOutbox.ts create mode 100644 apps/server/src/workflow/Services/ProviderResponsePort.ts create mode 100644 apps/server/src/workflow/Services/ScriptCancelRegistry.ts create mode 100644 apps/server/src/workflow/Services/ScriptCommandRunner.ts create mode 100644 apps/server/src/workflow/Services/ScriptStepExecutor.ts create mode 100644 apps/server/src/workflow/Services/SetupRunService.ts create mode 100644 apps/server/src/workflow/Services/StepExecutor.ts create mode 100644 apps/server/src/workflow/Services/StepUsageReader.ts create mode 100644 apps/server/src/workflow/Services/TicketCheckpointService.ts create mode 100644 apps/server/src/workflow/Services/TicketDiffQuery.ts create mode 100644 apps/server/src/workflow/Services/TicketMergeService.ts create mode 100644 apps/server/src/workflow/Services/TicketPullRequestService.ts create mode 100644 apps/server/src/workflow/Services/TurnStateReader.ts create mode 100644 apps/server/src/workflow/Services/WorkSourceConnectionStore.ts create mode 100644 apps/server/src/workflow/Services/WorkSourceProvider.ts create mode 100644 apps/server/src/workflow/Services/WorkflowBoardEvents.ts create mode 100644 apps/server/src/workflow/Services/WorkflowBoardNotificationDispatcher.ts create mode 100644 apps/server/src/workflow/Services/WorkflowBoardNotificationRelay.ts create mode 100644 apps/server/src/workflow/Services/WorkflowBoardSaveLocks.ts create mode 100644 apps/server/src/workflow/Services/WorkflowBoardVersionStore.ts create mode 100644 apps/server/src/workflow/Services/WorkflowEngine.ts create mode 100644 apps/server/src/workflow/Services/WorkflowEventCommitter.ts create mode 100644 apps/server/src/workflow/Services/WorkflowEventStore.ts create mode 100644 apps/server/src/workflow/Services/WorkflowFileLoader.ts create mode 100644 apps/server/src/workflow/Services/WorkflowGitHubPoller.ts create mode 100644 apps/server/src/workflow/Services/WorkflowIds.ts create mode 100644 apps/server/src/workflow/Services/WorkflowIntake.ts create mode 100644 apps/server/src/workflow/Services/WorkflowProjectionPipeline.ts create mode 100644 apps/server/src/workflow/Services/WorkflowReadModel.ts create mode 100644 apps/server/src/workflow/Services/WorkflowRecovery.ts create mode 100644 apps/server/src/workflow/Services/WorkflowRoutingContextBuilder.ts create mode 100644 apps/server/src/workflow/Services/WorkflowSourceCommitter.ts create mode 100644 apps/server/src/workflow/Services/WorkflowSourceSyncer.ts create mode 100644 apps/server/src/workflow/Services/WorkflowTerminalRetentionSweeper.ts create mode 100644 apps/server/src/workflow/Services/WorkflowThreadJanitor.ts create mode 100644 apps/server/src/workflow/Services/WorkflowWebhook.ts create mode 100644 apps/server/src/workflow/Services/WorkflowWorktreeJanitor.ts create mode 100644 apps/server/src/workflow/Services/WorktreeLeaseService.ts create mode 100644 apps/server/src/workflow/Services/WorktreePort.ts create mode 100644 apps/server/src/workflow/WorkflowEngineLive.test.ts create mode 100644 apps/server/src/workflow/WorkflowEngineLive.ts create mode 100644 apps/server/src/workflow/WorkflowFoundationLive.test.ts create mode 100644 apps/server/src/workflow/WorkflowFoundationLive.ts create mode 100644 apps/server/src/workflow/WorkflowRuntimeLive.ts create mode 100644 apps/server/src/workflow/boardDeletion.test.ts create mode 100644 apps/server/src/workflow/boardDeletion.ts create mode 100644 apps/server/src/workflow/boardSlug.test.ts create mode 100644 apps/server/src/workflow/boardSlug.ts create mode 100644 apps/server/src/workflow/defaultBoard.test.ts create mode 100644 apps/server/src/workflow/defaultBoard.ts create mode 100644 apps/server/src/workflow/dryRun.test.ts create mode 100644 apps/server/src/workflow/dryRun.ts create mode 100644 apps/server/src/workflow/externalEvent.ts create mode 100644 apps/server/src/workflow/instructionPath.ts create mode 100644 apps/server/src/workflow/instructionTemplate.test.ts create mode 100644 apps/server/src/workflow/instructionTemplate.ts create mode 100644 apps/server/src/workflow/jsonLogicRule.ts create mode 100644 apps/server/src/workflow/redactSensitiveText.test.ts create mode 100644 apps/server/src/workflow/redactSensitiveText.ts create mode 100644 apps/server/src/workflow/sampleBoardFile.test.ts create mode 100644 apps/server/src/workflow/sourceReconcileDiff.test.ts create mode 100644 apps/server/src/workflow/sourceReconcileDiff.ts create mode 100644 apps/server/src/workflow/ticketMessageBody.ts create mode 100644 apps/server/src/workflow/ticketRefs.test.ts create mode 100644 apps/server/src/workflow/ticketRefs.ts create mode 100644 apps/server/src/workflow/webhookRoute.ts create mode 100644 apps/server/src/workflow/workflowFile.test.ts create mode 100644 apps/server/src/workflow/workflowFile.ts create mode 100644 apps/server/src/workflow/workflowVersionHash.ts create mode 100644 apps/web/src/components/board/AgentSessionDialog.tsx create mode 100644 apps/web/src/components/board/BoardDigestDialog.tsx create mode 100644 apps/web/src/components/board/BoardHeaderControls.browser.tsx create mode 100644 apps/web/src/components/board/BoardHeaderControls.test.tsx create mode 100644 apps/web/src/components/board/BoardHeaderControls.tsx create mode 100644 apps/web/src/components/board/BoardView.test.tsx create mode 100644 apps/web/src/components/board/BoardView.tsx create mode 100644 apps/web/src/components/board/IntakeDialog.tsx create mode 100644 apps/web/src/components/board/LaneColumn.tsx create mode 100644 apps/web/src/components/board/StepActivityFeed.browser.tsx create mode 100644 apps/web/src/components/board/StepActivityFeed.tsx create mode 100644 apps/web/src/components/board/TicketArtifacts.tsx create mode 100644 apps/web/src/components/board/TicketCard.test.tsx create mode 100644 apps/web/src/components/board/TicketCard.tsx create mode 100644 apps/web/src/components/board/TicketDiff.tsx create mode 100644 apps/web/src/components/board/TicketDrawer.browser.tsx create mode 100644 apps/web/src/components/board/TicketDrawer.test.tsx create mode 100644 apps/web/src/components/board/TicketDrawer.tsx create mode 100644 apps/web/src/components/board/WebhookConfigDialog.tsx create mode 100644 apps/web/src/components/board/editor/DryRunPanel.tsx create mode 100644 apps/web/src/components/board/editor/LaneForm.tsx create mode 100644 apps/web/src/components/board/editor/LaneList.tsx create mode 100644 apps/web/src/components/board/editor/PipelineEditor.tsx create mode 100644 apps/web/src/components/board/editor/RoutingEditor.tsx create mode 100644 apps/web/src/components/board/editor/SourcesSection.tsx create mode 100644 apps/web/src/components/board/editor/StepFields.browser.tsx create mode 100644 apps/web/src/components/board/editor/StepFields.tsx create mode 100644 apps/web/src/components/board/editor/WorkflowEditor.browser.tsx create mode 100644 apps/web/src/components/board/editor/WorkflowEditor.tsx create mode 100644 apps/web/src/components/board/editor/WorkflowEditorFullscreen.test.tsx create mode 100644 apps/web/src/components/board/editor/WorkflowEditorFullscreen.tsx create mode 100644 apps/web/src/components/board/editor/agentStepSelection.test.ts create mode 100644 apps/web/src/components/board/editor/agentStepSelection.ts create mode 100644 apps/web/src/components/board/editor/canvas/CanvasView.browser.tsx create mode 100644 apps/web/src/components/board/editor/canvas/CanvasView.tsx create mode 100644 apps/web/src/components/board/editor/canvas/LaneCard.tsx create mode 100644 apps/web/src/components/board/editor/canvas/RoutingEdges.tsx create mode 100644 apps/web/src/components/board/editor/canvas/RoutingHandles.test.ts create mode 100644 apps/web/src/components/board/editor/canvas/RoutingHandles.tsx create mode 100644 apps/web/src/components/board/editor/canvas/StepBlock.tsx create mode 100644 apps/web/src/components/board/editor/canvas/canvasLayout.test.ts create mode 100644 apps/web/src/components/board/editor/canvas/canvasLayout.ts create mode 100644 apps/web/src/components/board/editor/canvas/edgeRouting.test.ts create mode 100644 apps/web/src/components/board/editor/canvas/edgeRouting.ts create mode 100644 apps/web/src/components/board/editor/history/DiffView.browser.tsx create mode 100644 apps/web/src/components/board/editor/history/DiffView.test.ts create mode 100644 apps/web/src/components/board/editor/history/DiffView.tsx create mode 100644 apps/web/src/components/board/editor/history/VersionHistoryPanel.tsx create mode 100644 apps/web/src/components/settings/WorkSourceConnectionsSettings.tsx create mode 100644 apps/web/src/routes/-boardRouteState.test.ts create mode 100644 apps/web/src/routes/-workflowEditorSurface.test.ts create mode 100644 apps/web/src/routes/_chat.$environmentId.board.tsx create mode 100644 apps/web/src/routes/settings.work-sources.tsx create mode 100644 apps/web/src/vite-raw.d.ts create mode 100644 apps/web/src/workflow/agingFormat.test.ts create mode 100644 apps/web/src/workflow/agingFormat.ts create mode 100644 apps/web/src/workflow/boardListState.test.ts create mode 100644 apps/web/src/workflow/boardRpc.test.ts create mode 100644 apps/web/src/workflow/boardRpc.ts create mode 100644 apps/web/src/workflow/boardState.test.ts create mode 100644 apps/web/src/workflow/boardState.ts create mode 100644 apps/web/src/workflow/dryRunFormat.test.ts create mode 100644 apps/web/src/workflow/dryRunFormat.ts create mode 100644 apps/web/src/workflow/editorModel.test.ts create mode 100644 apps/web/src/workflow/editorModel.ts create mode 100644 apps/web/src/workflow/intakeState.test.ts create mode 100644 apps/web/src/workflow/intakeState.ts create mode 100644 apps/web/src/workflow/resolveRecentAgent.test.ts create mode 100644 apps/web/src/workflow/resolveRecentAgent.ts create mode 100644 apps/web/src/workflow/routeDecision.test.ts create mode 100644 apps/web/src/workflow/routeDecision.ts create mode 100644 apps/web/src/workflow/usageFormat.ts create mode 100644 apps/web/src/workflow/useNowTick.ts create mode 100644 docs/workflow-boards/github-flow-example.json create mode 100644 infra/relay/src/agentActivity/BoardTicketPublisher.test.ts create mode 100644 infra/relay/src/agentActivity/BoardTicketPublisher.ts create mode 100644 packages/contracts/src/auth.test.ts create mode 100644 packages/contracts/src/workSource.test.ts create mode 100644 packages/contracts/src/workSource.ts create mode 100644 packages/contracts/src/workflow.test.ts create mode 100644 packages/contracts/src/workflow.ts create mode 100644 packages/contracts/src/workflowRpc.test.ts 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/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 136e141fdcf..d3b31bbd1b7 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -86,6 +86,16 @@ function AppNavigator() { headerShown: false, }} /> + + ); diff --git a/apps/mobile/src/app/needs-you.tsx b/apps/mobile/src/app/needs-you.tsx new file mode 100644 index 00000000000..54b0fcd7fe4 --- /dev/null +++ b/apps/mobile/src/app/needs-you.tsx @@ -0,0 +1,5 @@ +import { NeedsYouInboxScreen } from "../features/board/NeedsYouInboxScreen"; + +export default function NeedsYouRoute() { + return ; +} diff --git a/apps/mobile/src/app/tickets/[environmentId]/[boardId]/[ticketId]/index.tsx b/apps/mobile/src/app/tickets/[environmentId]/[boardId]/[ticketId]/index.tsx new file mode 100644 index 00000000000..805f48d71de --- /dev/null +++ b/apps/mobile/src/app/tickets/[environmentId]/[boardId]/[ticketId]/index.tsx @@ -0,0 +1,5 @@ +import { TicketActionSheetScreen } from "../../../../../features/board/TicketActionSheetScreen"; + +export default function TicketRoute() { + return ; +} diff --git a/apps/mobile/src/features/agent-awareness/notificationPayload.test.ts b/apps/mobile/src/features/agent-awareness/notificationPayload.test.ts new file mode 100644 index 00000000000..30883feaac6 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/notificationPayload.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + encodeTicketDeepLink, + extractAgentNotificationDeepLink, + normalizeTicketDeepLink, + routeAgentNotificationResponseOnce, +} from "./notificationPayload"; + +function responseWithData(data: Record, identifier = "notification-1") { + return { + notification: { + request: { + identifier, + content: { + data, + }, + }, + }, + }; +} + +// --------------------------------------------------------------------------- +// encodeTicketDeepLink +// --------------------------------------------------------------------------- +describe("encodeTicketDeepLink", () => { + it("returns null when environmentId is empty", () => { + expect(encodeTicketDeepLink({ environmentId: "", boardId: "b1", ticketId: "t1" })).toBeNull(); + }); + + it("returns null when boardId is empty", () => { + expect( + encodeTicketDeepLink({ environmentId: "env", boardId: "", ticketId: "t1" }), + ).toBeNull(); + }); + + it("returns null when ticketId is empty", () => { + expect( + encodeTicketDeepLink({ environmentId: "env", boardId: "b1", ticketId: "" }), + ).toBeNull(); + }); + + it("encodes a basic ticket deep link", () => { + expect( + encodeTicketDeepLink({ environmentId: "env-1", boardId: "board-1", ticketId: "ticket-1" }), + ).toBe("/tickets/env-1/board-1/ticket-1"); + }); + + it("percent-encodes components with special characters", () => { + expect( + encodeTicketDeepLink({ + environmentId: "env 1", + boardId: "board/2", + ticketId: "ticket 3", + }), + ).toBe("/tickets/env%201/board%2F2/ticket%203"); + }); +}); + +// --------------------------------------------------------------------------- +// normalizeTicketDeepLink +// --------------------------------------------------------------------------- +describe("normalizeTicketDeepLink", () => { + it("accepts and round-trips a well-formed ticket path", () => { + expect(normalizeTicketDeepLink("/tickets/env-1/b1/t1")).toBe("/tickets/env-1/b1/t1"); + }); + + it("accepts a path with percent-encoded components", () => { + expect(normalizeTicketDeepLink("/tickets/env%201/board%2F2/ticket%203")).toBe( + "/tickets/env%201/board%2F2/ticket%203", + ); + }); + + it("rejects a path with too few segments (missing ticketId)", () => { + expect(normalizeTicketDeepLink("/tickets/env-1/b1")).toBeNull(); + }); + + it("rejects a path with too many segments", () => { + expect(normalizeTicketDeepLink("/tickets/a/b/c/d")).toBeNull(); + }); + + it("rejects a thread path", () => { + expect(normalizeTicketDeepLink("/threads/env-1/t1")).toBeNull(); + }); + + it("rejects a path with a query string", () => { + expect(normalizeTicketDeepLink("/tickets/env/b/t?x=1")).toBeNull(); + }); + + it("rejects a path with a hash fragment", () => { + expect(normalizeTicketDeepLink("/tickets/env/b/t#section")).toBeNull(); + }); + + it("rejects a path with leading double-slash", () => { + expect(normalizeTicketDeepLink("//tickets/env/b/t")).toBeNull(); + }); + + it("rejects a value with surrounding whitespace", () => { + expect(normalizeTicketDeepLink(" /tickets/env/b/t")).toBeNull(); + expect(normalizeTicketDeepLink("/tickets/env/b/t ")).toBeNull(); + }); + + it("rejects an empty middle segment (passes 5-segment check, fails encode)", () => { + expect(normalizeTicketDeepLink("/tickets/env//t")).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// extractAgentNotificationDeepLink — ticket paths +// --------------------------------------------------------------------------- +describe("extractAgentNotificationDeepLink — ticket deep links", () => { + it("uses explicit ticket deep link from APNs payload data", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + deepLink: "/tickets/env/b/t", + }), + ), + ).toBe("/tickets/env/b/t"); + }); + + it("normalizes explicit ticket deep links with encoded components", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + deepLink: "/tickets/env%201/board%2F2/ticket%203", + }), + ), + ).toBe("/tickets/env%201/board%2F2/ticket%203"); + }); + + it("falls back to identity fields when no deepLink", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + environmentId: "env 1", + boardId: "board/2", + ticketId: "ticket 3", + }), + ), + ).toBe("/tickets/env%201/board%2F2/ticket%203"); + }); + + it("uses ticket identity fallback when deepLink is not a recognized route", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + deepLink: "/", + environmentId: "env", + boardId: "b", + ticketId: "t", + }), + ), + ).toBe("/tickets/env/b/t"); + }); + + it("ignores malformed ticket deep link and falls back to ids", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + deepLink: "/tickets/env/b", + environmentId: "env", + boardId: "b", + ticketId: "t", + }), + ), + ).toBe("/tickets/env/b/t"); + }); +}); + +// --------------------------------------------------------------------------- +// REGRESSION: thread paths still work +// --------------------------------------------------------------------------- +describe("extractAgentNotificationDeepLink — thread deep links (regression)", () => { + it("uses explicit thread deep link from APNs payload data", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + deepLink: "/threads/env/thread", + environmentId: "ignored", + threadId: "ignored", + }), + ), + ).toBe("/threads/env/thread"); + }); + + it("prefers the thread identity fallback over ticket when both id sets are present", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + environmentId: "env", + threadId: "thread", + boardId: "b", + ticketId: "t", + }), + ), + ).toBe("/threads/env/thread"); + }); + + it("normalizes explicit thread deep links from APNs payload data", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + deepLink: "/threads/env%201/thread%2F2", + }), + ), + ).toBe("/threads/env%201/thread%2F2"); + }); + + it("falls back to the thread route from environment and thread ids", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + environmentId: "env 1", + threadId: "thread/2", + }), + ), + ).toBe("/threads/env%201/thread%2F2"); + }); + + it("falls back to thread ids when explicit deep link is not a recognized route", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + deepLink: "/", + environmentId: "env", + threadId: "thread", + }), + ), + ).toBe("/threads/env/thread"); + }); + + it("ignores malformed or external links with no usable fallback", () => { + expect( + extractAgentNotificationDeepLink(responseWithData({ deepLink: "https://example.com" })), + ).toBeNull(); + expect( + extractAgentNotificationDeepLink(responseWithData({ deepLink: "/settings" })), + ).toBeNull(); + expect( + extractAgentNotificationDeepLink(responseWithData({ deepLink: "//example.com" })), + ).toBeNull(); + expect( + extractAgentNotificationDeepLink(responseWithData({ deepLink: "/threads/env/thread?x=1" })), + ).toBeNull(); + expect(extractAgentNotificationDeepLink({})).toBeNull(); + }); + + it("falls back to ticket identity when threadId is an empty string", () => { + // An empty threadId must NOT short-circuit into the thread branch and return + // null; the ticket-identity fallback must run instead. + expect( + extractAgentNotificationDeepLink( + responseWithData({ + environmentId: "env", + threadId: "", + boardId: "board-1", + ticketId: "ticket-1", + }), + ), + ).toBe("/tickets/env/board-1/ticket-1"); + }); +}); + +// --------------------------------------------------------------------------- +// routeAgentNotificationResponseOnce (regression) +// --------------------------------------------------------------------------- +describe("routeAgentNotificationResponseOnce", () => { + it("does not navigate twice when the initial and listener responses refer to one notification", () => { + const handledResponseIds = new Set(); + const navigations: Array = []; + const response = responseWithData({ + environmentId: "env", + threadId: "thread", + }); + + routeAgentNotificationResponseOnce({ + handledResponseIds, + response, + navigate: (deepLink) => navigations.push(deepLink), + }); + routeAgentNotificationResponseOnce({ + handledResponseIds, + response, + navigate: (deepLink) => navigations.push(deepLink), + }); + + expect(navigations).toEqual(["/threads/env/thread"]); + }); +}); diff --git a/apps/mobile/src/features/agent-awareness/notificationPayload.ts b/apps/mobile/src/features/agent-awareness/notificationPayload.ts index dc72e3d1bd2..b255f7edd79 100644 --- a/apps/mobile/src/features/agent-awareness/notificationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/notificationPayload.ts @@ -69,21 +69,80 @@ function normalizeThreadDeepLink(value: string): string | null { } } +export function encodeTicketDeepLink(input: { + readonly environmentId: string; + readonly boardId: string; + readonly ticketId: string; +}): string | null { + if ( + input.environmentId.length === 0 || + input.boardId.length === 0 || + input.ticketId.length === 0 + ) { + return null; + } + return `/tickets/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.boardId)}/${encodeURIComponent(input.ticketId)}`; +} + +export function normalizeTicketDeepLink(value: string): string | null { + if ( + value.trim() !== value || + value.startsWith("//") || + value.includes("?") || + value.includes("#") + ) { + return null; + } + + const parts = value.split("/"); + if (parts.length !== 5 || parts[0] !== "" || parts[1] !== "tickets") { + return null; + } + + try { + return encodeTicketDeepLink({ + environmentId: decodeURIComponent(parts[2] ?? ""), + boardId: decodeURIComponent(parts[3] ?? ""), + ticketId: decodeURIComponent(parts[4] ?? ""), + }); + } catch { + return null; + } +} + export function extractAgentNotificationDeepLink(response: unknown): string | null { const data = dataFromNotificationResponse(response); const deepLink = data?.deepLink; if (typeof deepLink === "string") { - const normalizedDeepLink = normalizeThreadDeepLink(deepLink); - if (normalizedDeepLink) { - return normalizedDeepLink; + const normalizedThreadDeepLink = normalizeThreadDeepLink(deepLink); + if (normalizedThreadDeepLink) { + return normalizedThreadDeepLink; + } + const normalizedTicketDeepLink = normalizeTicketDeepLink(deepLink); + if (normalizedTicketDeepLink) { + return normalizedTicketDeepLink; } } const environmentId = data?.environmentId; const threadId = data?.threadId; - if (typeof environmentId === "string" && typeof threadId === "string") { - return encodeThreadDeepLink({ environmentId, threadId }); + if (typeof environmentId === "string" && typeof threadId === "string" && threadId.length > 0) { + const threadDeepLink = encodeThreadDeepLink({ environmentId, threadId }); + if (threadDeepLink) { + return threadDeepLink; + } } + + const boardId = data?.boardId; + const ticketId = data?.ticketId; + if ( + typeof environmentId === "string" && + typeof boardId === "string" && + typeof ticketId === "string" + ) { + return encodeTicketDeepLink({ environmentId, boardId, ticketId }); + } + return null; } diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index 44ef38df0ef..1587294e44d 100644 --- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -28,6 +28,7 @@ export function makeRelayDeviceRegistrationRequest(input: { notifyOnInput: true, notifyOnCompletion: true, notifyOnFailure: true, + notifyOnBlocked: true, }, }; } diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 346680df8c0..a9a0d5f9e70 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -201,6 +201,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { notifyOnInput: true, notifyOnCompletion: true, notifyOnFailure: true, + notifyOnBlocked: true, }, }); }); @@ -232,6 +233,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { notifyOnInput: true, notifyOnCompletion: true, notifyOnFailure: true, + notifyOnBlocked: true, }, }); }); diff --git a/apps/mobile/src/features/board/NeedsYouInboxScreen.tsx b/apps/mobile/src/features/board/NeedsYouInboxScreen.tsx new file mode 100644 index 00000000000..10b406c0632 --- /dev/null +++ b/apps/mobile/src/features/board/NeedsYouInboxScreen.tsx @@ -0,0 +1,190 @@ +import { Stack, useFocusEffect, useRouter } from "expo-router"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Pressable, RefreshControl, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + EnvironmentId, + type WorkflowNeedsAttentionTicketView, +} from "@t3tools/contracts"; + +import { AppText as Text } from "../../components/AppText"; +import { EmptyState } from "../../components/EmptyState"; +import { buildTicketRoutePath } from "../../lib/routes"; +import { getEnvironmentClient } from "../../state/environment-session-registry"; +import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; + +interface NeedsYouRow { + readonly environmentId: EnvironmentId; + readonly ticket: WorkflowNeedsAttentionTicketView; +} + +function attentionLabel(ticket: WorkflowNeedsAttentionTicketView): string { + switch (ticket.attentionKind) { + case "waiting_for_approval": + return "Needs approval"; + case "waiting_for_input": + return "Needs input"; + case "blocked": + return "Blocked"; + default: + return ticket.status; + } +} + +function formatRelative(updatedAt: string): string { + const then = Date.parse(updatedAt); + if (Number.isNaN(then)) { + return ""; + } + const deltaMs = Date.now() - then; + const minutes = Math.floor(deltaMs / 60_000); + if (minutes < 1) { + return "just now"; + } + if (minutes < 60) { + return `${minutes}m ago`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h ago`; + } + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +export function NeedsYouInboxScreen() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { environmentStateById } = useRemoteEnvironmentState(); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const environmentIds = useMemo( + () => Object.keys(environmentStateById).map((id) => EnvironmentId.make(id)), + [environmentStateById], + ); + + const load = useCallback( + async (isActive: () => boolean) => { + if (isActive()) { + setError(null); + } + const aggregated: NeedsYouRow[] = []; + const failures: string[] = []; + + await Promise.all( + environmentIds.map(async (environmentId) => { + const client = getEnvironmentClient(environmentId); + if (!client) { + return; + } + try { + const tickets = await client.workflow.listNeedsAttentionTickets({}); + for (const ticket of tickets) { + aggregated.push({ environmentId, ticket }); + } + } catch (cause) { + failures.push(cause instanceof Error ? cause.message : "Failed to load tickets."); + } + }), + ); + + if (!isActive()) { + return; + } + + aggregated.sort((a, b) => Date.parse(b.ticket.updatedAt) - Date.parse(a.ticket.updatedAt)); + setRows(aggregated); + if (aggregated.length === 0 && failures.length > 0) { + setError(failures[0] ?? "Failed to load tickets."); + } + }, + [environmentIds], + ); + + useFocusEffect( + useCallback(() => { + let cancelled = false; + const isActive = () => !cancelled && mountedRef.current; + setLoading(true); + void load(isActive).finally(() => { + if (isActive()) { + setLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [load]), + ); + + const onRefresh = useCallback(() => { + setRefreshing(true); + void load(() => mountedRef.current).finally(() => { + if (mountedRef.current) { + setRefreshing(false); + } + }); + }, [load]); + + return ( + + + } + > + Needs you + + {!loading && rows.length === 0 ? ( + + + + ) : null} + + {rows.map((row) => ( + + router.push( + buildTicketRoutePath({ + environmentId: row.environmentId, + boardId: row.ticket.boardId, + ticketId: row.ticket.ticketId, + }), + ) + } + > + + + {row.ticket.title} + + + {formatRelative(row.ticket.updatedAt)} + + + {row.ticket.boardName} + + {attentionLabel(row.ticket)} + + + ))} + + + ); +} diff --git a/apps/mobile/src/features/board/TicketActionSheetScreen.tsx b/apps/mobile/src/features/board/TicketActionSheetScreen.tsx new file mode 100644 index 00000000000..88f934a38db --- /dev/null +++ b/apps/mobile/src/features/board/TicketActionSheetScreen.tsx @@ -0,0 +1,408 @@ +import { Stack, useLocalSearchParams } from "expo-router"; +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import { Linking, Pressable, ScrollView, View } from "react-native"; +import { + BoardId, + EnvironmentId, + LaneKey, + TicketId, + type StepRunId, + type WorkflowTicketDetailView, + type WorkflowTicketMessageView, +} from "@t3tools/contracts"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { EmptyState } from "../../components/EmptyState"; +import { LoadingScreen } from "../../components/LoadingScreen"; +import { + getEnvironmentClient, + subscribeEnvironmentConnections, +} from "../../state/environment-session-registry"; +import { useEnvironmentRuntime } from "../../state/use-environment-runtime"; +import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; +import { isTicketSourceOwned, selectTicketAffordance } from "./ticketAffordance"; + +function firstRouteParam(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null; + } + + return value ?? null; +} + +export function TicketActionSheetScreen() { + const params = useLocalSearchParams<{ + environmentId?: string | string[]; + boardId?: string | string[]; + ticketId?: string | string[]; + }>(); + + const environmentIdRaw = firstRouteParam(params.environmentId); + const boardIdRaw = firstRouteParam(params.boardId); + const ticketIdRaw = firstRouteParam(params.ticketId); + + const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; + const ticketId = ticketIdRaw ? TicketId.make(ticketIdRaw) : null; + // boardId is part of the deep-link contract; surfaced for parity with routing. + const boardId = boardIdRaw ? BoardId.make(boardIdRaw) : null; + + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [mutationError, setMutationError] = useState(null); + const [busy, setBusy] = useState(false); + const [answerText, setAnswerText] = useState(""); + const [commentText, setCommentText] = useState(""); + + const { isLoadingSavedConnection, pendingConnectionError } = useRemoteEnvironmentState(); + const routeEnvironmentRuntime = useEnvironmentRuntime(environmentId); + const routeConnectionState = routeEnvironmentRuntime.connectionState; + const routeConnectionError = pendingConnectionError ?? routeEnvironmentRuntime.connectionError; + + // Re-read the environment client whenever a connection connects/disconnects so + // a cold-start notification tap (session not yet connected at first render) + // picks up the session as soon as bootstrap finishes. + const subscribeConnections = useCallback( + (onStoreChange: () => void) => subscribeEnvironmentConnections(onStoreChange), + [], + ); + const getSessionSnapshot = useCallback( + () => (environmentId ? getEnvironmentClient(environmentId) : null), + [environmentId], + ); + const session = useSyncExternalStore( + subscribeConnections, + getSessionSnapshot, + getSessionSnapshot, + ); + + // Still hydrating: saved connections are loading, or the route's environment is + // mid-(re)connect. Drives "Connecting…" instead of the terminal disconnected state. + const stillHydrating = + isLoadingSavedConnection || + routeConnectionState === "connecting" || + routeConnectionState === "reconnecting"; + + const refetch = useCallback(async () => { + if (!session || !ticketId) { + return; + } + const next = await session.workflow.getTicketDetail({ ticketId }); + setDetail(next); + }, [session, ticketId]); + + useEffect(() => { + if (!session || !ticketId) { + return; + } + + let cancelled = false; + setLoading(true); + setLoadError(null); + + void (async () => { + try { + const next = await session.workflow.getTicketDetail({ ticketId }); + if (!cancelled) { + setDetail(next); + } + } catch (error) { + if (!cancelled) { + setLoadError(error instanceof Error ? error.message : "Failed to load ticket."); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [session, ticketId]); + + const runMutation = useCallback( + async (mutate: () => Promise) => { + setBusy(true); + setMutationError(null); + try { + await mutate(); + await refetch(); + } catch (error) { + setMutationError(error instanceof Error ? error.message : "Action failed."); + } finally { + setBusy(false); + } + }, + [refetch], + ); + + const onSubmitAnswer = useCallback( + (stepRunId: StepRunId) => { + const text = answerText.trim(); + if (!session || text.length === 0) { + return; + } + void runMutation(async () => { + await session.workflow.answerTicketStep({ stepRunId, text }); + setAnswerText(""); + }); + }, + [answerText, runMutation, session], + ); + + const onResolveApproval = useCallback( + (stepRunId: StepRunId, approved: boolean) => { + if (!session) { + return; + } + void runMutation(() => session.workflow.resolveApproval({ stepRunId, approved })); + }, + [runMutation, session], + ); + + const onMoveTicket = useCallback( + (toLane: LaneKey) => { + if (!session || !ticketId) { + return; + } + void runMutation(() => session.workflow.moveTicket({ ticketId, toLane })); + }, + [runMutation, session, ticketId], + ); + + const onPostComment = useCallback(() => { + const text = commentText.trim(); + if (!session || !ticketId || text.length === 0) { + return; + } + void runMutation(async () => { + await session.workflow.postTicketMessage({ ticketId, text }); + setCommentText(""); + }); + }, [commentText, runMutation, session, ticketId]); + + if (!environmentId || !boardId || !ticketId) { + return ; + } + + if (!session) { + // Cold-start notification tap: the saved session may still be (re)connecting. + // Show "Connecting…" while hydration is in flight; only fall through to the + // terminal "not connected" EmptyState once hydration has settled. + if (stillHydrating) { + return ; + } + + return ( + + + + ); + } + + if (loading) { + return ; + } + + if (loadError || !detail) { + return ( + + + + ); + } + + const affordance = selectTicketAffordance(detail); + const ticket = detail.ticket; + const sourceOwned = isTicketSourceOwned(detail); + + return ( + + + + + {ticket.title} + + {ticket.currentLane?.name ?? ticket.currentLaneKey} · {ticket.status} + + {sourceOwned && detail.syncedSource ? ( + void Linking.openURL(detail.syncedSource!.url)} + className="self-start" + > + + Synced from {detail.syncedSource.provider} ↗ + + + ) : null} + + + {mutationError ? ( + + {mutationError} + + ) : null} + + {affordance.kind === "answer" ? ( + + + {affordance.question ?? "The agent needs your input."} + + + onSubmitAnswer(affordance.stepRunId)} + /> + + ) : null} + + {affordance.kind === "approve" ? ( + + + {affordance.question ?? "The agent is waiting for your approval."} + + + onResolveApproval(affordance.stepRunId, true)} + /> + onResolveApproval(affordance.stepRunId, false)} + /> + + + ) : null} + + {affordance.kind === "blocked" ? ( + + Blocked + + {affordance.blockReason ?? "This ticket is blocked."} + + + ) : null} + + {affordance.laneActions.length > 0 ? ( + + Move ticket + + {affordance.laneActions.map((action) => ( + onMoveTicket(action.to)} + /> + ))} + + + ) : null} + + + Add a comment + + + + + {detail.messages.length > 0 ? ( + + Conversation + {detail.messages.map((message) => ( + + ))} + + ) : null} + + + ); +} + +function ScreenShell(props: { readonly children: React.ReactNode }) { + return ( + + + {props.children} + + ); +} + +function ActionButton(props: { + readonly label: string; + readonly onPress: () => void; + readonly disabled?: boolean; + readonly tone?: "primary" | "secondary" | "danger"; +}) { + const tone = props.tone ?? "primary"; + const bg = + tone === "danger" ? "bg-danger" : tone === "secondary" ? "bg-card-alt" : "bg-primary"; + const fg = tone === "secondary" ? "text-foreground" : "text-primary-foreground"; + + return ( + + {props.label} + + ); +} + +function MessageRow(props: { readonly message: WorkflowTicketMessageView }) { + const { message } = props; + return ( + + {message.author} + {message.body} + + ); +} diff --git a/apps/mobile/src/features/board/ticketAffordance.test.ts b/apps/mobile/src/features/board/ticketAffordance.test.ts new file mode 100644 index 00000000000..089bb775dc3 --- /dev/null +++ b/apps/mobile/src/features/board/ticketAffordance.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + BoardId, + LaneKey, + StepRunId, + StepKey, + TicketId, + type BoardTicketView, + type WorkflowCurrentLaneView, + type WorkflowLaneActionView, + type WorkflowStepRunView, + type WorkflowTicketDetailView, + type WorkflowTicketAttentionKind, +} from "@t3tools/contracts"; + +import { isTicketSourceOwned, selectTicketAffordance } from "./ticketAffordance"; + +const TICKET_ID = TicketId.make("ticket-1"); +const BOARD_ID = BoardId.make("board-1"); + +const LANE_ACTIONS: readonly WorkflowLaneActionView[] = [ + { label: "Send back", to: LaneKey.make("triage") }, + { label: "Ship", to: LaneKey.make("done") }, +]; + +const CURRENT_LANE: WorkflowCurrentLaneView = { + key: LaneKey.make("review"), + name: "Review", + actions: LANE_ACTIONS, +}; + +function makeAwaitingStep( + overrides: Partial = {}, +): WorkflowStepRunView { + return { + stepRunId: StepRunId.make("step-run-1"), + stepKey: StepKey.make("review-step"), + stepType: "agent", + status: "awaiting_user", + waitingReason: null, + blockedReason: null, + scriptThreadId: null, + terminalId: null, + scriptStatus: null, + exitCode: null, + signal: null, + ...overrides, + }; +} + +function makeTicket(overrides: Partial = {}): BoardTicketView { + return { + ticketId: TICKET_ID, + boardId: BOARD_ID, + title: "Investigate flake", + currentLaneKey: LaneKey.make("review"), + status: "running", + currentLane: CURRENT_LANE, + ...overrides, + }; +} + +function makeDetail(args: { + readonly ticket?: Partial; + readonly steps?: readonly WorkflowStepRunView[]; +}): WorkflowTicketDetailView { + return { + ticket: makeTicket(args.ticket), + steps: args.steps ?? [], + messages: [], + }; +} + +describe("selectTicketAffordance", () => { + it("maps waiting_for_input to answer with the awaiting step's stepRunId and question", () => { + const detail = makeDetail({ + ticket: { attentionKind: "waiting_for_input", attentionReason: "fallback reason" }, + steps: [ + makeAwaitingStep({ + stepRunId: StepRunId.make("step-input"), + waitingReason: "Which database should I target?", + }), + ], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("answer"); + if (result.kind !== "answer") throw new Error("expected answer"); + expect(result.stepRunId).toBe(StepRunId.make("step-input")); + expect(result.question).toBe("Which database should I target?"); + expect(result.laneActions).toEqual(LANE_ACTIONS); + }); + + it("falls back to attentionReason when the awaiting input step has no waitingReason", () => { + const detail = makeDetail({ + ticket: { attentionKind: "waiting_for_input", attentionReason: "Need credentials" }, + steps: [makeAwaitingStep({ waitingReason: null })], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("answer"); + if (result.kind !== "answer") throw new Error("expected answer"); + expect(result.question).toBe("Need credentials"); + }); + + it("derives answer from providerResponseKind when attentionKind is absent", () => { + const detail = makeDetail({ + ticket: {}, + steps: [makeAwaitingStep({ providerResponseKind: "user-input", waitingReason: "?" })], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("answer"); + }); + + it("maps waiting_for_approval to approve with the awaiting step's stepRunId", () => { + const detail = makeDetail({ + ticket: { attentionKind: "waiting_for_approval" }, + steps: [ + makeAwaitingStep({ + stepRunId: StepRunId.make("step-approve"), + waitingReason: "Approve deploy to prod?", + }), + ], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("approve"); + if (result.kind !== "approve") throw new Error("expected approve"); + expect(result.stepRunId).toBe(StepRunId.make("step-approve")); + expect(result.question).toBe("Approve deploy to prod?"); + }); + + it("derives approve from providerResponseKind=request when attentionKind is absent", () => { + const detail = makeDetail({ + ticket: {}, + steps: [makeAwaitingStep({ providerResponseKind: "request" })], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("approve"); + }); + + it("maps blocked attention to blocked with blockReason and laneActions", () => { + const detail = makeDetail({ + ticket: { attentionKind: "blocked", attentionReason: "ticket-level block" }, + steps: [makeAwaitingStep({ blockedReason: "Missing API key" })], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("blocked"); + if (result.kind !== "blocked") throw new Error("expected blocked"); + expect(result.blockReason).toBe("Missing API key"); + expect(result.laneActions).toEqual(LANE_ACTIONS); + }); + + it("treats ticket.status === blocked as blocked even without attentionKind", () => { + const detail = makeDetail({ + ticket: { status: "blocked", attentionReason: "blocked reason" }, + steps: [], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("blocked"); + if (result.kind !== "blocked") throw new Error("expected blocked"); + expect(result.blockReason).toBe("blocked reason"); + }); + + it("prefers answer over blocked when attentionKind is absent but status is blocked and the awaiting step wants input", () => { + // Precedence lock: wants-input must win over the blocked branch so the user + // can actually respond instead of hitting a dead-end. Do not flip silently. + const detail = makeDetail({ + ticket: { attentionKind: undefined, status: "blocked" }, + steps: [ + makeAwaitingStep({ + stepRunId: StepRunId.make("step-input"), + providerResponseKind: "user-input", + waitingReason: "Pick a target", + }), + ], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("answer"); + if (result.kind !== "answer") throw new Error("expected answer"); + expect(result.stepRunId).toBe(StepRunId.make("step-input")); + }); + + it("maps a ticket with no attention to comment", () => { + const detail = makeDetail({ ticket: {}, steps: [] }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("comment"); + expect(result.laneActions).toEqual(LANE_ACTIONS); + }); + + it("degrades waiting_for_input to comment when no awaiting step is present", () => { + const detail = makeDetail({ + ticket: { attentionKind: "waiting_for_input" }, + steps: [], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("comment"); + }); + + it("degrades waiting_for_approval to comment when no awaiting step is present", () => { + const detail = makeDetail({ + ticket: { attentionKind: "waiting_for_approval" }, + steps: [], + }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("comment"); + }); + + it("defaults laneActions to an empty array when currentLane is absent", () => { + const detail = makeDetail({ ticket: { currentLane: undefined }, steps: [] }); + + const result = selectTicketAffordance(detail); + + expect(result.kind).toBe("comment"); + expect(result.laneActions).toEqual([]); + }); + + // Type guard usage to keep WorkflowTicketAttentionKind imported and meaningful. + it("only recognizes the three attention kinds", () => { + const kinds: readonly WorkflowTicketAttentionKind[] = [ + "waiting_for_approval", + "waiting_for_input", + "blocked", + ]; + expect(kinds).toHaveLength(3); + }); +}); + +describe("isTicketSourceOwned", () => { + it("returns false when syncedSource is absent", () => { + expect(isTicketSourceOwned({ syncedSource: undefined })).toBe(false); + }); + + it("returns true when syncedSource is present", () => { + expect( + isTicketSourceOwned({ + syncedSource: { provider: "github", url: "https://github.com/o/r/issues/1" }, + }), + ).toBe(true); + }); + + it("returns true for an Asana syncedSource", () => { + expect( + isTicketSourceOwned({ + syncedSource: { provider: "asana", url: "https://app.asana.com/0/123/456" }, + }), + ).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/board/ticketAffordance.ts b/apps/mobile/src/features/board/ticketAffordance.ts new file mode 100644 index 00000000000..18508b54a05 --- /dev/null +++ b/apps/mobile/src/features/board/ticketAffordance.ts @@ -0,0 +1,118 @@ +import type { + StepRunId, + WorkflowLaneActionView, + WorkflowStepRunView, + WorkflowTicketDetailView, +} from "@t3tools/contracts"; + +/** + * Returns true when the ticket is owned by an external sync source + * (i.e. its title/description are managed by the source provider and + * should be treated as read-only in the UI). + */ +export function isTicketSourceOwned( + detail: Pick, +): boolean { + return Boolean(detail.syncedSource); +} + +/** + * Discriminated union describing what the human can do with a ticket that + * surfaced in the "Needs you" inbox / notification deep-link. The `kind` is + * driven primarily off the server-projected `ticket.attentionKind`; the awaiting + * step's `providerResponseKind` is only consulted as a fallback. Every variant + * carries `laneActions` so the action sheet can always offer manual lane moves. + */ +export type TicketAffordance = + | { + readonly kind: "answer"; + readonly stepRunId: StepRunId; + readonly question: string | null; + readonly laneActions: readonly WorkflowLaneActionView[]; + } + | { + readonly kind: "approve"; + readonly stepRunId: StepRunId; + readonly question: string | null; + readonly laneActions: readonly WorkflowLaneActionView[]; + } + | { + readonly kind: "blocked"; + readonly blockReason: string | null; + readonly laneActions: readonly WorkflowLaneActionView[]; + } + | { + readonly kind: "comment"; + readonly laneActions: readonly WorkflowLaneActionView[]; + }; + +function findAwaitingStep( + detail: WorkflowTicketDetailView, +): WorkflowStepRunView | undefined { + return detail.steps.find((step) => step.status === "awaiting_user"); +} + +/** + * Maps a ticket detail view onto the single best human affordance. + * + * Mapping rules (see TicketActionSheetScreen for the UI): + * - `waiting_for_input` (or awaiting step `providerResponseKind === "user-input"`) + * → `answer`, requires the awaiting step's `stepRunId`; degrades to `comment` + * when no awaiting step is present. + * - `waiting_for_approval` (or `providerResponseKind === "request"`) → `approve`, + * same `stepRunId` requirement / degrade. + * - `blocked` attention OR `ticket.status === "blocked"` → `blocked`. + * - otherwise → `comment`. + */ +export function selectTicketAffordance( + detail: WorkflowTicketDetailView, +): TicketAffordance { + const ticket = detail.ticket; + const awaitingStep = findAwaitingStep(detail); + const laneActions = ticket.currentLane?.actions ?? []; + + const attentionKind = ticket.attentionKind; + const providerResponseKind = awaitingStep?.providerResponseKind ?? null; + + const wantsInput = + attentionKind === "waiting_for_input" || + (attentionKind === undefined && providerResponseKind === "user-input"); + const wantsApproval = + attentionKind === "waiting_for_approval" || + (attentionKind === undefined && providerResponseKind === "request"); + const isBlocked = attentionKind === "blocked" || ticket.status === "blocked"; + + if (wantsInput) { + if (awaitingStep) { + return { + kind: "answer", + stepRunId: awaitingStep.stepRunId, + question: awaitingStep.waitingReason ?? ticket.attentionReason ?? null, + laneActions, + }; + } + return { kind: "comment", laneActions }; + } + + if (wantsApproval) { + if (awaitingStep) { + return { + kind: "approve", + stepRunId: awaitingStep.stepRunId, + question: awaitingStep.waitingReason ?? ticket.attentionReason ?? null, + laneActions, + }; + } + return { kind: "comment", laneActions }; + } + + if (isBlocked) { + return { + kind: "blocked", + blockReason: awaitingStep?.blockedReason ?? ticket.attentionReason ?? null, + laneActions, + }; + } + + return { kind: "comment", laneActions }; +} diff --git a/apps/mobile/src/lib/routes.ts b/apps/mobile/src/lib/routes.ts index bf49a20ac41..6811ba6d5ac 100644 --- a/apps/mobile/src/lib/routes.ts +++ b/apps/mobile/src/lib/routes.ts @@ -1,6 +1,6 @@ import type { Href, useRouter } from "expo-router"; import type { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { BoardId, EnvironmentId, ThreadId, TicketId } from "@t3tools/contracts"; import type { SelectedThreadRef } from "../state/remote-runtime-types"; @@ -71,6 +71,16 @@ export function buildThreadTerminalNavigation( }; } +export function buildTicketRoutePath(input: { + readonly environmentId: EnvironmentId; + readonly boardId: BoardId; + readonly ticketId: TicketId; +}): string { + return `/tickets/${encodeURIComponent(input.environmentId)}/${encodeURIComponent( + input.boardId, + )}/${encodeURIComponent(input.ticketId)}`; +} + export function dismissRoute(router: Router) { if (router.canGoBack()) { router.back(); 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/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index bee861677a5..a02d25c5163 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -610,6 +610,41 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { cwd: input.cwd, args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], }).pipe(Effect.asVoid), + mergePullRequest: (input) => + Effect.fail( + new GitHubCliError({ + operation: "execute", + detail: `Unexpected merge: #${input.number}`, + }), + ), + getPullRequestDetail: (input) => + Effect.fail( + new GitHubCliError({ + operation: "execute", + detail: `Unexpected detail: #${input.number}`, + }), + ), + listPullRequestChecks: (input) => + Effect.fail( + new GitHubCliError({ + operation: "execute", + detail: `Unexpected checks: #${input.number}`, + }), + ), + listPullRequestReviews: (input) => + Effect.fail( + new GitHubCliError({ + operation: "execute", + detail: `Unexpected reviews: #${input.number}`, + }), + ), + listPullRequestReviewComments: (input) => + Effect.fail( + new GitHubCliError({ + operation: "execute", + detail: `Unexpected review comments: #${input.number}`, + }), + ), }, ghCalls, }; 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 f12df850941..3ae0df43884 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -612,6 +612,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.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 7db2a23e5ec..be71f531ce8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -563,6 +563,123 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); + it.effect("excludes hidden threads from the archived shell snapshot", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-hidden-archived-test', + 'Hidden Archived Test', + '/tmp/hidden-archived-test', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-07T00:00:00.000Z', + '2026-04-07T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + deleted_at, + hidden + ) + VALUES + ( + 'thread-archived-visible', + 'project-hidden-archived-test', + 'Archived Visible Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-07T00:00:02.000Z', + '2026-04-07T00:00:03.000Z', + '2026-04-07T00:00:04.000Z', + NULL, + 0 + ), + ( + 'thread-archived-hidden', + 'project-hidden-archived-test', + 'Archived Hidden Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-07T00:00:05.000Z', + '2026-04-07T00:00:06.000Z', + '2026-04-07T00:00:07.000Z', + NULL, + 1 + ) + `; + + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES + (${ORCHESTRATION_PROJECTOR_NAMES.projects}, 5, '2026-04-07T00:00:08.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threads}, 5, '2026-04-07T00:00:08.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 5, '2026-04-07T00:00:08.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 5, '2026-04-07T00:00:08.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 5, '2026-04-07T00:00:08.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 5, '2026-04-07T00:00:08.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 5, '2026-04-07T00:00:08.000Z') + `; + + const archivedShellSnapshot = yield* snapshotQuery.getArchivedShellSnapshot(); + assert.deepEqual( + archivedShellSnapshot.threads.map((thread) => thread.id), + [ThreadId.make("thread-archived-visible")], + "hidden archived thread must not appear in archived shell snapshot", + ); + }), + ); + it.effect( "reads targeted project, thread, and count queries without hydrating the full snapshot", () => diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index e629d1604b3..78338bae516 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 `, }); @@ -399,6 +400,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { FROM projection_threads WHERE deleted_at IS NULL AND archived_at IS NOT NULL + AND hidden = 0 ORDER BY project_id ASC, archived_at DESC, thread_id DESC `, }); @@ -508,6 +510,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 `, }); @@ -533,6 +536,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ON threads.thread_id = sessions.thread_id WHERE threads.deleted_at IS NULL AND threads.archived_at IS NOT NULL + AND threads.hidden = 0 ORDER BY sessions.thread_id ASC `, }); @@ -602,6 +606,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 `, @@ -628,6 +633,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 NOT NULL + AND threads.hidden = 0 AND threads.latest_turn_id IS NOT NULL ORDER BY turns.thread_id ASC `, @@ -711,6 +717,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 +1019,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 +1921,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 +2090,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..dd31d18847b 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,7 @@ 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_WorkflowSchema.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +90,7 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [33, "WorkflowSchema", Migration0033], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_WorkflowSchema.test.ts b/apps/server/src/persistence/Migrations/033_WorkflowSchema.test.ts new file mode 100644 index 00000000000..dc4715606a4 --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_WorkflowSchema.test.ts @@ -0,0 +1,466 @@ +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"; + +/** + * Equivalence gate for the collapsed workflow schema. + * + * `GOLDEN` below was captured from the real, original 23-step migration chain + * (033 -> 055) — it is the authoritative reference. The consolidated migration + * 033_WorkflowSchema must reproduce it EXACTLY. The dump filters to + * `tbl_name LIKE 'workflow_%' OR tbl_name = 'projection_threads'` (the objects + * the workflow feature owns or extends) and normalizes whitespace. + * + * If this test fails, the collapsed schema diverged from the chain — fix the + * migration, do not weaken the assertion. + */ + +const layer = it.layer(Layer.mergeAll(SqlitePersistenceMemory)); + +/** Collapse all runs of whitespace to a single space and trim. */ +const normalize = (sql: string) => sql.replace(/\s+/g, " ").trim(); + +interface MasterRow { + readonly type: string; + readonly name: string; + readonly tbl_name: string; + readonly sql: string; +} + +const GOLDEN: ReadonlyArray = [ + { + type: "table", + name: "projection_threads", + tbl_name: "projection_threads", + sql: "CREATE TABLE projection_threads ( thread_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, branch TEXT, worktree_path TEXT, latest_turn_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT , runtime_mode TEXT NOT NULL DEFAULT 'full-access', interaction_mode TEXT NOT NULL DEFAULT 'default', model_selection_json TEXT, archived_at TEXT, latest_user_message_at TEXT, pending_approval_count INTEGER NOT NULL DEFAULT 0, pending_user_input_count INTEGER NOT NULL DEFAULT 0, has_actionable_proposed_plan INTEGER NOT NULL DEFAULT 0, hidden INTEGER NOT NULL DEFAULT 0)", + }, + { + type: "index", + name: "idx_projection_threads_project_archived_at", + tbl_name: "projection_threads", + sql: "CREATE INDEX idx_projection_threads_project_archived_at ON projection_threads(project_id, archived_at)", + }, + { + type: "index", + name: "idx_projection_threads_project_deleted_created", + tbl_name: "projection_threads", + sql: "CREATE INDEX idx_projection_threads_project_deleted_created ON projection_threads(project_id, deleted_at, created_at)", + }, + { + type: "index", + name: "idx_projection_threads_project_id", + tbl_name: "projection_threads", + sql: "CREATE INDEX idx_projection_threads_project_id ON projection_threads(project_id)", + }, + { + type: "index", + name: "idx_projection_threads_shell_active", + tbl_name: "projection_threads", + sql: "CREATE INDEX idx_projection_threads_shell_active ON projection_threads(deleted_at, archived_at, project_id, created_at, thread_id)", + }, + { + type: "index", + name: "idx_projection_threads_shell_archived", + tbl_name: "projection_threads", + sql: "CREATE INDEX idx_projection_threads_shell_archived ON projection_threads(deleted_at, archived_at, project_id, thread_id)", + }, + { + type: "table", + name: "workflow_board_version", + tbl_name: "workflow_board_version", + sql: "CREATE TABLE 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 )", + }, + { + type: "index", + name: "idx_workflow_board_version_board", + tbl_name: "workflow_board_version", + sql: "CREATE INDEX idx_workflow_board_version_board ON workflow_board_version(board_id, version_id)", + }, + { + type: "index", + name: "idx_workflow_board_version_hash", + tbl_name: "workflow_board_version", + sql: "CREATE INDEX idx_workflow_board_version_hash ON workflow_board_version(board_id, version_hash)", + }, + { + type: "table", + name: "workflow_board_webhook", + tbl_name: "workflow_board_webhook", + sql: "CREATE TABLE workflow_board_webhook ( board_id TEXT PRIMARY KEY, token_hash TEXT NOT NULL, token_prefix TEXT NOT NULL, created_at TEXT NOT NULL )", + }, + { + type: "table", + name: "workflow_dispatch_outbox", + tbl_name: "workflow_dispatch_outbox", + sql: "CREATE TABLE 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 , options_json TEXT, project_id TEXT, thread_title TEXT, runtime_mode TEXT)", + }, + { + type: "index", + name: "idx_dispatch_outbox_pending", + tbl_name: "workflow_dispatch_outbox", + sql: "CREATE INDEX idx_dispatch_outbox_pending ON workflow_dispatch_outbox(status)", + }, + { + type: "table", + name: "workflow_events", + tbl_name: "workflow_events", + sql: "CREATE TABLE 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 )", + }, + { + type: "index", + name: "idx_workflow_events_stream_version", + tbl_name: "workflow_events", + sql: "CREATE UNIQUE INDEX idx_workflow_events_stream_version ON workflow_events(ticket_id, stream_version)", + }, + { + type: "table", + name: "workflow_pr_observation", + tbl_name: "workflow_pr_observation", + sql: "CREATE TABLE workflow_pr_observation ( observation_id TEXT PRIMARY KEY, ticket_id TEXT NOT NULL, dedup_key TEXT NOT NULL UNIQUE, event_name TEXT NOT NULL, payload_json TEXT NOT NULL, message_body TEXT NULL, status TEXT NOT NULL DEFAULT 'pending', attempt_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL )", + }, + { + type: "index", + name: "idx_workflow_pr_observation_pending", + tbl_name: "workflow_pr_observation", + sql: "CREATE INDEX idx_workflow_pr_observation_pending ON workflow_pr_observation (status, ticket_id)", + }, + { + type: "table", + name: "workflow_pr_state", + tbl_name: "workflow_pr_state", + sql: "CREATE TABLE workflow_pr_state ( ticket_id TEXT PRIMARY KEY, pr_number INTEGER NOT NULL, pr_url TEXT NOT NULL, branch TEXT NOT NULL, remote_name TEXT NOT NULL, repo TEXT NOT NULL, pr_state TEXT NOT NULL DEFAULT 'open', last_head_sha TEXT NULL, last_ci_state TEXT NULL, last_review_decision TEXT NULL, last_comment_cursor TEXT NULL, updated_at TEXT NOT NULL )", + }, + { + type: "index", + name: "idx_workflow_pr_state_open", + tbl_name: "workflow_pr_state", + sql: "CREATE INDEX idx_workflow_pr_state_open ON workflow_pr_state (pr_state) WHERE pr_state = 'open'", + }, + { + type: "table", + name: "workflow_project_trust", + tbl_name: "workflow_project_trust", + sql: "CREATE TABLE workflow_project_trust ( project_id TEXT PRIMARY KEY, trusted_at TEXT NOT NULL )", + }, + { + type: "table", + name: "workflow_script_run", + tbl_name: "workflow_script_run", + sql: "CREATE TABLE 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 )", + }, + { + type: "index", + name: "idx_workflow_script_run_status", + tbl_name: "workflow_script_run", + sql: "CREATE INDEX idx_workflow_script_run_status ON workflow_script_run(status)", + }, + { + type: "index", + name: "idx_workflow_script_run_ticket", + tbl_name: "workflow_script_run", + sql: "CREATE INDEX idx_workflow_script_run_ticket ON workflow_script_run(ticket_id)", + }, + { + type: "table", + name: "workflow_setup_run", + tbl_name: "workflow_setup_run", + sql: "CREATE TABLE 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 )", + }, + { + type: "table", + name: "workflow_webhook_delivery", + tbl_name: "workflow_webhook_delivery", + sql: "CREATE TABLE workflow_webhook_delivery ( board_id TEXT NOT NULL, delivery_id TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (board_id, delivery_id) )", + }, +]; + +const GOLDEN_PROJECTION_THREADS_COLUMNS = + "thread_id,project_id,title,branch,worktree_path,latest_turn_id,created_at,updated_at,deleted_at,runtime_mode,interaction_mode,model_selection_json,archived_at,latest_user_message_at,pending_approval_count,pending_user_input_count,has_actionable_proposed_plan,hidden"; + +layer("033_WorkflowSchema", (it) => { + it.effect("migration entry exists at id 33", () => + Effect.gen(function* () { + assert.isTrue( + migrationEntries.some(([id, name]) => id === 33 && name === "WorkflowSchema"), + ); + }), + ); + + it.effect("collapsed schema equals the golden 033->055 chain schema", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 33 }); + + const rows = yield* sql` + SELECT type, name, tbl_name, sql + FROM sqlite_master + WHERE (tbl_name LIKE 'workflow_%' OR tbl_name = 'projection_threads') + AND tbl_name != 'workflow_notification_outbox' + AND sql IS NOT NULL + ORDER BY tbl_name ASC, type DESC, name ASC + `; + + const actual = rows.map((row) => ({ + type: row.type, + name: row.name, + tbl_name: row.tbl_name, + sql: normalize(row.sql), + })); + + assert.deepEqual(actual, GOLDEN as Array); + }), + ); + + it.effect("projection_threads columns match the golden chain (incl. hidden)", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations(); + + const cols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_threads)`; + assert.strictEqual(cols.map((c) => c.name).join(","), GOLDEN_PROJECTION_THREADS_COLUMNS); + }), + ); + + // --- Readable targeted assertions for documentation value --- + + it.effect("projection_threads.hidden present", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + const cols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_threads)`; + assert.isTrue(cols.some((c) => c.name === "hidden")); + }), + ); + + it.effect("workflow_pr_observation.attempt_count present and dedup_key is UNIQUE", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + + const cols = yield* sql<{ readonly name: string }>` + PRAGMA table_info(workflow_pr_observation) + `; + assert.isTrue(cols.some((c) => c.name === "attempt_count")); + + yield* sql` + INSERT INTO workflow_pr_observation + (observation_id, ticket_id, dedup_key, event_name, payload_json, status, created_at) + VALUES + ('obs-1', 'ticket-a', 'dedup-xyz', 'ci_check', '{}', 'pending', '2026-01-01T00:00:00Z') + `; + const duplicate = yield* Effect.exit(sql` + INSERT INTO workflow_pr_observation + (observation_id, ticket_id, dedup_key, event_name, payload_json, status, created_at) + VALUES + ('obs-2', 'ticket-b', 'dedup-xyz', 'ci_check', '{}', 'pending', '2026-01-01T00:00:00Z') + `); + assert.strictEqual(duplicate._tag, "Failure"); + }), + ); + + it.effect("partial open index on workflow_pr_state present", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + const indexes = yield* sql<{ readonly name: string }>`PRAGMA index_list(workflow_pr_state)`; + assert.isTrue(indexes.some((idx) => idx.name === "idx_workflow_pr_state_open")); + }), + ); + + // --- Folded-in coverage from the former 034 (BoardNotifications) --- + + it.effect("workflow_notification_outbox table exists with expected columns", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + + const cols = yield* sql<{ + readonly name: string; + readonly type: string; + readonly notnull: number; + readonly pk: number; + }>`PRAGMA table_info(workflow_notification_outbox)`; + + assert.deepEqual( + cols.map((c) => c.name), + [ + "outbox_id", + "ticket_id", + "board_id", + "sequence", + "status", + "attention_kind", + "attention_reason", + "delivery_state", + "attempt_count", + "created_at", + ], + ); + + assert.strictEqual(cols.find((c) => c.name === "outbox_id")!.pk, 1); + assert.strictEqual(cols.find((c) => c.name === "ticket_id")!.notnull, 1); + assert.strictEqual(cols.find((c) => c.name === "sequence")!.type, "INTEGER"); + assert.strictEqual(cols.find((c) => c.name === "attention_kind")!.notnull, 0); + assert.strictEqual(cols.find((c) => c.name === "delivery_state")!.notnull, 1); + }), + ); + + it.effect("workflow_notification_outbox.sequence is UNIQUE", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + + yield* sql` + INSERT INTO workflow_notification_outbox + (outbox_id, ticket_id, board_id, sequence, status, delivery_state, created_at) + VALUES + ('outbox-1', 'ticket-a', 'board-x', 42, 'pending', 'pending', '2026-01-01T00:00:00Z') + `; + const duplicate = yield* Effect.exit(sql` + INSERT INTO workflow_notification_outbox + (outbox_id, ticket_id, board_id, sequence, status, delivery_state, created_at) + VALUES + ('outbox-2', 'ticket-b', 'board-y', 42, 'pending', 'pending', '2026-01-01T00:00:00Z') + `); + assert.strictEqual(duplicate._tag, "Failure"); + }), + ); + + it.effect("idx_workflow_notification_outbox_pending index exists", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + const indexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(workflow_notification_outbox) + `; + assert.isTrue( + indexes.some((idx) => idx.name === "idx_workflow_notification_outbox_pending"), + ); + }), + ); + + it.effect("projection_ticket has attention_kind and attention_reason columns", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + const cols = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_ticket)`; + const colNames = cols.map((c) => c.name); + assert.isTrue(colNames.includes("attention_kind"), "attention_kind column missing"); + assert.isTrue(colNames.includes("attention_reason"), "attention_reason column missing"); + }), + ); + + // --- Folded-in coverage from the former 035 (WorkSources) --- + + it.effect("work_source_connection table exists with expected columns", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + const cols = yield* sql<{ readonly name: string; readonly pk: number }>` + PRAGMA table_info(work_source_connection) + `; + assert.deepEqual( + cols.map((c) => c.name), + ["connection_ref", "provider", "display_name", "auth_mode", "token_secret_name", "created_at"], + ); + assert.strictEqual(cols.find((c) => c.name === "connection_ref")!.pk, 1); + }), + ); + + it.effect("work_source_mapping table exists with expected columns", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + const cols = yield* sql<{ readonly name: string; readonly pk: number }>` + PRAGMA table_info(work_source_mapping) + `; + assert.deepEqual( + cols.map((c) => c.name), + [ + "mapping_id", + "board_id", + "source_id", + "provider", + "external_id", + "ticket_id", + "provider_version", + "content_hash", + "lifecycle", + "sync_status", + "source_metadata_json", + "created_at", + "last_synced_at", + ], + ); + assert.strictEqual(cols.find((c) => c.name === "mapping_id")!.pk, 1); + }), + ); + + it.effect("work_source_state table exists with composite primary key", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + const cols = yield* sql<{ readonly name: string; readonly pk: number; readonly type: string }>` + PRAGMA table_info(work_source_state) + `; + assert.deepEqual( + cols.map((c) => c.name), + [ + "board_id", + "source_id", + "cursor_or_etag", + "last_full_run_at", + "backoff_until", + "consecutive_failures", + "last_error", + ], + ); + assert.isAbove(cols.find((c) => c.name === "board_id")!.pk, 0); + assert.isAbove(cols.find((c) => c.name === "source_id")!.pk, 0); + }), + ); + + it.effect("unique indexes on work_source_mapping exist and enforce uniqueness", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations(); + + const objects = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master + WHERE type = 'index' + AND name IN ('idx_work_source_mapping_external', 'idx_work_source_mapping_ticket') + ORDER BY name + `; + const indexNames = objects.map((o) => o.name); + assert.isTrue(indexNames.includes("idx_work_source_mapping_external")); + assert.isTrue(indexNames.includes("idx_work_source_mapping_ticket")); + + yield* sql` + INSERT INTO work_source_mapping + (mapping_id, board_id, source_id, provider, external_id, ticket_id, content_hash, lifecycle, created_at, last_synced_at) + VALUES + ('map-1', 'board-a', 'src-1', 'github', 'ext-1', 'ticket-x', 'hash-1', 'open', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z') + `; + const duplicate = yield* Effect.exit(sql` + INSERT INTO work_source_mapping + (mapping_id, board_id, source_id, provider, external_id, ticket_id, content_hash, lifecycle, created_at, last_synced_at) + VALUES + ('map-2', 'board-b', 'src-2', 'github', 'ext-2', 'ticket-x', 'hash-2', 'open', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z') + `); + assert.strictEqual(duplicate._tag, "Failure"); + }), + ); + + it.effect("33 is the highest migration entry", () => + Effect.gen(function* () { + const highest = migrationEntries.reduce((max, [id]) => (id > max ? id : max), 0); + assert.strictEqual(highest, 33); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/033_WorkflowSchema.ts b/apps/server/src/persistence/Migrations/033_WorkflowSchema.ts new file mode 100644 index 00000000000..e6a50bb200c --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_WorkflowSchema.ts @@ -0,0 +1,413 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Consolidated workflow schema. + * + * Collapses the former migrations 033-055 (all pure DDL — CREATE TABLE / + * ALTER TABLE ADD COLUMN / CREATE INDEX, no data backfills) into a single + * migration. ALTER-added columns are folded inline in ascending original + * migration order, so the resulting schema is byte-for-byte equivalent to the + * one produced by running the original 23-step chain. + * + * This branch (ft/hyperion) has only ever run on a single instance that will + * be wiped, so renumbering is safe — there is no deployed DB to preserve. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // --- Event store (was 033) --- + 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 + ) + `; + + // --- Read-model projections (was 033, with later ALTERs folded in) --- + 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 + ) + `; + + // projection_ticket base (033) + current_lane_entry_token (034) + queued_at + // (042) + terminal_at (046) + token_budget (053). description (044) and + // terminal_at (046) were guarded re-adds in the chain; description already + // exists in the 033 CREATE, so only the genuinely new columns are appended. + // attention_kind / attention_reason were added via ALTER in the former 034 + // (BoardNotifications) — folded inline here (TEXT, nullable, matching the + // ALTER-produced columns). + 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, + current_lane_entry_token TEXT, + queued_at TEXT, + terminal_at TEXT, + token_budget INTEGER, + attention_kind TEXT, + attention_reason TEXT + ) + `; + + 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 + ) + `; + + // projection_step_run base (033) + pre/post_checkpoint_ref (038) + + // output_json (041) + provider_response_kind (045) + attempt (048) + + // usage columns (049). + 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, + pre_checkpoint_ref TEXT, + post_checkpoint_ref TEXT, + output_json TEXT, + provider_response_kind TEXT, + attempt INTEGER, + input_tokens INTEGER, + cached_input_tokens INTEGER, + output_tokens INTEGER, + total_tokens INTEGER, + retryable INTEGER + ) + `; + + // projection_ticket_message (044) + 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 + ) + `; + + // projection_ticket_dependency (052) + 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) + ) + `; + + // --- Worktree lease (035) --- + 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 + ) + `; + + // --- Dispatch outbox --- + // Created (036) then extended via ALTER ADD COLUMN in 047 (options_json) and + // 051 (project_id, thread_title, runtime_mode). SQLite stores the canonical + // CREATE SQL with ALTER-appended columns spliced in before the closing paren, + // which leaves a characteristic ` ,` / ` )` whitespace shape. We reproduce + // the original CREATE + ALTER sequence verbatim so the stored sqlite_master + // SQL is byte-for-byte identical to the original 23-step chain. + 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`ALTER TABLE workflow_dispatch_outbox ADD COLUMN options_json TEXT`; + 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`; + + // --- Setup run (037) --- + 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 + ) + `; + + // --- Project trust (039) --- + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_project_trust ( + project_id TEXT PRIMARY KEY, + trusted_at TEXT NOT NULL + ) + `; + + // --- Script run (040) --- + 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 + ) + `; + + // --- Board version (043) --- + 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 + ) + `; + + // --- Board webhook + delivery dedup (054) --- + 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) + ) + `; + + // --- Pull request state + observations (055) --- + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_pr_state ( + ticket_id TEXT PRIMARY KEY, + pr_number INTEGER NOT NULL, + pr_url TEXT NOT NULL, + branch TEXT NOT NULL, + remote_name TEXT NOT NULL, + repo TEXT NOT NULL, + pr_state TEXT NOT NULL DEFAULT 'open', + last_head_sha TEXT NULL, + last_ci_state TEXT NULL, + last_review_decision TEXT NULL, + last_comment_cursor TEXT NULL, + updated_at TEXT NOT NULL + ) + `; + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_pr_observation ( + observation_id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL, + dedup_key TEXT NOT NULL UNIQUE, + event_name TEXT NOT NULL, + payload_json TEXT NOT NULL, + message_body TEXT NULL, + status TEXT NOT NULL DEFAULT 'pending', + attempt_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) + `; + + // --- Board notification outbox (was 034) --- + yield* sql` + CREATE TABLE IF NOT EXISTS workflow_notification_outbox ( + outbox_id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL, + board_id TEXT NOT NULL, + sequence INTEGER NOT NULL UNIQUE, + status TEXT NOT NULL, + attention_kind TEXT NULL, + attention_reason TEXT NULL, + delivery_state TEXT NOT NULL DEFAULT 'pending', + attempt_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) + `; + + // --- Work sources (was 035) --- + yield* sql` + CREATE TABLE IF NOT EXISTS work_source_connection ( + connection_ref TEXT PRIMARY KEY, + provider TEXT NOT NULL, + display_name TEXT NOT NULL, + auth_mode TEXT NOT NULL, + token_secret_name TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `; + yield* sql` + CREATE TABLE IF NOT EXISTS work_source_mapping ( + mapping_id TEXT PRIMARY KEY, + board_id TEXT NOT NULL, + source_id TEXT NOT NULL, + provider TEXT NOT NULL, + external_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + provider_version TEXT NULL, + content_hash TEXT NOT NULL, + lifecycle TEXT NOT NULL, + sync_status TEXT NOT NULL DEFAULT 'active', + source_metadata_json TEXT NULL, + created_at TEXT NOT NULL, + last_synced_at TEXT NOT NULL + ) + `; + yield* sql` + CREATE UNIQUE INDEX IF NOT EXISTS idx_work_source_mapping_external + ON work_source_mapping (board_id, source_id, provider, external_id) + `; + yield* sql` + CREATE UNIQUE INDEX IF NOT EXISTS idx_work_source_mapping_ticket + ON work_source_mapping (ticket_id) + `; + yield* sql` + CREATE TABLE IF NOT EXISTS work_source_state ( + board_id TEXT NOT NULL, + source_id TEXT NOT NULL, + cursor_or_etag TEXT NULL, + last_full_run_at TEXT NULL, + backoff_until TEXT NULL, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + last_error TEXT NULL, + PRIMARY KEY (board_id, source_id) + ) + `; + + // --- Indexes --- + yield* sql` + CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_events_stream_version + ON workflow_events(ticket_id, stream_version) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_ticket_board + ON projection_ticket(board_id) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_step_run_ticket + ON projection_step_run(ticket_id) + `; + 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) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_ticket_message_ticket + ON projection_ticket_message(ticket_id, created_at) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_ticket_terminal_retention + ON projection_ticket(board_id, current_lane_key, terminal_at) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_ticket_dependency_depends_on + ON projection_ticket_dependency(depends_on_ticket_id) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_dispatch_outbox_pending + ON workflow_dispatch_outbox(status) + `; + 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) + `; + 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) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_pr_state_open + ON workflow_pr_state (pr_state) + WHERE pr_state = 'open' + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_pr_observation_pending + ON workflow_pr_observation (status, ticket_id) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_workflow_notification_outbox_pending + ON workflow_notification_outbox (delivery_state, created_at) + `; + + // --- projection_threads.hidden (050). The table is created by a <=032 + // migration, so this only appends the column. --- + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0 + `; +}); 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..eff5a531fce 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,11 +56,13 @@ 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, restart: () => Effect.die(new Error("unused")), close: () => Effect.void, + getSnapshot: () => Effect.succeed(null), subscribe: () => Effect.succeed(() => undefined), subscribeMetadata: () => Effect.succeed(() => undefined), }), @@ -117,11 +120,13 @@ 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, restart: () => Effect.die(new Error("unused")), close: () => Effect.void, + getSnapshot: () => Effect.succeed(null), subscribe: () => Effect.succeed(() => undefined), subscribeMetadata: () => Effect.succeed(() => undefined), }), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 916c9d077dd..c4ccf58e458 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1891,6 +1891,90 @@ describe("ClaudeAdapterLive", () => { }, ); + it.effect( + "treats flat cumulative result usage without iterations as totals, not context usage", + () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + }); + + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-usage-flat-total", + description: "Thinking through the patch", + usage: { + total_tokens: 190000, + }, + session_id: "sdk-session-task-usage-flat-total", + uuid: "task-usage-progress-flat-total", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 1234, + duration_api_ms: 1200, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-result-usage-flat-total", + usage: { + input_tokens: 1200, + cache_creation_input_tokens: 33800, + cache_read_input_tokens: 480000, + output_tokens: 20000, + }, + modelUsage: { + "claude-opus-4-6": { + contextWindow: 200000, + maxOutputTokens: 64000, + }, + }, + } as unknown as SDKMessage); + harness.query.finish(); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const usageEvents = runtimeEvents.filter( + (event) => event.type === "thread.token-usage.updated", + ); + const finalUsageEvent = usageEvents.at(-1); + assert.equal(finalUsageEvent?.type, "thread.token-usage.updated"); + if (finalUsageEvent?.type === "thread.token-usage.updated") { + assert.deepEqual(finalUsageEvent.payload, { + usage: { + usedTokens: 190000, + lastUsedTokens: 190000, + totalProcessedTokens: 535000, + maxTokens: 200000, + }, + }); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + it.effect( "emits completion only after turn result when assistant frames arrive before deltas", () => { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 38b77c69262..40336b3c3ce 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -770,6 +770,7 @@ function applyClaudeTaskToolResult( if (!Array.isArray(resultTasks)) { return false; } + const hadTasks = tasks.size > 0; tasks.clear(); for (const entry of resultTasks) { if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { @@ -788,7 +789,7 @@ function applyClaudeTaskToolResult( blockedBy: new Set(readStringArray(task.blockedBy)), }); } - return tasks.size > 0; + return tasks.size > 0 || hadTasks; } if (tool.toolName === "TaskCreate") { @@ -1925,13 +1926,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( : undefined; const hasResultUsageIteration = resultUsageRecord !== undefined && lastClaudeUsageIteration(resultUsageRecord) !== undefined; - const resultHasActiveUsage = - resultUsageRecord !== undefined && - (hasResultUsageIteration || - claudeUsageInputTokens(resultUsageRecord) + claudeUsageOutputTokens(resultUsageRecord) > 0); + // Without an `iterations` array, result.usage carries turn-cumulative + // totals (flat fields included), not the active context size — only an + // iteration snapshot is trusted for `usedTokens`. const resultTotalOnly = resultUsageRecord !== undefined && - !resultHasActiveUsage && + !hasResultUsageIteration && claudeTotalProcessedTokens(resultUsageRecord) !== undefined; const resultIterationSnapshot = resultUsageRecord ? normalizeClaudeActiveTokenUsage( diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 0e21b76306b..fd8d3ced51e 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -64,6 +64,59 @@ it("maps current Codex model capability fields", () => { ]); }); +it("does not duplicate the default option when the catalog carries a 'default' tier", () => { + const capabilities = mapCodexModelCapabilities({ + additionalSpeedTiers: [], + defaultReasoningEffort: "medium", + defaultServiceTier: "default", + description: "Test model", + displayName: "GPT Test", + hidden: false, + id: "gpt-test", + isDefault: true, + model: "gpt-test", + serviceTiers: [ + { + id: "default", + name: "Standard", + description: "Balanced speed and cost.", + }, + { + id: "priority", + name: "Fast", + description: "Lower latency responses.", + }, + ], + supportedReasoningEfforts: [], + }); + + const serviceTier = capabilities.optionDescriptors?.find( + (descriptor) => descriptor.id === "serviceTier", + ); + assert.deepStrictEqual(serviceTier, { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { + id: "default", + label: "Standard", + description: "Balanced speed and cost.", + isDefault: true, + }, + { + id: "priority", + label: "Fast", + description: "Lower latency responses.", + }, + ], + currentValue: "default", + }); + const options = serviceTier?.type === "select" ? serviceTier.options : []; + assert.strictEqual(options.filter((option) => option.id === "default").length, 1); + assert.strictEqual(options.filter((option) => option.isDefault === true).length, 1); +}); + it("uses standard routing when the catalog has no default service tier", () => { const capabilities = mapCodexModelCapabilities({ additionalSpeedTiers: ["fast"], diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 89d7421b232..7262ac476c0 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -142,16 +142,24 @@ export function mapCodexModelCapabilities( }); } if (serviceTiers.length > 0) { + // Only synthesize the Standard option when the catalog doesn't already + // carry a 'default' tier — otherwise the catalog entry (mapped below with + // its own name/description) would be duplicated. + const hasCatalogDefaultTier = serviceTiers.some((tier) => tier.id === DEFAULT_SERVICE_TIER_ID); optionDescriptors.push({ id: "serviceTier", label: "Service Tier", type: "select", options: [ - { - id: DEFAULT_SERVICE_TIER_ID, - label: "Standard", - ...(defaultServiceTier === DEFAULT_SERVICE_TIER_ID ? { isDefault: true } : {}), - }, + ...(hasCatalogDefaultTier + ? [] + : [ + { + id: DEFAULT_SERVICE_TIER_ID, + label: "Standard", + ...(defaultServiceTier === DEFAULT_SERVICE_TIER_ID ? { isDefault: true } : {}), + }, + ]), ...serviceTiers.map((tier) => ({ id: tier.id, label: tier.name, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 3f483d8fd7e..e33e0971ae6 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -63,6 +63,29 @@ const runtimeMock = { closeError: null as Error | null, messages: [] as MessageEntry[], subscribedEvents: [] as unknown[], + // When true, the subscribed-event stream stays open after draining + // `subscribedEvents` and waits for `pushSubscribedEvent` calls, so tests + // can interleave SSE delivery with adapter calls. + subscribedEventsOpen: false, + notifySubscribedEvent: [] as Array<() => void>, + }, + pushSubscribedEvent(event: unknown) { + this.state.subscribedEvents.push(event); + for (const notify of this.state.notifySubscribedEvent.splice(0)) { + notify(); + } + }, + // Tests that set `subscribedEventsOpen` MUST close the stream before + // finishing (e.g. via Effect.ensuring) — a generator left suspended on the + // notify promise blocks the event-pump fiber's teardown at scope close. + // Note: pumps of sessions left over from earlier tests may also be + // suspended here (their lazy first pull can happen while the stream is + // open), which is why the waiter list must support multiple resolvers. + closeSubscribedEvents() { + this.state.subscribedEventsOpen = false; + for (const notify of this.state.notifySubscribedEvent.splice(0)) { + notify(); + } }, reset() { this.state.startCalls.length = 0; @@ -76,6 +99,7 @@ const runtimeMock = { this.state.closeError = null; this.state.messages = []; this.state.subscribedEvents = []; + this.closeSubscribedEvents(); }, }; @@ -161,8 +185,18 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { event: { subscribe: async () => ({ stream: (async function* () { - for (const event of runtimeMock.state.subscribedEvents) { - yield event; + let index = 0; + while (true) { + if (index < runtimeMock.state.subscribedEvents.length) { + yield runtimeMock.state.subscribedEvents[index++]; + continue; + } + if (!runtimeMock.state.subscribedEventsOpen) { + return; + } + await new Promise((resolve) => { + runtimeMock.state.notifySubscribedEvent.push(resolve); + }); } })(), }), @@ -460,20 +494,124 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { input: "actually run 15", modelSelection: { instanceId: ProviderInstanceId.make("opencode"), - model: "openai/gpt-5", + model: "anthropic/claude-sonnet-4-5", }, }) .pipe(Effect.flip); - // The original turn keeps running — only the steer prompt failed. + // The original turn keeps running — only the steer prompt failed, and + // the pre-steer model is restored instead of reporting the new one. assert.equal(error._tag, "ProviderAdapterRequestError"); const sessions = yield* adapter.listSessions(); const session = sessions.find((entry) => entry.threadId === threadId); assert.equal(session?.status, "running"); assert.equal(String(session?.activeTurnId), String(turn.turnId)); + assert.equal(session?.model, "openai/gpt-5"); + assert.equal(session?.lastError, "steer failed"); }), ); + it.effect("opens a fresh turn for a prompt sent right after an interrupt", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-interrupt-then-prompt"); + const openCodeSessionId = "http://127.0.0.1:9999/session"; + const statusEvent = (status: Record) => ({ + type: "session.status", + properties: { sessionID: openCodeSessionId, status }, + }); + // Keep the SSE stream open so events can be delivered mid-test. + runtimeMock.state.subscribedEventsOpen = true; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "run 5 commands", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "openai/gpt-5", + }, + }); + + yield* adapter.interruptTurn(threadId, turn.turnId); + + // The interrupt settles the turn synchronously — without waiting for + // the async SSE idle event the session must already be ready. + const interruptedSessions = yield* adapter.listSessions(); + const interrupted = interruptedSessions.find((entry) => entry.threadId === threadId); + assert.equal(interrupted?.status, "ready"); + assert.equal(interrupted?.activeTurnId, undefined); + + // A prompt sent immediately after the interrupt is a fresh turn, not a + // steer of the aborted one. + const nextTurn = yield* adapter.sendTurn({ + threadId, + input: "try something else", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "openai/gpt-5", + }, + }); + assert.notEqual(String(nextTurn.turnId), String(turn.turnId)); + + const sessions = yield* adapter.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); + assert.equal(session?.status, "running"); + assert.equal(String(session?.activeTurnId), String(nextTurn.turnId)); + + // The abort of the interrupted turn makes the server emit a trailing + // idle. Deliver it AFTER the fresh turn has started: it must not + // settle the fresh turn. The retry event is an observable marker that + // proves the stale idle was processed without emitting turn.completed, + // and the busy + idle pair is the fresh turn's own lifecycle, which + // must still complete it. + const settleEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "turn.completed" || event.type === "runtime.warning"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + + runtimeMock.pushSubscribedEvent(statusEvent({ type: "idle" })); + runtimeMock.pushSubscribedEvent(statusEvent({ type: "retry", message: "stale-idle-marker" })); + runtimeMock.pushSubscribedEvent(statusEvent({ type: "busy" })); + runtimeMock.pushSubscribedEvent(statusEvent({ type: "idle" })); + + const settleEvents = Array.from( + yield* Fiber.join(settleEventsFiber).pipe(Effect.timeout("1 second")), + ); + // The stale abort-idle (processed before the marker warning) emitted no + // turn.completed; only the genuine idle completed the fresh turn. + assert.deepEqual( + settleEvents.map((event) => event.type), + ["runtime.warning", "turn.completed"], + ); + const completed = settleEvents[1]; + if (completed?.type === "turn.completed") { + assert.equal(String(completed.turnId), String(nextTurn.turnId)); + assert.equal(completed.payload.state, "completed"); + } + + const settledSessions = yield* adapter.listSessions(); + const settled = settledSessions.find((entry) => entry.threadId === threadId); + assert.equal(settled?.status, "ready"); + assert.equal(settled?.activeTurnId, undefined); + }).pipe( + // Close the live SSE stream so the event-pump fiber can wind down at + // scope close instead of hanging on the suspended mock generator. + Effect.ensuring(Effect.sync(() => runtimeMock.closeSubscribedEvents())), + ), + ); + it.effect("passes agent and variant options for the adapter's bound custom instance id", () => { const instanceId = ProviderInstanceId.make("opencode_zen"); const adapterLayer = Layer.effect( diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 54444ce586d..31fea152ddd 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -80,6 +80,16 @@ interface OpenCodeSessionContext { activeTurnId: TurnId | undefined; activeAgent: string | undefined; activeVariant: string | undefined; + /** + * Set by `interruptTurn` after a successful abort: the abort makes the + * server emit a trailing idle (and possibly error) status for the aborted + * turn, which `interruptTurn` already settled synchronously. Those stale + * events must not settle a newer turn started right after the interrupt, + * so idle/error handling is suppressed until the next `busy` status — the + * server emits the abort-idle before the next turn's busy, so once busy is + * seen any later idle/error is genuine again. + */ + suppressSettleEventsUntilBusy: boolean; /** * One-shot guard flipped by `stopOpenCodeContext` / `emitUnexpectedExit`. * The session lifecycle is owned by `sessionScope`; this Ref exists only @@ -874,6 +884,8 @@ export function makeOpenCodeAdapter( case "session.status": { if (event.properties.status.type === "busy") { + // A new turn is running: any idle/error from here on is genuine. + context.suppressSettleEventsUntilBusy = false; yield* updateProviderSession(context, { status: "running", activeTurnId: turnId, @@ -896,6 +908,13 @@ export function makeOpenCodeAdapter( break; } + if (event.properties.status.type === "idle" && context.suppressSettleEventsUntilBusy) { + // Stale idle caused by interruptTurn's abort — that turn was + // already settled there; ignore it so it cannot settle a newer + // turn started after the interrupt. + break; + } + if (event.properties.status.type === "idle" && turnId) { context.activeTurnId = undefined; yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); @@ -915,6 +934,12 @@ export function makeOpenCodeAdapter( } case "session.error": { + if (context.suppressSettleEventsUntilBusy) { + // Error fallout from interruptTurn's abort — that turn was + // already settled there; ignore it so it cannot fail a newer + // turn started after the interrupt. + break; + } const message = sessionErrorMessage(event.properties.error); const activeTurnId = context.activeTurnId; context.activeTurnId = undefined; @@ -1124,6 +1149,7 @@ export function makeOpenCodeAdapter( activeTurnId: undefined, activeAgent: undefined, activeVariant: undefined, + suppressSettleEventsUntilBusy: false, stopped: yield* Ref.make(false), sessionScope: started.sessionScope, }; @@ -1197,6 +1223,12 @@ export function makeOpenCodeAdapter( const agent = getModelSelectionStringOptionValue(modelSelection, "agent"); const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); + // Snapshot the pre-prompt state so a failed steer can roll back to the + // still-running original turn's agent/variant/model. + const previousAgent = context.activeAgent; + const previousVariant = context.activeVariant; + const previousModel = context.session.model; + context.activeTurnId = turnId; context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); context.activeVariant = variant; @@ -1235,10 +1267,23 @@ export function makeOpenCodeAdapter( // session back to ready with lastError set, emit turn.aborted, then // let the typed error propagate. We don't need to rebuild the error // here — `toRequestError` already produced the right shape. A failed - // steer leaves the still-running original turn untouched. + // steer leaves the still-running original turn untouched, but the + // pre-prompt agent/variant/model mutations are rolled back so the + // adapter keeps reporting the running turn's state; no turn.aborted + // is emitted because that turn is still running. Effect.tapError((requestError) => steeringTurnId !== undefined - ? Effect.void + ? Effect.gen(function* () { + context.activeTurnId = steeringTurnId; + context.activeAgent = previousAgent; + context.activeVariant = previousVariant; + yield* updateProviderSession(context, { + status: "running", + activeTurnId: steeringTurnId, + ...(previousModel !== undefined ? { model: previousModel } : {}), + lastError: requestError.detail, + }); + }) : Effect.gen(function* () { context.activeTurnId = undefined; context.activeAgent = undefined; @@ -1278,11 +1323,18 @@ export function makeOpenCodeAdapter( yield* runOpenCodeSdk("session.abort", () => context.client.session.abort({ sessionID: context.openCodeSessionId }), ).pipe(Effect.mapError(toRequestError)); - if (turnId ?? context.activeTurnId) { + // The abort makes the server emit a trailing idle/error status for + // the aborted turn. We settle the turn synchronously below, so those + // stale events must be ignored until the next turn's busy status — + // otherwise a late abort-idle could settle a turn started right + // after this interrupt. + context.suppressSettleEventsUntilBusy = true; + const abortedTurnId = turnId ?? context.activeTurnId; + if (abortedTurnId) { yield* emit({ ...(yield* buildEventBase({ threadId, - turnId: turnId ?? context.activeTurnId, + turnId: abortedTurnId, })), type: "turn.aborted", payload: { @@ -1290,6 +1342,14 @@ export function makeOpenCodeAdapter( }, }); } + // Settle the turn synchronously instead of waiting for the async SSE + // idle event: a prompt sent right after an interrupt must open a fresh + // turn rather than be misclassified as a steer of the aborted one. + // Mirrors the idle handler's cleanup. + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); }, ); 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.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index bbfbd236ad0..e9eb32caf16 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -453,6 +453,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { Effect.as(Option.some(thread)), ), getProjectShellById: () => Effect.succeed(Option.some(project)), + isThreadHidden: () => Effect.succeed(false), } as unknown as ProjectionSnapshotQueryShape; const descriptor = { @@ -619,6 +620,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { } satisfies OrchestrationShellSnapshot), getThreadShellById: () => Effect.succeed(Option.some(thread)), getProjectShellById: () => Effect.succeed(Option.some(project)), + isThreadHidden: () => Effect.succeed(false), } as unknown as ProjectionSnapshotQueryShape), ); 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..a1c68431dc8 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,19 @@ 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 { WorkSourceConnectionStore } from "./workflow/Services/WorkSourceConnectionStore.ts"; +import { WorkSourceAuthError } from "./workflow/Services/WorkSourceProvider.ts"; import * as Data from "effect/Data"; const defaultProjectId = ProjectId.make("project-default"); @@ -535,6 +551,75 @@ 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, + }), + Layer.mock(WorkSourceConnectionStore)({ + getToken: (connectionRef) => + Effect.fail(new WorkSourceAuthError({ connectionRef })), + create: () => Effect.die("unused work-source connection create"), + list: () => Effect.succeed([]), + remove: () => Effect.void, + }), + ); const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, @@ -719,6 +804,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.checkpointDiffQuery, }), ), + Layer.provide(workflowRouteServicesLayer), ); const appLayer = servedRoutesLayer.pipe( @@ -910,9 +996,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 +1493,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 +1511,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 b7331f95e6e..a6387efda41 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, @@ -189,6 +192,15 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay Layer.provideMerge(VcsDriverRegistryLayerLive), ); +// The workflow PR steps need GitHubCli alongside the registry. Re-export +// GitHubCli as a peer output of the registry layer (which consumes it +// internally but does not surface it); GitHubCli's VcsProcess requirement is +// satisfied by the single VcsProcess.layer provided at makeServerLayer level, +// so no second ProcessRunner pool is created. +const SourceControlForWorkflowLive = SourceControlProviderRegistryLayerLive.pipe( + Layer.provideMerge(GitHubCli.layer), +); + const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(ProjectSetupScriptRunnerLive), Layer.provideMerge(GitVcsDriver.layer), @@ -267,13 +279,27 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); -const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( +const WorkflowRuntimeLayerLive = WorkflowServerRuntimeLive.pipe( + Layer.provideMerge(CheckpointingLayerLive), + Layer.provideMerge(SourceControlForWorkflowLive), + 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), @@ -301,6 +327,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( @@ -337,6 +366,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..b6494892a9b 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -17,6 +17,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; import * as Context from "effect/Context"; import * as Console from "effect/Console"; @@ -34,6 +35,11 @@ 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 { WorkflowBoardNotificationDispatcher } from "./workflow/Services/WorkflowBoardNotificationDispatcher.ts"; +import { WorkflowSourceSyncer } from "./workflow/Services/WorkflowSourceSyncer.ts"; +import { WorkflowGitHubPoller } from "./workflow/Services/WorkflowGitHubPoller.ts"; +import { WorkflowRecovery } from "./workflow/Services/WorkflowRecovery.ts"; +import { WorkflowTerminalRetentionSweeper } from "./workflow/Services/WorkflowTerminalRetentionSweeper.ts"; import { formatHeadlessServeOutput, formatHostForUrl, @@ -286,9 +292,14 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { const keybindings = yield* Keybindings; const orchestrationReactor = yield* OrchestrationReactor; const providerSessionReaper = yield* ProviderSessionReaper; + const workflowTerminalRetentionSweeper = yield* WorkflowTerminalRetentionSweeper; + const workflowGitHubPoller = yield* WorkflowGitHubPoller; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const serverEnvironment = yield* ServerEnvironment; + const workflowRecovery = yield* WorkflowRecovery; + const workflowBoardNotificationDispatcher = yield* WorkflowBoardNotificationDispatcher; + const workflowSourceSyncer = yield* WorkflowSourceSyncer; const crypto = yield* Crypto.Crypto; const commandGate = yield* makeCommandGate; @@ -334,9 +345,63 @@ 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* workflowGitHubPoller.start().pipe(Scope.provide(reactorScope)); }), ); + yield* Effect.logDebug("startup phase: recovering workflow runtime"); + // Recovery is non-fatal for the rest of startup (the server must still + // boot), but we capture whether it SUCCEEDED so we can gate the board + // notification dispatcher on it below. + const recovered = yield* runStartupPhase( + "workflow.recover", + workflowRecovery.recover().pipe( + Effect.retry( + Schedule.exponential("500 millis").pipe(Schedule.both(Schedule.recurs(3))), + ), + Effect.as(true), + Effect.catch((cause) => + Effect.logWarning("workflow recovery failed during startup", { + cause, + }).pipe(Effect.as(false)), + ), + ), + ); + + // Start the board notification dispatcher AFTER recovery SUCCEEDS: + // recovery may write outbox rows / fix projections that the dispatcher then + // drains, so starting before (or after a failed) recovery risks draining a + // half-recovered state — wrongly superseding a needed notification or + // publishing stale content. + if (recovered) { + yield* Effect.logDebug("startup phase: starting workflow board notification dispatcher"); + yield* runStartupPhase( + "workflow.board-notifications.start", + workflowBoardNotificationDispatcher.start().pipe(Scope.provide(reactorScope)), + ); + } else { + yield* Effect.logWarning( + "skipping board-notification dispatcher start: workflow recovery failed", + ); + } + + // Start the work-source syncer ONLY after recovery succeeds: the syncer + // creates/admits tickets from upstream sources, so it must not run against a + // half-recovered projection. Same recovery gate as the notification + // dispatcher above. + if (recovered) { + yield* Effect.logDebug("startup phase: starting workflow source syncer"); + yield* runStartupPhase( + "workflow.source-sync.start", + workflowSourceSyncer.start().pipe(Scope.provide(reactorScope)), + ); + } else { + yield* Effect.logWarning( + "skipping work-source syncer start: workflow recovery failed", + ); + } + const welcomeBase = yield* resolveWelcomeBase; const environment = yield* serverEnvironment.getDescriptor; yield* Effect.logDebug("startup phase: preparing welcome payload"); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index fb765b352c2..82352746333 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -15,6 +15,18 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ stderrTruncated: false, }); +const processOutputWithExit = ( + stdout: string, + exitCode: number, + stderr = "", +): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(exitCode), + stdout, + stderr, + stdoutTruncated: false, + stderrTruncated: false, +}); + const mockRun = vi.fn(); const layer = GitHubCli.layer.pipe( @@ -293,4 +305,314 @@ describe("GitHubCli.layer", () => { assert.equal(error.message.includes("Pull request not found"), true); }).pipe(Effect.provide(layer)), ); + + it.effect("creates a draft pull request when draft is true", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "feature/x", + title: "My PR", + bodyFile: "/tmp/body.md", + draft: true, + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: [ + "pr", + "create", + "--base", + "main", + "--head", + "feature/x", + "--title", + "My PR", + "--body-file", + "/tmp/body.md", + "--draft", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("merges a pull request with the requested strategy", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.mergePullRequest({ cwd: "/repo", number: 7, strategy: "squash" }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "merge", "7", "--squash"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("maps merge/rebase strategies to the gh flag", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.mergePullRequest({ cwd: "/repo", number: 7, strategy: "merge" }); + yield* gh.mergePullRequest({ cwd: "/repo", number: 8, strategy: "rebase" }); + + expect(mockRun).toHaveBeenNthCalledWith(1, { + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "merge", "7", "--merge"], + cwd: "/repo", + timeoutMs: 30_000, + }); + expect(mockRun).toHaveBeenNthCalledWith(2, { + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "merge", "8", "--rebase"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("surfaces gh stderr when a merge fails", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.fail( + new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh pr merge", + cwd: "/repo", + exitCode: 1, + detail: "Pull request is not mergeable: the base branch policy requires review.", + }), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh + .mergePullRequest({ cwd: "/repo", number: 7, strategy: "squash" }) + .pipe(Effect.flip); + + assert.equal(error.message.includes("not mergeable"), true); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reads pull request detail json", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + state: "OPEN", + mergedAt: null, + reviewDecision: "CHANGES_REQUESTED", + headRefOid: "abc123", + url: "https://github.com/o/r/pull/7", + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getPullRequestDetail({ cwd: "/repo", number: 7 }); + + assert.deepStrictEqual(result, { + state: "OPEN", + mergedAt: null, + reviewDecision: "CHANGES_REQUESTED", + headRefOid: "abc123", + url: "https://github.com/o/r/pull/7", + }); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: [ + "pr", + "view", + "7", + "--json", + "state,mergedAt,reviewDecision,headRefOid,url", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("treats pr checks exit codes 0, 1 and 8 as success", () => + Effect.gen(function* () { + const checksJson = + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { name: "build", state: "SUCCESS", bucket: "pass", link: "https://x/runs/1" }, + { name: "test", state: "FAILURE", bucket: "fail", link: "https://x/runs/2" }, + ]); + mockRun.mockReturnValueOnce(Effect.succeed(processOutputWithExit(checksJson, 0))); + mockRun.mockReturnValueOnce(Effect.succeed(processOutputWithExit(checksJson, 1))); + mockRun.mockReturnValueOnce(Effect.succeed(processOutputWithExit(checksJson, 8))); + + const gh = yield* GitHubCli.GitHubCli; + const expected = [ + { name: "build", state: "SUCCESS", bucket: "pass", link: "https://x/runs/1" }, + { name: "test", state: "FAILURE", bucket: "fail", link: "https://x/runs/2" }, + ]; + + const r0 = yield* gh.listPullRequestChecks({ cwd: "/repo", number: 7 }); + const r1 = yield* gh.listPullRequestChecks({ cwd: "/repo", number: 7 }); + const r8 = yield* gh.listPullRequestChecks({ cwd: "/repo", number: 7 }); + + assert.deepStrictEqual(r0, expected); + assert.deepStrictEqual(r1, expected); + assert.deepStrictEqual(r8, expected); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "checks", "7", "--json", "name,state,bucket,link"], + cwd: "/repo", + timeoutMs: 30_000, + allowNonZeroExit: true, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("returns an empty checks list when gh reports no checks", () => + Effect.gen(function* () { + // gh prints nothing and exits 0 when a PR has no checks configured. + mockRun.mockReturnValueOnce(Effect.succeed(processOutputWithExit("", 0))); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listPullRequestChecks({ cwd: "/repo", number: 7 }); + + assert.deepStrictEqual(result, []); + }).pipe(Effect.provide(layer)), + ); + + it.effect("fails pr checks on an unexpected exit code", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed(processOutputWithExit("boom", 2, "fatal: unexpected")), + ); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh + .listPullRequestChecks({ cwd: "/repo", number: 7 }) + .pipe(Effect.flip); + + assert.equal(error.operation, "listPullRequestChecks"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reads pull request reviews mapping gh shape", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + reviews: [ + { + id: "PRR_x", + author: { login: "alice" }, + state: "CHANGES_REQUESTED", + body: "please fix", + submittedAt: "2026-06-12T10:00:00Z", + }, + ], + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listPullRequestReviews({ cwd: "/repo", number: 7 }); + + assert.deepStrictEqual(result, [ + { + id: "PRR_x", + author: "alice", + state: "CHANGES_REQUESTED", + body: "please fix", + submittedAt: "2026-06-12T10:00:00Z", + }, + ]); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "view", "7", "--json", "reviews"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reads pull request review comments via gh api", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + id: 555, + user: { login: "bob" }, + body: "nit", + path: "src/x.ts", + created_at: "2026-06-12T11:00:00Z", + }, + { + id: 556, + user: { login: "carol" }, + body: "general", + path: null, + created_at: "2026-06-12T12:00:00Z", + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listPullRequestReviewComments({ + cwd: "/repo", + repo: "octocat/codething-mvp", + number: 7, + }); + + assert.deepStrictEqual(result, [ + { + id: 555, + user: "bob", + body: "nit", + path: "src/x.ts", + createdAt: "2026-06-12T11:00:00Z", + }, + { + id: 556, + user: "carol", + body: "general", + path: null, + createdAt: "2026-06-12T12:00:00Z", + }, + ]); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: ["api", "repos/octocat/codething-mvp/pulls/7/comments"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); }); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index d6c858c28bd..93d49c2abe3 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -44,6 +44,39 @@ export interface GitHubRepositoryCloneUrls { readonly sshUrl: string; } +export type GitHubMergeStrategy = "squash" | "merge" | "rebase"; + +export interface GitHubPullRequestDetail { + readonly state: string; + readonly mergedAt: string | null; + readonly reviewDecision: string | null; + readonly headRefOid: string; + readonly url: string; +} + +export interface GitHubPullRequestCheck { + readonly name: string; + readonly state: string; + readonly bucket: string; + readonly link: string; +} + +export interface GitHubPullRequestReview { + readonly id: string; + readonly author: string; + readonly state: string; + readonly body: string; + readonly submittedAt: string; +} + +export interface GitHubPullRequestReviewComment { + readonly id: number; + readonly user: string; + readonly body: string; + readonly path: string | null; + readonly createdAt: string; +} + export interface GitHubCliShape { readonly execute: (input: { readonly cwd: string; @@ -79,8 +112,36 @@ export interface GitHubCliShape { readonly headSelector: string; readonly title: string; readonly bodyFile: string; + readonly draft?: boolean; }) => Effect.Effect; + readonly mergePullRequest: (input: { + readonly cwd: string; + readonly number: number; + readonly strategy: GitHubMergeStrategy; + }) => Effect.Effect; + + readonly getPullRequestDetail: (input: { + readonly cwd: string; + readonly number: number; + }) => Effect.Effect; + + readonly listPullRequestChecks: (input: { + readonly cwd: string; + readonly number: number; + }) => Effect.Effect, GitHubCliError>; + + readonly listPullRequestReviews: (input: { + readonly cwd: string; + readonly number: number; + }) => Effect.Effect, GitHubCliError>; + + readonly listPullRequestReviewComments: (input: { + readonly cwd: string; + readonly repo: string; + readonly number: number; + }) => Effect.Effect, GitHubCliError>; + readonly getDefaultBranch: (input: { readonly cwd: string; }) => Effect.Effect; @@ -161,6 +222,45 @@ const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ sshUrl: TrimmedNonEmptyString, }); +const RawGitHubPullRequestDetailSchema = Schema.Struct({ + state: Schema.String, + mergedAt: Schema.NullOr(Schema.String), + reviewDecision: Schema.NullOr(Schema.String), + headRefOid: Schema.String, + url: Schema.String, +}); + +const RawGitHubPullRequestCheckSchema = Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + bucket: Schema.optional(Schema.NullOr(Schema.String)), + link: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const RawGitHubPullRequestChecksSchema = Schema.Array(RawGitHubPullRequestCheckSchema); + +const RawGitHubPullRequestReviewsSchema = Schema.Struct({ + reviews: Schema.Array( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.String) }))), + state: Schema.optional(Schema.NullOr(Schema.String)), + body: Schema.optional(Schema.NullOr(Schema.String)), + submittedAt: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), +}); + +const RawGitHubPullRequestReviewCommentsSchema = Schema.Array( + Schema.Struct({ + id: Schema.Number, + user: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.String) }))), + body: Schema.optional(Schema.NullOr(Schema.String)), + path: Schema.optional(Schema.NullOr(Schema.String)), + created_at: Schema.optional(Schema.NullOr(Schema.String)), + }), +); + function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, ): GitHubRepositoryCloneUrls { @@ -211,7 +311,14 @@ function deriveRepositoryCloneUrlsFromCreateOutput( function decodeGitHubJson( raw: string, schema: S, - operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", + operation: + | "listOpenPullRequests" + | "getPullRequest" + | "getRepositoryCloneUrls" + | "getPullRequestDetail" + | "listPullRequestChecks" + | "listPullRequestReviews" + | "listPullRequestReviewComments", invalidDetail: string, ): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( @@ -352,8 +459,147 @@ export const make = Effect.fn("makeGitHubCli")(function* () { input.title, "--body-file", input.bodyFile, + ...(input.draft ? ["--draft"] : []), ], }).pipe(Effect.asVoid), + mergePullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "merge", + String(input.number), + input.strategy === "merge" + ? "--merge" + : input.strategy === "rebase" + ? "--rebase" + : "--squash", + ], + }).pipe(Effect.asVoid), + getPullRequestDetail: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + String(input.number), + "--json", + "state,mergedAt,reviewDecision,headRefOid,url", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitHubJson( + raw, + RawGitHubPullRequestDetailSchema, + "getPullRequestDetail", + "GitHub CLI returned invalid pull request detail JSON.", + ), + ), + Effect.map((raw) => ({ + state: raw.state, + mergedAt: raw.mergedAt, + reviewDecision: raw.reviewDecision, + headRefOid: raw.headRefOid, + url: raw.url, + })), + ), + listPullRequestChecks: (input) => + // `gh pr checks` exits 8 while checks are pending and 1 when some fail, + // yet still prints valid JSON. Tolerate those exit codes (and 0) as long + // as stdout parses; any other exit code is a real failure. + process + .run({ + operation: "GitHubCli.execute", + command: "gh", + args: ["pr", "checks", String(input.number), "--json", "name,state,bucket,link"], + cwd: input.cwd, + timeoutMs: DEFAULT_TIMEOUT_MS, + allowNonZeroExit: true, + }) + .pipe( + Effect.mapError((error) => normalizeGitHubCliError("execute", error)), + Effect.flatMap((result) => { + const exitCode = result.exitCode as number; + if (exitCode !== 0 && exitCode !== 1 && exitCode !== 8) { + return Effect.fail( + new GitHubCliError({ + operation: "listPullRequestChecks", + detail: + result.stderr.trim() || + `gh pr checks exited with code ${exitCode}.`, + }), + ); + } + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed([] as ReadonlyArray); + } + return decodeGitHubJson( + raw, + RawGitHubPullRequestChecksSchema, + "listPullRequestChecks", + "GitHub CLI returned invalid pull request checks JSON.", + ).pipe( + Effect.map((checks) => + checks.map((check) => ({ + name: check.name ?? "", + state: check.state ?? "", + bucket: check.bucket ?? "", + link: check.link ?? "", + })), + ), + ); + }), + ), + listPullRequestReviews: (input) => + execute({ + cwd: input.cwd, + args: ["pr", "view", String(input.number), "--json", "reviews"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitHubJson( + raw, + RawGitHubPullRequestReviewsSchema, + "listPullRequestReviews", + "GitHub CLI returned invalid pull request reviews JSON.", + ), + ), + Effect.map((decoded) => + decoded.reviews.map((review) => ({ + id: review.id ?? "", + author: review.author?.login ?? "", + state: review.state ?? "", + body: review.body ?? "", + submittedAt: review.submittedAt ?? "", + })), + ), + ), + listPullRequestReviewComments: (input) => + execute({ + cwd: input.cwd, + args: ["api", `repos/${input.repo}/pulls/${input.number}/comments`], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitHubJson( + raw, + RawGitHubPullRequestReviewCommentsSchema, + "listPullRequestReviewComments", + "GitHub CLI returned invalid pull request review comments JSON.", + ), + ), + Effect.map((decoded) => + decoded.map((comment) => ({ + id: comment.id, + user: comment.user?.login ?? "", + body: comment.body ?? "", + path: comment.path ?? null, + createdAt: comment.created_at ?? "", + })), + ), + ), getDefaultBranch: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 2ebf8481957..fc47fe0460d 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -219,6 +219,27 @@ 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 sequence?: number | undefined; + }; + 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 +376,163 @@ 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, + sequence: expect.any(Number), + }, + }); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + }), + ); + + it.effect("delivers history-attach output buffered during the snapshot callback once", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + ptyAdapter: new FakePtyAdapter("async"), + }); + const threadId = "script-thread-buffered"; + const terminalId = "script-terminal-buffered"; + + yield* manager.open(openInput({ threadId, terminalId })); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + const attachHistoryStream = (manager as TerminalManagerWithHistory).attachHistoryStream; + expect(typeof attachHistoryStream).toBe("function"); + + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* attachHistoryStream({ threadId, terminalId }, (event) => + Effect.gen(function* () { + yield* Ref.update(attachEvents, (events) => [...events, event]); + if (event.type === "snapshot") { + yield* Effect.sync(() => process.emitData("during snapshot\n")); + yield* Effect.yieldNow; + } + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + yield* waitFor( + Effect.map(Ref.get(attachEvents), (events) => + events.some((event) => event.type === "output" && event.data === "during snapshot\n"), + ), + "1200 millis", + ); + + const events = yield* Ref.get(attachEvents); + const snapshotEvents = events.filter((event) => event.type === "snapshot"); + expect(snapshotEvents).toHaveLength(1); + expect(snapshotEvents[0]?.snapshot?.sequence).toEqual(expect.any(Number)); + expect( + events.filter((event) => event.type === "output" && event.data === "during snapshot\n"), + ).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..118c691ecb4 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,44 @@ 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, + sequence: session.value.eventSequence, + }; + } + + 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 getSnapshot: TerminalManagerShape["getSnapshot"] = (input) => + getSession(input.threadId, input.terminalId).pipe( + Effect.map((session) => (Option.isSome(session) ? snapshot(session.value) : null)), + ); + const subscribe: TerminalManagerShape["subscribe"] = (listener) => Effect.sync(() => { terminalEventListeners.add(listener); @@ -2143,6 +2199,67 @@ 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) { + if ( + typeof event.sequence === "number" && + typeof initialSnapshot.sequence === "number" && + event.sequence <= initialSnapshot.sequence + ) { + continue; + } + + 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,11 +2499,13 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith return { open, attachStream, + attachHistoryStream, write, resize, clear, restart, close, + getSnapshot, subscribe, subscribeMetadata, } satisfies TerminalManagerShape; diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index 51c66f49f7c..95da414b335 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. */ @@ -123,6 +134,15 @@ export interface TerminalManagerShape { */ readonly close: (input: TerminalCloseInput) => Effect.Effect; + /** + * Read the current snapshot for a terminal session without opening or + * modifying it. Returns `null` if no session exists for the given ids. + */ + readonly getSnapshot: (input: { + readonly threadId: string; + readonly terminalId: string; + }) => Effect.Effect; + /** * Subscribe to terminal runtime events with a direct callback. * 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..25c09ee70a0 --- /dev/null +++ b/apps/server/src/workflow/Layers/ApprovalGate.ts @@ -0,0 +1,69 @@ +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); + return yield* incrementWaiter(id).pipe( + Effect.andThen(Deferred.await(deferred)), + 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/AsanaProvider.test.ts b/apps/server/src/workflow/Layers/AsanaProvider.test.ts new file mode 100644 index 00000000000..c3828e30387 --- /dev/null +++ b/apps/server/src/workflow/Layers/AsanaProvider.test.ts @@ -0,0 +1,483 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { AsanaProvider as AsanaProviderTag } from "../Services/WorkSourceProvider.ts"; +import { WorkSourceConnectionStore } from "../Services/WorkSourceConnectionStore.ts"; +import { AsanaProviderLive } from "./AsanaProvider.ts"; + +// --------------------------------------------------------------------------- +// Canned Asana API responses +// --------------------------------------------------------------------------- + +/** Task 1: open/incomplete */ +const taskOpen = { + gid: "task-gid-1", + name: "Fix the bug", + notes: "Detailed description here", + completed: false, + completed_at: null, + assignee: { name: "Alice" }, + tags: [{ name: "urgent" }, { name: "backend" }], + permalink_url: "https://app.asana.com/0/project/task-gid-1", + modified_at: "2024-02-01T10:00:00.000Z", +}; + +/** Task 2: completed */ +const taskCompleted = { + gid: "task-gid-2", + name: "Write the docs", + notes: null, + completed: true, + completed_at: "2024-02-02T12:00:00.000Z", + assignee: null, + tags: [], + permalink_url: "https://app.asana.com/0/project/task-gid-2", + modified_at: "2024-02-02T12:00:00.000Z", +}; + +// --------------------------------------------------------------------------- +// Helper: build a test layer with mocked HttpClient + connection store +// --------------------------------------------------------------------------- + +function makeTestLayer(input: { + readonly responseBody: unknown; + readonly responseStatus?: number; + readonly responseHeaders?: Record; + readonly pat?: string; +}) { + const pat = input.pat ?? "test-asana-pat"; + const status = input.responseStatus ?? 200; + const headers = input.responseHeaders ?? {}; + + const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(input.responseBody), { + status, + headers: { + "content-type": "application/json", + ...headers, + }, + }), + ), + ), + ); + + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => execute(request)), + ); + + const connectionStoreLayer = Layer.succeed(WorkSourceConnectionStore, { + getToken: (_connectionRef, _expectedProvider) => Effect.succeed(pat), + create: (_input) => Effect.die("not needed in test"), + list: () => Effect.die("not needed in test"), + remove: (_connectionRef) => Effect.die("not needed in test"), + }); + + const testLayer = AsanaProviderLive.pipe( + Layer.provide(httpClientLayer), + Layer.provide(connectionStoreLayer), + ); + + return { execute, testLayer }; +} + +// Helper: a canned page response wrapping tasks +function pageResponse( + tasks: unknown[], + nextOffset?: string, +): { data: unknown[]; next_page: unknown } { + return { + data: tasks, + next_page: nextOffset ? { offset: nextOffset, path: "/tasks?offset=" + nextOffset, uri: "https://app.asana.com" } : null, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("AsanaProvider", () => { + describe("listPage", () => { + it.effect("maps incomplete task → open lifecycle, completed → closed lifecycle", () => { + const { testLayer } = makeTestLayer({ + responseBody: pageResponse([taskOpen, taskCompleted]), + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + selector: { projectGid: "proj-123" }, + pageSize: 50, + }); + + expect(page.items).toHaveLength(2); + + // Task 1: open + expect(page.items[0]!.externalId).toBe("task-gid-1"); + expect(page.items[0]!.lifecycle).toBe("open"); + expect(page.items[0]!.provider).toBe("asana"); + + // Task 2: completed → closed + expect(page.items[1]!.externalId).toBe("task-gid-2"); + expect(page.items[1]!.lifecycle).toBe("closed"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("maps fields: name→title, notes→description, assignee.name→assignees, tags→labels, permalink_url→url", () => { + const { testLayer } = makeTestLayer({ + responseBody: pageResponse([taskOpen]), + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + selector: { projectGid: "proj-123" }, + pageSize: 50, + }); + + const item = page.items[0]!; + expect(item.fields.title).toBe("Fix the bug"); + expect(item.fields.description).toBe("Detailed description here"); + expect(item.fields.assignees).toEqual(["Alice"]); + expect(item.fields.labels).toEqual(["urgent", "backend"]); + expect(item.url).toBe("https://app.asana.com/0/project/task-gid-1"); + expect(item.version.updatedAt).toBe("2024-02-01T10:00:00.000Z"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("task with null notes → description undefined", () => { + const { testLayer } = makeTestLayer({ + responseBody: pageResponse([taskCompleted]), + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + selector: { projectGid: "proj-123" }, + pageSize: 50, + }); + + expect(page.items[0]!.fields.description).toBeUndefined(); + // No assignee → assignees undefined + expect(page.items[0]!.fields.assignees).toBeUndefined(); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("pagination: next_page.offset becomes nextPageToken when present", () => { + const { testLayer } = makeTestLayer({ + responseBody: pageResponse([taskOpen], "PAGE_TOKEN_ABC"), + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + selector: { projectGid: "proj-123" }, + pageSize: 10, + }); + expect(page.nextPageToken).toBe("PAGE_TOKEN_ABC"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("pagination: null next_page → nextPageToken undefined", () => { + const { testLayer } = makeTestLayer({ + responseBody: pageResponse([taskOpen]), + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + selector: { projectGid: "proj-123" }, + pageSize: 10, + }); + expect(page.nextPageToken).toBeUndefined(); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("includeCompleted:false adds completed_since=now to the request", () => { + const { execute, testLayer } = makeTestLayer({ + responseBody: pageResponse([taskOpen]), + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + yield* provider.listPage({ + connectionRef: "conn", + selector: { projectGid: "proj-123", includeCompleted: false }, + pageSize: 20, + }); + + const request = execute.mock.calls[0]?.[0]; + expect(request).toBeDefined(); + // urlParams is a UrlParams object with a .params ReadonlyArray + const params: ReadonlyArray = request!.urlParams.params; + const completedSinceParam = params.find(([k]) => k === "completed_since"); + expect(completedSinceParam).toBeDefined(); + expect(completedSinceParam![1]).toBe("now"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("includeCompleted:true (default) does NOT add completed_since", () => { + const { execute, testLayer } = makeTestLayer({ + responseBody: pageResponse([taskOpen]), + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + yield* provider.listPage({ + connectionRef: "conn", + // Omit includeCompleted — defaults to true + selector: { projectGid: "proj-123" }, + pageSize: 20, + }); + + const request = execute.mock.calls[0]?.[0]; + expect(request).toBeDefined(); + const params: ReadonlyArray = request!.urlParams.params; + const completedSinceParam = params.find(([k]) => k === "completed_since"); + expect(completedSinceParam).toBeUndefined(); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("sectionGid/tagGid set → still returns full mapped page (warning is non-fatal)", () => { + const { testLayer } = makeTestLayer({ + responseBody: pageResponse([taskOpen, taskCompleted]), + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + // sectionGid/tagGid set — v1 does NOT filter; warning emitted but behavior unchanged + selector: { projectGid: "proj-123", sectionGid: "sect-1", tagGid: "tag-1" }, + pageSize: 50, + }); + + // Full project page returned, not filtered down + expect(page.items.map((i) => i.externalId)).toEqual(["task-gid-1", "task-gid-2"]); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("429 + Retry-After:2 → WorkSourceRateLimitError{retryAfterMs:2000}", () => { + // it.effect uses an internal test clock pinned at epoch 0 — the + // Asana 429 path reads Retry-After in seconds and multiplies by 1000, + // so Retry-After:2 → retryAfterMs:2000 deterministically. + const { testLayer } = makeTestLayer({ + responseBody: { errors: [{ message: "Rate Limited" }] }, + responseStatus: 429, + responseHeaders: { "retry-after": "2" }, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "conn", + selector: { projectGid: "proj-123" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceRateLimitError"); + expect((failure as { retryAfterMs?: number }).retryAfterMs).toBe(2000); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("429 without Retry-After → WorkSourceRateLimitError with fallback 60_000ms", () => { + const { testLayer } = makeTestLayer({ + responseBody: { errors: [{ message: "Rate Limited" }] }, + responseStatus: 429, + responseHeaders: {}, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "conn", + selector: { projectGid: "proj-123" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceRateLimitError"); + expect((failure as { retryAfterMs?: number }).retryAfterMs).toBe(60_000); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("401 → WorkSourceAuthError", () => { + const { testLayer } = makeTestLayer({ + responseBody: { errors: [{ message: "Not Authorized" }] }, + responseStatus: 401, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "my-conn", + selector: { projectGid: "proj-123" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceAuthError"); + expect((failure as { connectionRef?: string }).connectionRef).toBe("my-conn"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("invalid selector → WorkSourceConfigError", () => { + const { testLayer } = makeTestLayer({ responseBody: pageResponse([]) }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "conn", + // missing required projectGid + selector: { includeCompleted: false }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceConfigError"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("sends Authorization header with PAT", () => { + const { execute, testLayer } = makeTestLayer({ + responseBody: pageResponse([]), + pat: "secret-asana-pat-xyz", + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + yield* provider.listPage({ + connectionRef: "conn", + selector: { projectGid: "proj-999" }, + pageSize: 10, + }); + + const request = execute.mock.calls[0]?.[0]; + expect(request).toBeDefined(); + expect(request!.headers["authorization"]).toBe("Bearer secret-asana-pat-xyz"); + }).pipe(Effect.provide(testLayer)); + }); + }); + + describe("getItem", () => { + it.effect("returns a mapped ExternalWorkItem for an existing task gid", () => { + const { testLayer } = makeTestLayer({ + responseBody: { data: taskOpen }, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const item = yield* provider.getItem({ + connectionRef: "conn", + selector: { projectGid: "p" }, + externalId: "task-gid-1", + }); + + expect(item).not.toBeNull(); + expect(item!.externalId).toBe("task-gid-1"); + expect(item!.lifecycle).toBe("open"); + expect(item!.fields.title).toBe("Fix the bug"); + expect(item!.fields.description).toBe("Detailed description here"); + expect(item!.fields.assignees).toEqual(["Alice"]); + expect(item!.fields.labels).toEqual(["urgent", "backend"]); + expect(item!.url).toBe("https://app.asana.com/0/project/task-gid-1"); + expect(item!.provider).toBe("asana"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("returns null when getItem receives a 404 (task deleted)", () => { + const { testLayer } = makeTestLayer({ + responseBody: { errors: [{ message: "task: Not a recognized ID" }] }, + responseStatus: 404, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const result = yield* provider.getItem({ + connectionRef: "conn", + selector: { projectGid: "p" }, + externalId: "nonexistent-gid", + }); + expect(result).toBeNull(); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("getItem 401 → WorkSourceAuthError", () => { + const { testLayer } = makeTestLayer({ + responseBody: { errors: [{ message: "Not Authorized" }] }, + responseStatus: 401, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const failure = yield* Effect.flip( + provider.getItem({ + connectionRef: "bad-conn", + selector: { projectGid: "p" }, + externalId: "some-gid", + }), + ); + expect(failure._tag).toBe("WorkSourceAuthError"); + expect((failure as { connectionRef?: string }).connectionRef).toBe("bad-conn"); + }).pipe(Effect.provide(testLayer)); + }); + }); + + describe("Fix 6: malformed response body → WorkSourceTransientError (not a defect)", () => { + it.effect("listPage: 200 body missing the data array → WorkSourceTransientError", () => { + const { testLayer } = makeTestLayer({ + responseBody: { not_data: "garbage" }, + responseStatus: 200, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const failure = yield* provider + .listPage({ connectionRef: "conn", selector: { projectGid: "p" }, pageSize: 100 }) + .pipe(Effect.flip); + expect(failure._tag).toBe("WorkSourceTransientError"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("listPage: 200 body where data is not an array → WorkSourceTransientError", () => { + const { testLayer } = makeTestLayer({ + responseBody: { data: "not-an-array" }, + responseStatus: 200, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const failure = yield* provider + .listPage({ connectionRef: "conn", selector: { projectGid: "p" }, pageSize: 100 }) + .pipe(Effect.flip); + expect(failure._tag).toBe("WorkSourceTransientError"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("getItem: 200 body missing the data object → WorkSourceTransientError", () => { + const { testLayer } = makeTestLayer({ + responseBody: { not_data: "garbage" }, + responseStatus: 200, + }); + + return Effect.gen(function* () { + const provider = yield* AsanaProviderTag; + const failure = yield* provider + .getItem({ connectionRef: "conn", selector: { projectGid: "p" }, externalId: "g" }) + .pipe(Effect.flip); + expect(failure._tag).toBe("WorkSourceTransientError"); + }).pipe(Effect.provide(testLayer)); + }); + }); +}); diff --git a/apps/server/src/workflow/Layers/AsanaProvider.ts b/apps/server/src/workflow/Layers/AsanaProvider.ts new file mode 100644 index 00000000000..844c0bf96ef --- /dev/null +++ b/apps/server/src/workflow/Layers/AsanaProvider.ts @@ -0,0 +1,324 @@ +/** + * AsanaProvider — raw-HTTP Asana Tasks work-source provider. + * + * Uses `HttpClient` from `effect/unstable/http` with a PAT fetched from + * `WorkSourceConnectionStore.getToken`. Mirrors the structure of + * `GithubIssuesProvider` closely. + * + * ### externalId strategy + * `externalId = gid` — Asana's globally unique task GID is stable and lets + * `getItem` issue a simple `GET /tasks/:gid` lookup. Unlike GitHub, we have + * the full identifier in the `getItem` signature, so orphan-confirmation is + * properly implemented (not deferred). + * + * ### nextPageToken strategy + * Asana's response wraps results in `{ data: [...], next_page: { offset, path, uri } | null }`. + * `nextPageToken = body.next_page?.offset` (a string token); absent/null → undefined. + * + * ### includeCompleted + * Asana includes completed tasks by default. To EXCLUDE completed tasks, we + * pass `completed_since=now` (an ISO string in the past forces Asana to return + * only tasks modified since that date that are NOT yet completed). Actually, + * the documented approach is: `completed_since=now` makes Asana return only + * incomplete tasks. When `selector.includeCompleted === true` (the default), + * we omit the parameter. When `selector.includeCompleted === false`, we add + * `completed_since=now`. + * + * ### sectionGid / tagGid (v1 limitation) + * The `AsanaSelector` schema accepts `sectionGid` and `tagGid` for future + * filtering. In v1 we always list the whole project via `project=projectGid` + * and do NOT apply section or tag filtering. These fields are reserved for + * future use and are documented here as deferred. To implement: + * - `sectionGid`: use `GET /sections/:gid/tasks` instead of `/tasks?project=` + * - `tagGid`: use `GET /tasks?tag=:gid` (no `project=` in that case) + * Both require restructuring the `listPage` URL; post-fetch filtering is not + * sufficient because Asana does not return `memberships` by default. + * + * ### getItem + * `GET /tasks/:gid?opt_fields=...` — proper orphan-confirmation (unlike GitHub + * v1 which returns null). 404 → null (task deleted on Asana side). + */ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { AsanaSelector } from "@t3tools/contracts/workSource"; + +import { + AsanaProvider as AsanaProviderTag, + WorkSourceAuthError, + WorkSourceConfigError, + WorkSourceRateLimitError, + WorkSourceTransientError, + type ExternalWorkItem, + type WorkSourcePage, + type WorkSourceProvider, +} from "../Services/WorkSourceProvider.ts"; +import { WorkSourceConnectionStore } from "../Services/WorkSourceConnectionStore.ts"; + +const ASANA_API_BASE = "https://app.asana.com/api/1.0"; + +const ASANA_TASK_OPT_FIELDS = + "name,notes,completed,completed_at,assignee.name,tags.name,permalink_url,modified_at,gid"; + +// --------------------------------------------------------------------------- +// Rate-limit helper +// --------------------------------------------------------------------------- + +function parseAsanaRateLimitRetryMs(headers: Record): number { + // Asana always sends Retry-After on 429 (seconds) + const retryAfter = headers["retry-after"]; + if (retryAfter) { + const seconds = Number(retryAfter); + if (!Number.isNaN(seconds) && seconds > 0) return seconds * 1000; + } + return 60_000; // fallback: 1 minute +} + +// --------------------------------------------------------------------------- +// Raw Asana JSON shapes (loose — only fields we use) +// --------------------------------------------------------------------------- + +interface RawAsanaAssignee { + readonly name: string; +} + +interface RawAsanaTag { + readonly name: string; +} + +interface RawAsanaTask { + readonly gid: string; + readonly name: string; + readonly notes: string | null; + readonly completed: boolean; + readonly completed_at: string | null; + readonly assignee: RawAsanaAssignee | null; + readonly tags: ReadonlyArray | null; + readonly permalink_url: string; + readonly modified_at: string; +} + +interface RawAsanaPage { + readonly data: ReadonlyArray; + readonly next_page: { readonly offset: string; readonly path: string; readonly uri: string } | null; +} + +function mapTask(raw: RawAsanaTask): ExternalWorkItem { + const assignees = raw.assignee ? [raw.assignee.name] : undefined; + const labels = raw.tags && raw.tags.length > 0 ? raw.tags.map((t) => t.name) : undefined; + return { + provider: "asana", + externalId: raw.gid, + url: raw.permalink_url, + lifecycle: raw.completed ? "closed" : "open", + version: { updatedAt: raw.modified_at }, + fields: { + title: raw.name, + // exactOptionalPropertyTypes: only spread when value is defined/truthy + ...(raw.notes != null && raw.notes !== "" && { description: raw.notes }), + ...(assignees !== undefined && { assignees }), + ...(labels !== undefined && { labels }), + }, + }; +} + +// --------------------------------------------------------------------------- +// Provider implementation +// --------------------------------------------------------------------------- + +const make = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const connectionStore = yield* WorkSourceConnectionStore; + + function buildHeaders(pat: string): Record { + return { + authorization: `Bearer ${pat}`, + accept: "application/json", + }; + } + + const provider: WorkSourceProvider = { + provider: "asana", + selectorSchema: AsanaSelector, + + listPage: (input) => + Effect.gen(function* () { + // Decode selector + const selector = yield* Schema.decodeUnknownEffect(AsanaSelector)(input.selector).pipe( + Effect.mapError( + (e) => new WorkSourceConfigError({ message: `Invalid Asana selector: ${e.message}` }), + ), + ); + + // v1 ops signal: section/tag filtering is not applied (we list the + // whole project). Warn so an operator notices if a user scoped a source + // to a section/tag expecting it to limit the synced tickets. + if (selector.sectionGid || selector.tagGid) { + yield* Effect.logWarning( + "asana source: sectionGid/tagGid filtering is not applied in v1; syncing the entire project", + { projectGid: selector.projectGid }, + ); + } + + const pat = yield* connectionStore.getToken(input.connectionRef, "asana"); + + const { projectGid, includeCompleted } = selector; + + // Build URL params + const urlParams: Array = [ + ["project", projectGid], + ["opt_fields", ASANA_TASK_OPT_FIELDS], + ["limit", String(input.pageSize)], + ]; + if (input.since) urlParams.push(["modified_since", input.since]); + if (input.pageToken) urlParams.push(["offset", input.pageToken]); + // When includeCompleted is false, pass completed_since=now to get only + // incomplete tasks. When true (the default), omit the param. + if (includeCompleted === false) { + urlParams.push(["completed_since", "now"]); + } + // v1: sectionGid and tagGid are not yet applied — see file header. + + const request = HttpClientRequest.get(`${ASANA_API_BASE}/tasks`, { urlParams }).pipe( + HttpClientRequest.setHeaders(buildHeaders(pat)), + ); + + const response = yield* client.execute(request).pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ + message: `Asana HTTP network error: ${String(cause)}`, + }), + ), + ); + + const { status, headers } = response; + + if (status === 401) { + return yield* new WorkSourceAuthError({ connectionRef: input.connectionRef }); + } + if (status === 429) { + return yield* new WorkSourceRateLimitError({ + retryAfterMs: parseAsanaRateLimitRetryMs(headers), + }); + } + if (status < 200 || status >= 300) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* new WorkSourceTransientError({ + message: `Asana API returned HTTP ${status}: ${body.trim() || "(no body)"}`, + }); + } + + const rawBody = (yield* response.json.pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ + message: `Failed to parse Asana JSON response: ${String(cause)}`, + }), + ), + )) as unknown; + + // Guard the shape before iterating: a malformed/unexpected success body + // (missing or non-array `data`) → transient failure (source backs off) + // rather than a thrown defect that only the syncer's log-only catch sees. + if ( + rawBody === null || + typeof rawBody !== "object" || + !Array.isArray((rawBody as { readonly data?: unknown }).data) + ) { + return yield* new WorkSourceTransientError({ + message: "Asana /tasks response did not contain a data array", + }); + } + + const page0 = rawBody as RawAsanaPage; + const items: Array = []; + for (const raw of page0.data) { + items.push(mapTask(raw)); + } + + const nextPageToken = page0.next_page?.offset ?? undefined; + + const page: WorkSourcePage = { + items, + ...(nextPageToken !== undefined && { nextPageToken }), + }; + return page; + }), + + getItem: (input) => + Effect.gen(function* () { + const pat = yield* connectionStore.getToken(input.connectionRef, "asana"); + + const urlParams: Array = [ + ["opt_fields", ASANA_TASK_OPT_FIELDS], + ]; + + const request = HttpClientRequest.get( + `${ASANA_API_BASE}/tasks/${encodeURIComponent(input.externalId)}`, + { urlParams }, + ).pipe(HttpClientRequest.setHeaders(buildHeaders(pat))); + + const response = yield* client.execute(request).pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ + message: `Asana HTTP network error (getItem): ${String(cause)}`, + }), + ), + ); + + const { status } = response; + + if (status === 404) { + return null; + } + if (status === 401) { + return yield* new WorkSourceAuthError({ connectionRef: input.connectionRef }); + } + if (status === 429) { + return yield* new WorkSourceRateLimitError({ + retryAfterMs: parseAsanaRateLimitRetryMs(response.headers), + }); + } + if (status < 200 || status >= 300) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* new WorkSourceTransientError({ + message: `Asana API returned HTTP ${status} (getItem): ${body.trim() || "(no body)"}`, + }); + } + + const rawBody = (yield* response.json.pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ + message: `Failed to parse Asana getItem JSON response: ${String(cause)}`, + }), + ), + )) as unknown; + + // Guard the shape: the single-task endpoint returns `{ data: {...} }`. + if ( + rawBody === null || + typeof rawBody !== "object" || + typeof (rawBody as { readonly data?: unknown }).data !== "object" || + (rawBody as { readonly data?: unknown }).data === null + ) { + return yield* new WorkSourceTransientError({ + message: "Asana /tasks/:gid response did not contain a data object", + }); + } + + return mapTask((rawBody as { readonly data: RawAsanaTask }).data); + }), + }; + + return provider; +}); + +export const AsanaProviderLive: Layer.Layer< + AsanaProviderTag, + never, + HttpClient.HttpClient | WorkSourceConnectionStore +> = Layer.effect(AsanaProviderTag, make); 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..f0ba3283159 --- /dev/null +++ b/apps/server/src/workflow/Layers/BoardDiscovery.test.ts @@ -0,0 +1,551 @@ +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"), + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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(workflowEngineStub), + Layer.provideMerge(WorkflowEventStoreLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + 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(workflowEngineStub), + Layer.provideMerge(WorkflowEventStoreLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + 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(workflowEngineStub), + Layer.provideMerge(WorkflowEventStoreLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + 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..39a7ae77679 --- /dev/null +++ b/apps/server/src/workflow/Layers/BoardRegistry.ts @@ -0,0 +1,80 @@ +import { WorkflowDefinition } from "@t3tools/contracts"; +import { AsanaSelector, GithubSelector } from "@t3tools/contracts/workSource"; +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, + selectorSchemaFor: (p) => + p === "github" ? GithubSelector : p === "asana" ? AsanaSelector : null, + }); + 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..da5c86f2483 --- /dev/null +++ b/apps/server/src/workflow/Layers/DurableApprovalResume.test.ts @@ -0,0 +1,418 @@ +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, + appendManyUnlocked: () => Effect.succeed([]), + publishTicketView: () => 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([]), + listNeedsAttentionTickets: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + getTicketPrState: () => Effect.succeed(null), + }), + ), + 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, + appendManyUnlocked: () => Effect.succeed([]), + publishTicketView: () => 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([]), + listNeedsAttentionTickets: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + getTicketPrState: () => Effect.succeed(null), + }), + ), + 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/GitHubPort.test.ts b/apps/server/src/workflow/Layers/GitHubPort.test.ts new file mode 100644 index 00000000000..83c5df77ff7 --- /dev/null +++ b/apps/server/src/workflow/Layers/GitHubPort.test.ts @@ -0,0 +1,794 @@ +import { assert, afterEach, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + GitHubCli, + GitHubCliError, + type GitHubCliShape, + type GitHubPullRequestCheck, + type GitHubPullRequestDetail, + type GitHubPullRequestReview, + type GitHubPullRequestReviewComment, + type GitHubPullRequestSummary, +} from "../../sourceControl/GitHubCli.ts"; +import { + SourceControlProviderRegistry, + type SourceControlProviderHandle, +} from "../../sourceControl/SourceControlProviderRegistry.ts"; +import { MergeGitPort, type MergeGitResult } from "../Services/TicketMergeService.ts"; +import { GitHubPort } from "../Services/GitHubPort.ts"; +import { GitHubPortLive } from "./GitHubPort.ts"; + +// --------------------------------------------------------------------------- +// Stubs +// --------------------------------------------------------------------------- + +const ghError = (detail: string): GitHubCliError => + new GitHubCliError({ operation: "execute", detail }); + +const unimplemented = (name: string) => () => + Effect.fail(new GitHubCliError({ operation: "execute", detail: `unexpected ${name}` })); + +const githubHandle = (remoteUrl: string, remoteName = "origin"): SourceControlProviderHandle => ({ + provider: {} as SourceControlProviderHandle["provider"], + context: { + provider: { kind: "github", name: "GitHub", baseUrl: "https://github.com" }, + remoteName, + remoteUrl, + }, +}); + +const registryLayer = (handle: SourceControlProviderHandle | null) => + Layer.succeed(SourceControlProviderRegistry, { + get: unimplemented("get") as never, + resolve: unimplemented("resolve") as never, + discover: Effect.succeed([]), + resolveHandle: () => + handle === null + ? Effect.succeed({ + provider: {} as SourceControlProviderHandle["provider"], + context: null, + }) + : Effect.succeed(handle), + }); + +interface GhStubs { + readonly execute?: GitHubCliShape["execute"]; + readonly getDefaultBranch?: GitHubCliShape["getDefaultBranch"]; + readonly listOpenPullRequests?: GitHubCliShape["listOpenPullRequests"]; + readonly createPullRequest?: GitHubCliShape["createPullRequest"]; + readonly mergePullRequest?: GitHubCliShape["mergePullRequest"]; + readonly getPullRequestDetail?: GitHubCliShape["getPullRequestDetail"]; + readonly listPullRequestChecks?: GitHubCliShape["listPullRequestChecks"]; + readonly listPullRequestReviews?: GitHubCliShape["listPullRequestReviews"]; + readonly listPullRequestReviewComments?: GitHubCliShape["listPullRequestReviewComments"]; + readonly getRepositoryCloneUrls?: GitHubCliShape["getRepositoryCloneUrls"]; +} + +const ghLayer = (stubs: GhStubs) => + Layer.succeed(GitHubCli, { + execute: stubs.execute ?? (unimplemented("execute") as never), + listOpenPullRequests: stubs.listOpenPullRequests ?? (unimplemented("listOpenPullRequests") as never), + getPullRequest: unimplemented("getPullRequest") as never, + getRepositoryCloneUrls: + stubs.getRepositoryCloneUrls ?? (unimplemented("getRepositoryCloneUrls") as never), + createRepository: unimplemented("createRepository") as never, + createPullRequest: stubs.createPullRequest ?? (unimplemented("createPullRequest") as never), + getDefaultBranch: stubs.getDefaultBranch ?? (unimplemented("getDefaultBranch") as never), + checkoutPullRequest: unimplemented("checkoutPullRequest") as never, + mergePullRequest: stubs.mergePullRequest ?? (unimplemented("mergePullRequest") as never), + getPullRequestDetail: + stubs.getPullRequestDetail ?? (unimplemented("getPullRequestDetail") as never), + listPullRequestChecks: + stubs.listPullRequestChecks ?? (unimplemented("listPullRequestChecks") as never), + listPullRequestReviews: + stubs.listPullRequestReviews ?? (unimplemented("listPullRequestReviews") as never), + listPullRequestReviewComments: + stubs.listPullRequestReviewComments ?? + (unimplemented("listPullRequestReviewComments") as never), + }); + +interface RecordedGitCall { + readonly cwd: string; + readonly args: ReadonlyArray; +} + +const gitLayer = ( + script: (input: { cwd: string; args: ReadonlyArray }) => MergeGitResult, + calls: RecordedGitCall[], +) => + Layer.succeed(MergeGitPort, { + run: (input) => { + calls.push({ cwd: input.cwd, args: input.args }); + return Effect.succeed(script({ cwd: input.cwd, args: input.args })); + }, + }); + +const gitResult = (overrides: Partial = {}): MergeGitResult => ({ + exitCode: 0, + stdout: "", + stderr: "", + ...overrides, +}); + +const tempFiles: Array<{ path: string; content: string }> = []; + +const fsLayer = Layer.mock(FileSystem.FileSystem)({ + makeTempFileScoped: () => Effect.succeed("/tmp/t3-pr-body-stub"), + writeFileString: (path: string, content: string) => + Effect.sync(() => { + tempFiles.push({ path, content }); + }), +} as never); + +const detail = (overrides: Partial = {}): GitHubPullRequestDetail => ({ + state: "OPEN", + mergedAt: null, + reviewDecision: null, + headRefOid: "sha-abc", + url: "https://github.com/o/r/pull/7", + ...overrides, +}); + +const check = (overrides: Partial = {}): GitHubPullRequestCheck => ({ + name: "build", + state: "SUCCESS", + bucket: "pass", + link: "", + ...overrides, +}); + +const review = (overrides: Partial = {}): GitHubPullRequestReview => ({ + id: "PRR_1", + author: "alice", + state: "COMMENTED", + body: "looks ok", + submittedAt: "2026-06-12T10:00:00Z", + ...overrides, +}); + +const comment = ( + overrides: Partial = {}, +): GitHubPullRequestReviewComment => ({ + id: 1, + user: "bob", + body: "nit", + path: "src/x.ts", + createdAt: "2026-06-12T09:00:00Z", + ...overrides, +}); + +const prSummary = ( + overrides: Partial = {}, +): GitHubPullRequestSummary => ({ + number: 7, + title: "My PR", + url: "https://github.com/o/r/pull/7", + baseRefName: "main", + headRefName: "feature/x", + ...overrides, +}); + +afterEach(() => { + tempFiles.length = 0; +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("GitHubPortLive", () => { + describe("resolveRemote", () => { + it.effect("derives repo from the remote url", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.resolveRemote("/repo"); + assert.deepStrictEqual(result, { remoteName: "origin", repo: "octocat/repo" }); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide(ghLayer({})), + Layer.provide(registryLayer(githubHandle("https://github.com/octocat/repo.git"))), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + + it.effect("falls back to gh repo view for unparseable urls", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.resolveRemote("/repo"); + assert.deepStrictEqual(result, { remoteName: "origin", repo: "octocat/ghe-repo" }); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + getRepositoryCloneUrls: () => + Effect.succeed({ + nameWithOwner: "octocat/ghe-repo", + url: "https://ghe.corp/octocat/ghe-repo", + sshUrl: "git@ghe.corp:octocat/ghe-repo.git", + }), + }), + ), + Layer.provide(registryLayer(githubHandle("https://ghe.corp/octocat/ghe-repo.git"))), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + }); + + describe("preflight", () => { + it.effect("returns ok when gh auth status succeeds", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.preflight("/repo"); + assert.deepStrictEqual(result, { ok: true }); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + execute: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "ok", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + + it.effect("returns not-ok on auth failure", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.preflight("/repo"); + assert.equal(result.ok, false); + if (result.ok === false) { + assert.equal(result.reason.includes("not authenticated"), true); + } + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + execute: () => + Effect.fail( + ghError("GitHub CLI is not authenticated. Run `gh auth login` and retry."), + ), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + + it.effect("propagates unexpected gh failures to the error channel", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const error = yield* port.preflight("/repo").pipe(Effect.flip); + assert.equal(error.message.includes("preflight"), true); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + execute: () => Effect.fail(ghError("network unreachable")), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + }); + + describe("openPr", () => { + const openInput = { + cwd: "/repo", + branch: "feature/x", + base: "main", + title: "My PR", + body: "the body", + draft: false, + }; + + it.effect("adopts an existing PR without creating one", () => { + const calls: RecordedGitCall[] = []; + return Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.openPr(openInput); + assert.deepStrictEqual(result, { + number: 7, + url: "https://github.com/o/r/pull/7", + adopted: true, + }); + // push happened, no create attempted (createPullRequest stub fails) + assert.equal(calls.length, 1); + assert.deepStrictEqual(calls[0]!.args, [ + "push", + "-u", + "origin", + "HEAD:refs/heads/feature/x", + ]); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + listOpenPullRequests: () => Effect.succeed([prSummary()]), + createPullRequest: () => + Effect.fail(ghError("createPullRequest should not be called")), + }), + ), + Layer.provide(registryLayer(githubHandle("https://github.com/octocat/repo.git"))), + Layer.provide(gitLayer(() => gitResult(), calls)), + Layer.provide(fsLayer), + ), + ), + ); + }); + + it.effect("creates a draft PR via a temp body file when none exists", () => { + const createCalls: Array<{ bodyFile: string; draft: boolean | undefined }> = []; + let listCount = 0; + return Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.openPr({ ...openInput, draft: true }); + assert.deepStrictEqual(result, { + number: 7, + url: "https://github.com/o/r/pull/7", + adopted: false, + }); + assert.equal(createCalls.length, 1); + assert.equal(createCalls[0]!.draft, true); + assert.equal(createCalls[0]!.bodyFile, "/tmp/t3-pr-body-stub"); + assert.deepStrictEqual(tempFiles, [ + { path: "/tmp/t3-pr-body-stub", content: "the body" }, + ]); + assert.equal(listCount, 2); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + listOpenPullRequests: () => + Effect.sync(() => { + listCount += 1; + return listCount === 1 ? [] : [prSummary()]; + }), + createPullRequest: (input) => + Effect.sync(() => { + createCalls.push({ bodyFile: input.bodyFile, draft: input.draft }); + }), + }), + ), + Layer.provide(registryLayer(githubHandle("https://github.com/octocat/repo.git"))), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ); + }); + + it.effect("maps a rejected push to branch diverged", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const error = yield* port.openPr(openInput).pipe(Effect.flip); + assert.equal(error.message.startsWith("branch diverged"), true); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide(ghLayer({})), + Layer.provide(registryLayer(githubHandle("https://github.com/octocat/repo.git"))), + Layer.provide( + gitLayer( + () => + gitResult({ + exitCode: 1, + stderr: "! [rejected] feature/x -> feature/x (non-fast-forward)", + }), + [], + ), + ), + Layer.provide(fsLayer), + ), + ), + ), + ); + }); + + describe("findPrForBranch", () => { + it.effect("returns the first open PR for the head selector", () => { + const selectors: Array = []; + return Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.findPrForBranch({ cwd: "/repo", branch: "workflow/ticket-x" }); + assert.deepStrictEqual(result, { number: 7, url: "https://github.com/o/r/pull/7" }); + assert.deepStrictEqual(selectors, ["workflow/ticket-x"]); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + listOpenPullRequests: (input) => + Effect.sync(() => { + selectors.push(input.headSelector); + return [prSummary({ number: 7 }), prSummary({ number: 9 })]; + }), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ); + }); + + it.effect("returns null when no open PR matches the branch", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.findPrForBranch({ cwd: "/repo", branch: "workflow/ticket-x" }); + assert.equal(result, null); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide(ghLayer({ listOpenPullRequests: () => Effect.succeed([]) })), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + }); + + describe("prDetail ciState mapping", () => { + const runDetail = (input: { + detail?: Partial; + checks: ReadonlyArray; + }) => + Effect.gen(function* () { + const port = yield* GitHubPort; + return yield* port.prDetail({ cwd: "/repo", prNumber: 7 }); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + getPullRequestDetail: () => Effect.succeed(detail(input.detail)), + listPullRequestChecks: () => Effect.succeed(input.checks), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ); + + it.effect("empty checks → success", () => + Effect.gen(function* () { + const result = yield* runDetail({ checks: [] }); + assert.equal(result.ciState, "success"); + }), + ); + + it.effect("any fail bucket → failure", () => + Effect.gen(function* () { + const result = yield* runDetail({ + checks: [check({ bucket: "pass" }), check({ name: "t", bucket: "fail" })], + }); + assert.equal(result.ciState, "failure"); + }), + ); + + it.effect("cancel bucket → failure", () => + Effect.gen(function* () { + const result = yield* runDetail({ checks: [check({ bucket: "cancel" })] }); + assert.equal(result.ciState, "failure"); + }), + ); + + it.effect("pending bucket (no failures) → pending", () => + Effect.gen(function* () { + const result = yield* runDetail({ + checks: [check({ bucket: "pass" }), check({ name: "t", bucket: "pending" })], + }); + assert.equal(result.ciState, "pending"); + }), + ); + + it.effect("all pass/skipping → success", () => + Effect.gen(function* () { + const result = yield* runDetail({ + checks: [check({ bucket: "pass" }), check({ name: "t", bucket: "skipping" })], + }); + assert.equal(result.ciState, "success"); + }), + ); + + it.effect("maps state and reviewDecision", () => + Effect.gen(function* () { + const merged = yield* runDetail({ + detail: { state: "OPEN", mergedAt: "2026-06-12T10:00:00Z" }, + checks: [], + }); + assert.equal(merged.state, "merged"); + + const approved = yield* runDetail({ + detail: { reviewDecision: "APPROVED" }, + checks: [], + }); + assert.equal(approved.reviewDecision, "approved"); + + const changes = yield* runDetail({ + detail: { reviewDecision: "CHANGES_REQUESTED" }, + checks: [], + }); + assert.equal(changes.reviewDecision, "changes_requested"); + + const closed = yield* runDetail({ detail: { state: "CLOSED" }, checks: [] }); + assert.equal(closed.state, "closed"); + assert.equal(closed.reviewDecision, "none"); + }), + ); + }); + + describe("mergePr", () => { + const mergeInput = { + cwd: "/repo", + prNumber: 7, + strategy: "squash" as const, + deleteBranch: false, + branch: "feature/x", + remoteName: "origin", + }; + + it.effect("returns ok:false when gh reports not mergeable", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.mergePr(mergeInput); + assert.equal(result.ok, false); + if (result.ok === false) { + assert.equal(result.reason.toLowerCase().includes("branch protection"), true); + } + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + mergePullRequest: () => + Effect.fail(ghError("Pull request is not mergeable: branch protection rules.")), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + + it.effect("deletes the remote branch best-effort on success", () => { + const calls: RecordedGitCall[] = []; + return Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.mergePr({ ...mergeInput, deleteBranch: true }); + assert.deepStrictEqual(result, { ok: true }); + assert.equal(calls.length, 1); + assert.deepStrictEqual(calls[0]!.args, ["push", "origin", "--delete", "feature/x"]); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + mergePullRequest: () => Effect.void, + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), calls)), + Layer.provide(fsLayer), + ), + ), + ); + }); + + it.effect("propagates unexpected merge failures to the error channel", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const error = yield* port.mergePr(mergeInput).pipe(Effect.flip); + assert.equal(error.message.includes("merge"), true); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + mergePullRequest: () => Effect.fail(ghError("boom unexpected internal error")), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + + it.effect("treats a transient error mentioning pending as infra, not not-mergeable", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const error = yield* port.mergePr(mergeInput).pipe(Effect.flip); + assert.equal(error.message.includes("merge"), true); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + mergePullRequest: () => + Effect.fail(ghError("network error: request pending, timed out")), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + }); + + describe("failingCheckLogs", () => { + it.effect("returns null when no checks fail", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.failingCheckLogs({ cwd: "/repo", prNumber: 7 }); + assert.equal(result, null); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ listPullRequestChecks: () => Effect.succeed([check({ bucket: "pass" })]) }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + + it.effect("parses a run id from the failing check link and fetches log tail", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.failingCheckLogs({ cwd: "/repo", prNumber: 7 }); + assert.equal(result !== null && result.length === 10_000, true); + assert.equal(result?.endsWith("END"), true); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + listPullRequestChecks: () => + Effect.succeed([ + check({ + name: "test", + bucket: "fail", + link: "https://github.com/o/r/actions/runs/9988/job/1", + }), + ]), + execute: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: `${"x".repeat(10_050)}END`, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + + it.effect("falls back to check names when no run id is parseable", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.failingCheckLogs({ cwd: "/repo", prNumber: 7 }); + assert.equal(result, "lint, typecheck"); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + listPullRequestChecks: () => + Effect.succeed([ + check({ name: "lint", bucket: "fail", link: "https://example.com/no-run" }), + check({ name: "typecheck", bucket: "fail", link: "" }), + ]), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + }); + + describe("listReviewFeedback", () => { + it.effect("merges reviews and comments, skips empties, sorts ascending", () => + Effect.gen(function* () { + const port = yield* GitHubPort; + const result = yield* port.listReviewFeedback({ + cwd: "/repo", + prNumber: 7, + repo: "o/r", + }); + assert.deepStrictEqual(result, [ + { + id: "comment:1", + author: "bob", + body: "nit", + submittedAt: "2026-06-12T09:00:00Z", + }, + { + id: "PRR_1", + author: "alice", + body: "looks ok", + submittedAt: "2026-06-12T10:00:00Z", + }, + ]); + }).pipe( + Effect.provide( + GitHubPortLive.pipe( + Layer.provide( + ghLayer({ + listPullRequestReviews: () => + Effect.succeed([ + review(), + review({ id: "PRR_2", body: " ", submittedAt: "2026-06-12T11:00:00Z" }), + ]), + listPullRequestReviewComments: () => + Effect.succeed([ + comment(), + comment({ id: 2, body: "", createdAt: "2026-06-12T08:00:00Z" }), + ]), + }), + ), + Layer.provide(registryLayer(null)), + Layer.provide(gitLayer(() => gitResult(), [])), + Layer.provide(fsLayer), + ), + ), + ), + ); + }); +}); diff --git a/apps/server/src/workflow/Layers/GitHubPort.ts b/apps/server/src/workflow/Layers/GitHubPort.ts new file mode 100644 index 00000000000..ca6fba30b3f --- /dev/null +++ b/apps/server/src/workflow/Layers/GitHubPort.ts @@ -0,0 +1,374 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import { parseGitHubRepositoryNameWithOwnerFromRemoteUrl } from "@t3tools/shared/git"; + +import { + GitHubCli, + GitHubCliError, + type GitHubPullRequestCheck, +} from "../../sourceControl/GitHubCli.ts"; +import { SourceControlProviderRegistry } from "../../sourceControl/SourceControlProviderRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + GitHubPort, + type GitHubPortShape, + type GitHubPrDetail, + type GitHubReviewItem, +} from "../Services/GitHubPort.ts"; +import { MergeGitPort } from "../Services/TicketMergeService.ts"; + +const FAILING_CHECK_LOG_CAP = 10_000; + +const firstLine = (text: string): string => text.trim().split("\n")[0] ?? ""; + +const eventStoreError = (message: string, cause?: unknown): WorkflowEventStoreError => + new WorkflowEventStoreError(cause === undefined ? { message } : { message, cause }); + +/** + * `gh` reports a missing binary or a logged-out account through the + * GitHubCli error-normalization layer. Those two conditions are expected + * infrastructure states the caller handles (step blocked), not bugs — so + * `preflight` returns `{ ok: false }` for them. Anything else is a real fault. + */ +const isExpectedAuthFailure = (error: GitHubCliError): boolean => { + const detail = error.detail.toLowerCase(); + return ( + detail.includes("not authenticated") || + detail.includes("not available on path") || + detail.includes("gh auth login") + ); +}; + +const NOT_MERGEABLE_PATTERNS = [ + "not mergeable", + "branch protection", + "required status", + "checks", + "conflict", + "review required", + "changes requested", + "review is required", + "at least", + "base branch", + "protected branch", +]; + +const looksNotMergeable = (text: string): boolean => { + const lower = text.toLowerCase(); + return NOT_MERGEABLE_PATTERNS.some((pattern) => lower.includes(pattern)); +}; + +const normalizeReviewDecision = ( + value: string | null, +): GitHubPrDetail["reviewDecision"] => { + const normalized = value?.trim().toUpperCase(); + if (normalized === "CHANGES_REQUESTED") return "changes_requested"; + if (normalized === "APPROVED") return "approved"; + return "none"; +}; + +const normalizeState = (input: { + state: string; + mergedAt: string | null; +}): GitHubPrDetail["state"] => { + const normalized = input.state.trim().toUpperCase(); + if (normalized === "MERGED" || (input.mergedAt !== null && input.mergedAt.trim().length > 0)) { + return "merged"; + } + if (normalized === "CLOSED") return "closed"; + return "open"; +}; + +/** + * Reduce gh's per-check buckets to a single CI signal: + * - any failed/cancelled check → "failure" + * - any still-pending check → "pending" + * - otherwise (all pass/skip) → "success" + * + * An EMPTY checks list maps to "success": a repository with no CI configured + * has nothing to wait on, so boards gating on `ci.passed` get an immediate + * pass rather than stalling forever on a check that never fires. + */ +const ciStateFromChecks = ( + checks: ReadonlyArray, +): GitHubPrDetail["ciState"] => { + if (checks.length === 0) return "success"; + let pending = false; + for (const check of checks) { + const bucket = check.bucket.trim().toLowerCase(); + if (bucket === "fail" || bucket === "cancel") return "failure"; + if (bucket === "pending") pending = true; + } + return pending ? "pending" : "success"; +}; + +const make = Effect.gen(function* () { + const gh = yield* GitHubCli; + const git = yield* MergeGitPort; + const registry = yield* SourceControlProviderRegistry; + const fileSystem = yield* FileSystem.FileSystem; + + const mapGhError = + (message: string) => + (error: GitHubCliError): WorkflowEventStoreError => + eventStoreError(`${message}: ${error.detail}`, error); + + const resolveRemote: GitHubPortShape["resolveRemote"] = (cwd) => + registry.resolveHandle({ cwd }).pipe( + Effect.mapError((error) => eventStoreError("failed to resolve source control remote", error)), + Effect.flatMap((handle) => { + const context = handle.context; + if (context === null) { + return Effect.fail(eventStoreError(`no source control remote detected for ${cwd}`)); + } + const parsed = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(context.remoteUrl); + if (parsed !== null) { + return Effect.succeed({ remoteName: context.remoteName, repo: parsed }); + } + // Self-hosted / non-canonical URLs the parser cannot read: ask gh for + // the canonical nameWithOwner of the configured remote. + return gh + .getRepositoryCloneUrls({ cwd, repository: context.remoteName }) + .pipe( + Effect.map((urls) => ({ remoteName: context.remoteName, repo: urls.nameWithOwner })), + Effect.mapError(mapGhError("failed to resolve repository name")), + ); + }), + ); + + const preflight: GitHubPortShape["preflight"] = (cwd) => + gh.execute({ cwd, args: ["auth", "status"] }).pipe( + Effect.as({ ok: true } as { ok: true } | { ok: false; reason: string }), + Effect.catchTag("GitHubCliError", (error) => + isExpectedAuthFailure(error) + ? Effect.succeed({ ok: false, reason: error.detail } as + | { ok: true } + | { ok: false; reason: string }) + : Effect.fail(eventStoreError("github preflight failed", error)), + ), + ); + + const defaultBranch: GitHubPortShape["defaultBranch"] = (cwd) => + gh.getDefaultBranch({ cwd }).pipe( + Effect.mapError(mapGhError("failed to resolve default branch")), + Effect.flatMap((branch) => + branch === null + ? Effect.fail(eventStoreError("github returned no default branch")) + : Effect.succeed(branch), + ), + ); + + const findPr = (input: { cwd: string; branch: string }) => + gh + .listOpenPullRequests({ cwd: input.cwd, headSelector: input.branch }) + .pipe(Effect.mapError(mapGhError("failed to list open pull requests"))); + + const openPr: GitHubPortShape["openPr"] = (input) => + Effect.gen(function* () { + // Push the worktree branch to the resolved remote. A rejected push means + // the remote moved ahead of us — surface it as "branch diverged" so the + // open action can map it to a blocked outcome. + const remote = yield* resolveRemote(input.cwd); + const push = yield* git + .run({ + cwd: input.cwd, + args: ["push", "-u", remote.remoteName, `HEAD:refs/heads/${input.branch}`], + allowNonZeroExit: true, + }) + .pipe( + Effect.mapError((error) => eventStoreError("failed to push branch", error)), + ); + if (push.exitCode !== 0) { + const combined = `${push.stderr}\n${push.stdout}`.toLowerCase(); + if ( + combined.includes("non-fast-forward") || + combined.includes("fetch first") || + (combined.includes("[rejected]") && !combined.includes("[remote rejected]")) + ) { + return yield* eventStoreError( + `branch diverged: ${firstLine(push.stderr) || firstLine(push.stdout) || "remote push rejected"}`, + ); + } + return yield* eventStoreError( + `failed to push branch: ${firstLine(push.stderr) || firstLine(push.stdout) || "push exited non-zero"}`, + ); + } + + // Idempotency: adopt an existing PR for this branch rather than creating + // a duplicate (recovery / retry safe). + const existing = yield* findPr({ cwd: input.cwd, branch: input.branch }); + const adoptedPr = existing[0]; + if (adoptedPr !== undefined) { + return { number: adoptedPr.number, url: adoptedPr.url, adopted: true }; + } + + yield* Effect.scoped( + Effect.gen(function* () { + const bodyFile = yield* fileSystem + .makeTempFileScoped({ prefix: "t3-pr-body-" }) + .pipe( + Effect.tap((path) => fileSystem.writeFileString(path, input.body)), + Effect.mapError((cause) => eventStoreError("failed to write PR body file", cause)), + ); + yield* gh + .createPullRequest({ + cwd: input.cwd, + baseBranch: input.base, + headSelector: input.branch, + title: input.title, + bodyFile, + draft: input.draft, + }) + .pipe(Effect.mapError(mapGhError("failed to create pull request"))); + }), + ); + + const created = yield* findPr({ cwd: input.cwd, branch: input.branch }); + const createdPr = created[0]; + if (createdPr === undefined) { + return yield* eventStoreError("pull request created but could not be located by branch"); + } + return { number: createdPr.number, url: createdPr.url, adopted: false }; + }); + + const findPrForBranch: GitHubPortShape["findPrForBranch"] = (input) => + findPr({ cwd: input.cwd, branch: input.branch }).pipe( + Effect.map((prs) => { + const pr = prs[0]; + return pr === undefined ? null : { number: pr.number, url: pr.url }; + }), + ); + + const prDetail: GitHubPortShape["prDetail"] = (input) => + Effect.gen(function* () { + const detail = yield* gh + .getPullRequestDetail({ cwd: input.cwd, number: input.prNumber }) + .pipe(Effect.mapError(mapGhError("failed to read pull request detail"))); + const checks = yield* gh + .listPullRequestChecks({ cwd: input.cwd, number: input.prNumber }) + .pipe(Effect.mapError(mapGhError("failed to read pull request checks"))); + + return { + number: input.prNumber, + url: detail.url, + state: normalizeState({ state: detail.state, mergedAt: detail.mergedAt }), + headSha: detail.headRefOid.trim().length > 0 ? detail.headRefOid : null, + reviewDecision: normalizeReviewDecision(detail.reviewDecision), + ciState: ciStateFromChecks(checks), + } satisfies GitHubPrDetail; + }); + + const mergePr: GitHubPortShape["mergePr"] = (input) => + gh + .mergePullRequest({ cwd: input.cwd, number: input.prNumber, strategy: input.strategy }) + .pipe( + Effect.matchEffect({ + onFailure: (error) => + looksNotMergeable(error.detail) + ? Effect.succeed({ ok: false, reason: firstLine(error.detail) } as + | { ok: true } + | { ok: false; reason: string }) + : Effect.fail(eventStoreError("failed to merge pull request", error)), + onSuccess: () => + Effect.gen(function* () { + if (input.deleteBranch) { + // Best-effort remote-branch cleanup. NEVER `gh --delete-branch`: + // the local branch backs a live worktree. + yield* git + .run({ + cwd: input.cwd, + args: ["push", input.remoteName, "--delete", input.branch], + allowNonZeroExit: true, + }) + .pipe(Effect.ignore); + } + return { ok: true } as { ok: true } | { ok: false; reason: string }; + }), + }), + ); + + const failingCheckLogs: GitHubPortShape["failingCheckLogs"] = (input) => + Effect.gen(function* () { + const checks = yield* gh + .listPullRequestChecks({ cwd: input.cwd, number: input.prNumber }) + .pipe(Effect.mapError(mapGhError("failed to read pull request checks"))); + const failing = checks.filter((check) => { + const bucket = check.bucket.trim().toLowerCase(); + return bucket === "fail" || bucket === "cancel"; + }); + if (failing.length === 0) { + return null; + } + + const firstFailing = failing[0]!; + const runIdMatch = /\/actions\/runs\/(\d+)/.exec(firstFailing.link); + const runId = runIdMatch?.[1]; + if (runId === undefined) { + // No parseable run id — return the failed check names as a summary. + return failing.map((check) => check.name).filter((name) => name.length > 0).join(", "); + } + + const output = yield* gh + .execute({ cwd: input.cwd, args: ["run", "view", runId, "--log-failed"] }) + .pipe(Effect.mapError(mapGhError("failed to read failing check logs"))); + const stdout = output.stdout; + return stdout.length > FAILING_CHECK_LOG_CAP + ? stdout.slice(stdout.length - FAILING_CHECK_LOG_CAP) + : stdout; + }); + + const listReviewFeedback: GitHubPortShape["listReviewFeedback"] = (input) => + Effect.gen(function* () { + const reviews = yield* gh + .listPullRequestReviews({ cwd: input.cwd, number: input.prNumber }) + .pipe(Effect.mapError(mapGhError("failed to read pull request reviews"))); + const comments = yield* gh + .listPullRequestReviewComments({ + cwd: input.cwd, + repo: input.repo, + number: input.prNumber, + }) + .pipe(Effect.mapError(mapGhError("failed to read pull request review comments"))); + + const items: Array = []; + for (const review of reviews) { + if (review.body.trim().length === 0) continue; + items.push({ + id: review.id, + author: review.author, + body: review.body, + submittedAt: review.submittedAt, + sortKey: review.submittedAt, + }); + } + for (const comment of comments) { + if (comment.body.trim().length === 0) continue; + items.push({ + id: `comment:${comment.id}`, + author: comment.user, + body: comment.body, + submittedAt: comment.createdAt, + sortKey: comment.createdAt, + }); + } + + items.sort((a, b) => (a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0)); + return items.map(({ sortKey: _sortKey, ...item }) => item); + }); + + return { + preflight, + resolveRemote, + defaultBranch, + openPr, + findPrForBranch, + prDetail, + mergePr, + failingCheckLogs, + listReviewFeedback, + } satisfies GitHubPortShape; +}); + +export const GitHubPortLive = Layer.effect(GitHubPort, make); diff --git a/apps/server/src/workflow/Layers/GithubIssuesProvider.test.ts b/apps/server/src/workflow/Layers/GithubIssuesProvider.test.ts new file mode 100644 index 00000000000..059e2ae2a32 --- /dev/null +++ b/apps/server/src/workflow/Layers/GithubIssuesProvider.test.ts @@ -0,0 +1,446 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { GithubIssuesProvider as GithubIssuesProviderTag } from "../Services/WorkSourceProvider.ts"; +import { WorkSourceConnectionStore } from "../Services/WorkSourceConnectionStore.ts"; +import { GithubIssuesProviderLive } from "./GithubIssuesProvider.ts"; + +// --------------------------------------------------------------------------- +// Canned GitHub API responses +// --------------------------------------------------------------------------- + +/** Issue-1: open issue (should be included) */ +const issueOpen = { + number: 1, + state: "open", + title: "Bug: something broken", + body: "Describe the bug", + html_url: "https://github.com/o/r/issues/1", + updated_at: "2024-01-01T00:00:00Z", + assignees: [{ login: "alice" }], + labels: [{ name: "bug" }], +}; + +/** Issue-2: pull request — should be FILTERED OUT */ +const pullRequest = { + number: 2, + state: "open", + title: "PR: add feature", + body: null, + html_url: "https://github.com/o/r/pull/2", + updated_at: "2024-01-02T00:00:00Z", + assignees: [], + labels: [], + pull_request: { url: "https://api.github.com/repos/o/r/pulls/2" }, +}; + +/** Issue-3: closed issue (should be included, lifecycle=closed) */ +const issueClosed = { + number: 3, + state: "closed", + title: "Fixed: something", + body: null, + html_url: "https://github.com/o/r/issues/3", + updated_at: "2024-01-03T00:00:00Z", + assignees: [], + labels: [{ name: "fixed" }], +}; + +// --------------------------------------------------------------------------- +// Helper: build a test layer with mocked HttpClient + connection store +// --------------------------------------------------------------------------- + +function makeTestLayer(input: { + readonly responseBody: unknown; + readonly responseStatus?: number; + readonly responseHeaders?: Record; + readonly pat?: string; +}) { + const pat = input.pat ?? "test-pat-12345"; + const status = input.responseStatus ?? 200; + const headers = input.responseHeaders ?? {}; + + const execute = vi.fn((request: HttpClientRequest.HttpClientRequest) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(input.responseBody), { + status, + headers: { + "content-type": "application/json", + ...headers, + }, + }), + ), + ), + ); + + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => execute(request)), + ); + + const connectionStoreLayer = Layer.succeed(WorkSourceConnectionStore, { + getToken: (_connectionRef, _expectedProvider) => Effect.succeed(pat), + create: (_input) => Effect.die("not needed in test"), + list: () => Effect.die("not needed in test"), + remove: (_connectionRef) => Effect.die("not needed in test"), + }); + + const testLayer = GithubIssuesProviderLive.pipe( + Layer.provide(httpClientLayer), + Layer.provide(connectionStoreLayer), + ); + + return { execute, testLayer }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("GithubIssuesProvider", () => { + describe("listPage", () => { + it.effect("lists issues, filters PRs, maps lifecycle + pagination", () => { + const linkHeader = + '; rel="next", ; rel="last"'; + + const { testLayer } = makeTestLayer({ + responseBody: [issueOpen, pullRequest, issueClosed], + responseHeaders: { link: linkHeader }, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + selector: { owner: "o", repo: "r", state: "all" }, + pageSize: 50, + }); + + // PR (issue-2) should be filtered + expect(page.items.map((i) => i.externalId)).toEqual(["1", "3"]); + + // open issue lifecycle + expect(page.items[0]!.lifecycle).toBe("open"); + // closed issue lifecycle + expect(page.items[1]!.lifecycle).toBe("closed"); + + // version.updatedAt is mapped + expect(page.items[0]!.version.updatedAt).toBe("2024-01-01T00:00:00Z"); + expect(page.items[1]!.version.updatedAt).toBe("2024-01-03T00:00:00Z"); + + // fields are mapped + expect(page.items[0]!.fields.title).toBe("Bug: something broken"); + expect(page.items[0]!.fields.description).toBe("Describe the bug"); + expect(page.items[0]!.fields.assignees).toEqual(["alice"]); + expect(page.items[0]!.fields.labels).toEqual(["bug"]); + + // closed issue body=null → description=undefined + expect(page.items[1]!.fields.description).toBeUndefined(); + + // nextPageToken parsed from Link header + expect(page.nextPageToken).toBe("2"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("returns no nextPageToken when Link header is absent", () => { + const { testLayer } = makeTestLayer({ + responseBody: [issueOpen], + responseHeaders: {}, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + selector: { owner: "o", repo: "r", state: "open" }, + pageSize: 50, + }); + + expect(page.nextPageToken).toBeUndefined(); + expect(page.items).toHaveLength(1); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("applies GithubSelector defaulting (state defaults to 'all')", () => { + const { execute, testLayer } = makeTestLayer({ responseBody: [] }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + // Omit 'state' — should default to 'all' via GithubSelector + yield* provider.listPage({ + connectionRef: "conn", + selector: { owner: "myorg", repo: "myrepo" }, + pageSize: 25, + }); + + const request = execute.mock.calls[0]?.[0]; + expect(request).toBeDefined(); + // URL should contain per_page and state params + expect(request!.url).toContain("myorg"); + expect(request!.url).toContain("myrepo"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("maps provider field on items", () => { + const { testLayer } = makeTestLayer({ responseBody: [issueOpen] }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const page = yield* provider.listPage({ + connectionRef: "conn", + selector: { owner: "o", repo: "r" }, + pageSize: 10, + }); + + expect(page.items[0]!.provider).toBe("github"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("sends Authorization header with PAT", () => { + const { execute, testLayer } = makeTestLayer({ + responseBody: [], + pat: "my-secret-pat", + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + yield* provider.listPage({ + connectionRef: "conn", + selector: { owner: "o", repo: "r" }, + pageSize: 10, + }); + + const request = execute.mock.calls[0]?.[0]; + expect(request).toBeDefined(); + expect(request!.headers["authorization"]).toBe("Bearer my-secret-pat"); + expect(request!.headers["accept"]).toBe("application/vnd.github+json"); + expect(request!.headers["x-github-api-version"]).toBe("2022-11-28"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("maps 401 to WorkSourceAuthError", () => { + const { testLayer } = makeTestLayer({ + responseBody: { message: "Bad credentials" }, + responseStatus: 401, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "my-conn", + selector: { owner: "o", repo: "r" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceAuthError"); + expect((failure as { connectionRef?: string }).connectionRef).toBe("my-conn"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("maps 429 with retry-after to WorkSourceRateLimitError", () => { + const { testLayer } = makeTestLayer({ + responseBody: { message: "rate limited" }, + responseStatus: 429, + responseHeaders: { "retry-after": "30" }, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "conn", + selector: { owner: "o", repo: "r" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceRateLimitError"); + expect((failure as { retryAfterMs?: number }).retryAfterMs).toBe(30_000); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("maps 403 with x-ratelimit-remaining:0 to WorkSourceRateLimitError", () => { + // Use a far-future epoch so the delta is always positive + const futureResetEpochSec = 9_999_999_999; + const { testLayer } = makeTestLayer({ + responseBody: { message: "API rate limit exceeded" }, + responseStatus: 403, + responseHeaders: { + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String(futureResetEpochSec), + }, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "conn", + selector: { owner: "o", repo: "r" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceRateLimitError"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("maps 403 without rate-limit headers to WorkSourceAuthError", () => { + // Common misconfigured-PAT case: 403 with no rate-limit headers at all. + const { testLayer } = makeTestLayer({ + responseBody: { message: "Resource not accessible by personal access token" }, + responseStatus: 403, + responseHeaders: {}, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "bad-pat-conn", + selector: { owner: "o", repo: "r" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceAuthError"); + expect((failure as { connectionRef?: string }).connectionRef).toBe("bad-pat-conn"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("computes retryAfterMs from x-ratelimit-reset epoch math", () => { + // `it.effect` runs with the default Effect clock at epoch 0, and the + // provider reads `DateTime.now`. Pin the reset epoch 120s past epoch 0 so + // the computed delta (resetMs - nowMs) is a deterministic ~120_000ms. + // A future epoch->ms unit regression (e.g. forgetting the *1000) would + // collapse this to ~120ms and fail the lower bound. + const resetEpochSec = 120; + const { testLayer } = makeTestLayer({ + responseBody: { message: "API rate limit exceeded" }, + responseStatus: 403, + responseHeaders: { + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String(resetEpochSec), + }, + }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "conn", + selector: { owner: "o", repo: "r" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceRateLimitError"); + const retryAfterMs = (failure as { retryAfterMs?: number }).retryAfterMs; + // ~120_000ms (120s * 1000). Wide enough to absorb any small clock slack, + // tight enough that a missing *1000 (=> ~120ms) fails the lower bound. + expect(retryAfterMs).toBeGreaterThan(60_000); + expect(retryAfterMs).toBeLessThanOrEqual(130_000); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("fails with WorkSourceConfigError for invalid selector", () => { + const { testLayer } = makeTestLayer({ responseBody: [] }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* Effect.flip( + provider.listPage({ + connectionRef: "conn", + // missing required 'owner' and 'repo' + selector: { state: "open" }, + pageSize: 10, + }), + ); + expect(failure._tag).toBe("WorkSourceConfigError"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("Fix 6: 200 body that is not an array → WorkSourceTransientError (not a defect)", () => { + const { testLayer } = makeTestLayer({ responseBody: { message: "garbage" }, responseStatus: 200 }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* provider + .listPage({ connectionRef: "conn", selector: { owner: "o", repo: "r" }, pageSize: 10 }) + .pipe(Effect.flip); + expect(failure._tag).toBe("WorkSourceTransientError"); + }).pipe(Effect.provide(testLayer)); + }); + }); + + describe("getItem", () => { + const selector = { owner: "o", repo: "r" }; + + it.effect("404 → null (genuinely deleted upstream)", () => { + const { testLayer } = makeTestLayer({ responseBody: { message: "Not Found" }, responseStatus: 404 }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const result = yield* provider.getItem({ + connectionRef: "conn", + selector, + externalId: "42", + }); + expect(result).toBeNull(); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("200 → the mapped item (still exists; fell out of a filter, NOT deleted)", () => { + const { testLayer } = makeTestLayer({ responseBody: issueOpen, responseStatus: 200 }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const result = yield* provider.getItem({ + connectionRef: "conn", + selector, + externalId: "1", + }); + expect(result).not.toBeNull(); + expect(result!.externalId).toBe("1"); + expect(result!.fields.title).toBe("Bug: something broken"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("401 → WorkSourceAuthError (typed failure, NOT null)", () => { + const { testLayer } = makeTestLayer({ responseBody: { message: "Bad credentials" }, responseStatus: 401 }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* provider + .getItem({ connectionRef: "conn", selector, externalId: "1" }) + .pipe(Effect.flip); + expect(failure._tag).toBe("WorkSourceAuthError"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("500 → WorkSourceTransientError (typed failure, NOT null)", () => { + const { testLayer } = makeTestLayer({ responseBody: { message: "boom" }, responseStatus: 500 }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* provider + .getItem({ connectionRef: "conn", selector, externalId: "1" }) + .pipe(Effect.flip); + expect(failure._tag).toBe("WorkSourceTransientError"); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("malformed 200 body (array, not an object) → WorkSourceTransientError", () => { + const { testLayer } = makeTestLayer({ responseBody: [1, 2, 3], responseStatus: 200 }); + + return Effect.gen(function* () { + const provider = yield* GithubIssuesProviderTag; + const failure = yield* provider + .getItem({ connectionRef: "conn", selector, externalId: "1" }) + .pipe(Effect.flip); + expect(failure._tag).toBe("WorkSourceTransientError"); + }).pipe(Effect.provide(testLayer)); + }); + }); +}); diff --git a/apps/server/src/workflow/Layers/GithubIssuesProvider.ts b/apps/server/src/workflow/Layers/GithubIssuesProvider.ts new file mode 100644 index 00000000000..c6124666bed --- /dev/null +++ b/apps/server/src/workflow/Layers/GithubIssuesProvider.ts @@ -0,0 +1,331 @@ +/** + * GithubIssuesProvider — raw-HTTP GitHub Issues work-source provider. + * + * Uses `HttpClient` from `effect/unstable/http` (NOT the `gh` CLI) with a PAT + * fetched from `WorkSourceConnectionStore.getToken`. + * + * ### externalId strategy + * `externalId = String(number)` — the issue number is stable per repo and lets + * `getItem` issue a simple `GET /repos/{owner}/{repo}/issues/:number` lookup. + * + * ### nextPageToken strategy + * Parse the `Link` response header for `rel="next"` and extract the `page` + * query-parameter value. Fall back to the page-count heuristic + * (`items.length === pageSize ? String(Number(pageToken ?? 1) + 1) : undefined`) + * only if the header is absent (GitHub always emits it when another page + * exists). + * + * ### getItem + * `getItem` decodes the source `selector` for owner/repo, then issues + * `GET /repos/{owner}/{repo}/issues/{externalId}` (externalId = issue number). + * 404 → null (genuinely deleted upstream → the syncer may terminal-route). + * 200 → the mapped item (it STILL EXISTS — it merely fell out of a label/ + * assignee/state filter, so it must NOT be confirmed-deleted). Auth/rate-limit/ + * transient map to their typed errors so the syncer treats them as + * "cannot confirm" (no deletion) and backs the source off. + */ +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 { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { GithubSelector } from "@t3tools/contracts/workSource"; + +import { + GithubIssuesProvider as GithubIssuesProviderTag, + WorkSourceAuthError, + WorkSourceConfigError, + WorkSourceRateLimitError, + WorkSourceTransientError, + type ExternalWorkItem, + type WorkSourcePage, + type WorkSourceProvider, +} from "../Services/WorkSourceProvider.ts"; +import { WorkSourceConnectionStore } from "../Services/WorkSourceConnectionStore.ts"; + +const GITHUB_API_BASE = "https://api.github.com"; +const GITHUB_API_VERSION = "2022-11-28"; +const USER_AGENT = "t3code-work-source/1.0"; + +// --------------------------------------------------------------------------- +// Link-header parser +// --------------------------------------------------------------------------- + +/** + * Parse GitHub's `Link` header and return the `page` value for `rel="next"`, + * or `undefined` if no next page. + * + * Example header value: + * ; rel="next", + * ; rel="last" + */ +function parseNextPageFromLinkHeader(linkHeader: string | undefined): string | undefined { + if (!linkHeader) return undefined; + // Split on commas that separate link entries + for (const part of linkHeader.split(",")) { + const nextMatch = part.match(/rel="next"/u); + if (!nextMatch) continue; + const urlMatch = part.match(/<([^>]+)>/u); + if (!urlMatch?.[1]) continue; + try { + const pageParam = new URL(urlMatch[1]).searchParams.get("page"); + return pageParam ?? undefined; + } catch { + return undefined; + } + } + return undefined; +} + +// --------------------------------------------------------------------------- +// Rate-limit helper +// --------------------------------------------------------------------------- + +function parseRateLimitRetryMs(headers: Record, nowMs: number): number { + // retry-after is in seconds + const retryAfter = headers["retry-after"]; + if (retryAfter) { + const seconds = Number(retryAfter); + if (!Number.isNaN(seconds)) return seconds * 1000; + } + // x-ratelimit-reset is an epoch timestamp in seconds + const resetEpoch = headers["x-ratelimit-reset"]; + if (resetEpoch) { + const resetMs = Number(resetEpoch) * 1000; + const delta = resetMs - nowMs; + return delta > 0 ? delta : 5000; + } + return 60_000; // fallback: 1 minute +} + +// --------------------------------------------------------------------------- +// Raw GitHub JSON shapes (loose — we only need the fields we use) +// --------------------------------------------------------------------------- + +interface RawGithubIssue { + readonly number: number; + readonly state: string; + readonly title: string; + readonly body: string | null; + readonly html_url: string; + readonly updated_at: string; + readonly pull_request?: unknown; + readonly assignees?: ReadonlyArray<{ readonly login: string }>; + readonly labels?: ReadonlyArray<{ readonly name: string }>; +} + +function mapIssue(raw: RawGithubIssue): ExternalWorkItem { + const assignees = raw.assignees?.map((a) => a.login); + const labels = raw.labels?.map((l) => l.name); + return { + provider: "github", + externalId: String(raw.number), + url: raw.html_url, + lifecycle: raw.state === "open" ? "open" : "closed", + version: { updatedAt: raw.updated_at }, + fields: { + title: raw.title, + // exactOptionalPropertyTypes: only spread when value is defined + ...(raw.body != null && { description: raw.body }), + ...(assignees !== undefined && { assignees }), + ...(labels !== undefined && { labels }), + }, + }; +} + +// --------------------------------------------------------------------------- +// Provider implementation +// --------------------------------------------------------------------------- + +const make = Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const connectionStore = yield* WorkSourceConnectionStore; + + function buildHeaders(pat: string): Record { + return { + authorization: `Bearer ${pat}`, + accept: "application/vnd.github+json", + "x-github-api-version": GITHUB_API_VERSION, + "user-agent": USER_AGENT, + }; + } + + const provider: WorkSourceProvider = { + provider: "github", + selectorSchema: GithubSelector, + + listPage: (input) => + Effect.gen(function* () { + // Decode selector + const selector = yield* Schema.decodeUnknownEffect(GithubSelector)(input.selector).pipe( + Effect.mapError( + (e) => new WorkSourceConfigError({ message: `Invalid GitHub selector: ${e.message}` }), + ), + ); + + const pat = yield* connectionStore.getToken(input.connectionRef, "github"); + const now = yield* DateTime.now; + const nowMs = DateTime.toEpochMillis(now); + + const { owner, repo, labels, assignee, state } = selector; + + // Build URL params + const urlParams: Array = [ + ["state", state], + ["per_page", String(input.pageSize)], + ["page", String(input.pageToken ?? "1")], + ]; + if (input.since) urlParams.push(["since", input.since]); + if (labels && labels.length > 0) urlParams.push(["labels", labels.join(",")]); + if (assignee) urlParams.push(["assignee", assignee]); + + const request = HttpClientRequest.get( + `${GITHUB_API_BASE}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`, + { urlParams }, + ).pipe( + HttpClientRequest.setHeaders(buildHeaders(pat)), + ); + + const response = yield* client.execute(request).pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ + message: `GitHub HTTP network error: ${String(cause)}`, + }), + ), + ); + + const { status, headers } = response; + + // Status error mapping + if (status === 401 || (status === 403 && !headers["x-ratelimit-remaining"])) { + // 401 always auth; 403 without rate-limit headers → auth/permission + return yield* new WorkSourceAuthError({ connectionRef: input.connectionRef }); + } + if ( + status === 429 || + (status === 403 && headers["x-ratelimit-remaining"] === "0") + ) { + return yield* new WorkSourceRateLimitError({ + retryAfterMs: parseRateLimitRetryMs(headers, nowMs), + }); + } + if (status < 200 || status >= 300) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* new WorkSourceTransientError({ + message: `GitHub API returned HTTP ${status}: ${body.trim() || "(no body)"}`, + }); + } + + const rawItems = yield* response.json.pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ + message: `Failed to parse GitHub JSON response: ${String(cause)}`, + }), + ), + ); + + if (!Array.isArray(rawItems)) { + return yield* new WorkSourceTransientError({ + message: "GitHub /issues response was not an array", + }); + } + + const items: Array = []; + for (const raw of rawItems as RawGithubIssue[]) { + // Skip pull requests (GitHub includes PRs in /issues endpoint) + if (raw.pull_request !== undefined) continue; + items.push(mapIssue(raw)); + } + + const linkHeader = headers["link"]; + const nextPageToken = parseNextPageFromLinkHeader(linkHeader); + + // exactOptionalPropertyTypes: only include nextPageToken when present + const page: WorkSourcePage = { + items, + ...(nextPageToken !== undefined && { nextPageToken }), + }; + return page; + }), + + getItem: (input) => + Effect.gen(function* () { + const selector = yield* Schema.decodeUnknownEffect(GithubSelector)(input.selector).pipe( + Effect.mapError( + (e) => new WorkSourceConfigError({ message: `Invalid GitHub selector: ${e.message}` }), + ), + ); + + const pat = yield* connectionStore.getToken(input.connectionRef, "github"); + const now = yield* DateTime.now; + const nowMs = DateTime.toEpochMillis(now); + + const { owner, repo } = selector; + + const request = HttpClientRequest.get( + `${GITHUB_API_BASE}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${encodeURIComponent(input.externalId)}`, + ).pipe(HttpClientRequest.setHeaders(buildHeaders(pat))); + + const response = yield* client.execute(request).pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ + message: `GitHub HTTP network error (getItem): ${String(cause)}`, + }), + ), + ); + + const { status, headers } = response; + + // 404 → genuinely deleted upstream. + if (status === 404) { + return null; + } + if (status === 401 || (status === 403 && !headers["x-ratelimit-remaining"])) { + return yield* new WorkSourceAuthError({ connectionRef: input.connectionRef }); + } + if (status === 429 || (status === 403 && headers["x-ratelimit-remaining"] === "0")) { + return yield* new WorkSourceRateLimitError({ + retryAfterMs: parseRateLimitRetryMs(headers, nowMs), + }); + } + if (status < 200 || status >= 300) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* new WorkSourceTransientError({ + message: `GitHub API returned HTTP ${status} (getItem): ${body.trim() || "(no body)"}`, + }); + } + + const rawItem = yield* response.json.pipe( + Effect.mapError( + (cause) => + new WorkSourceTransientError({ + message: `Failed to parse GitHub getItem JSON response: ${String(cause)}`, + }), + ), + ); + + // Guard the shape: the single-issue endpoint returns an object, not an + // array. A non-conforming body → transient (back off, never confirm). + if (rawItem === null || typeof rawItem !== "object" || Array.isArray(rawItem)) { + return yield* new WorkSourceTransientError({ + message: "GitHub /issues/:number response was not an object", + }); + } + + // The item still exists upstream (it merely fell out of the filter): + // return it so the syncer leaves confirmedDeleted=false. + return mapIssue(rawItem as unknown as RawGithubIssue); + }), + }; + + return provider; +}); + +export const GithubIssuesProviderLive: Layer.Layer< + GithubIssuesProviderTag, + never, + HttpClient.HttpClient | WorkSourceConnectionStore +> = Layer.effect(GithubIssuesProviderTag, 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..83ac0c2c724 --- /dev/null +++ b/apps/server/src/workflow/Layers/ProviderDispatchOutbox.test.ts @@ -0,0 +1,393 @@ +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("confirms the outbox row when the terminal wait times out", () => + 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: "running" 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); + + const terminalFiber = yield* Effect.forkChild( + outbox.awaitTerminal(request.dispatchId, request.threadId), + ); + yield* Effect.yieldNow; + yield* TestClock.adjust("30 minutes"); + const terminal = yield* Fiber.join(terminalFiber); + assert.deepEqual(terminal, { + ok: false, + error: "turn did not reach a terminal state before timeout", + }); + + // The timed-out row must be settled so restart recovery never + // re-dispatches a step the pipeline already failed. + 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..d8d8b4edc8b --- /dev/null +++ b/apps/server/src/workflow/Layers/ProviderDispatchOutbox.ts @@ -0,0 +1,481 @@ +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.gen(function* () { + // The pipeline treats this timeout as the step's terminal + // failure, so settle the outbox row too — otherwise restart + // recovery would re-dispatch/re-monitor a step the pipeline + // already routed on. + const confirmedAt = yield* nowIso; + yield* wrapSql(sql` + UPDATE workflow_dispatch_outbox + SET status = 'confirmed', + confirmed_at = ${confirmedAt} + WHERE dispatch_id = ${dispatchId} + `); + return { + 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..6eca680e3cf --- /dev/null +++ b/apps/server/src/workflow/Layers/RealStepExecutor.test.ts @@ -0,0 +1,1555 @@ +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 Stream from "effect/Stream"; +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 { ProviderService } from "../../provider/Services/ProviderService.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 { TicketPullRequestService } from "../Services/TicketPullRequestService.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 pullRequestServiceCalls: Array<{ readonly action: string; readonly input: unknown }> = []; +const StubTicketPullRequestServiceLayer = Layer.succeed(TicketPullRequestService, { + open: (input) => + Effect.sync(() => { + pullRequestServiceCalls.push({ action: "open", input }); + return { _tag: "completed", output: { prNumber: 1, url: "https://example/pull/1" } } as const; + }), + land: (input) => + Effect.sync(() => { + pullRequestServiceCalls.push({ action: "land", input }); + return { _tag: "completed" } as const; + }), +}); + +const openPrContext: StepExecutionContext = { + ...context, + ticketId: "ticket-open-pr" as never, + stepRunId: "step-run-open-pr" as never, + step: { + key: "open-pr" as never, + type: "pullRequest", + action: "open" as never, + }, +}; + +const landPrContext: StepExecutionContext = { + ...context, + ticketId: "ticket-land-pr" as never, + stepRunId: "step-run-land-pr" as never, + step: { + key: "land-pr" as never, + type: "pullRequest", + action: "land" 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(StubTicketPullRequestServiceLayer), + 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(StubTicketPullRequestServiceLayer), + 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("routes a pullRequest open step through the PR service without project setup", () => + Effect.gen(function* () { + pullRequestServiceCalls.length = 0; + setupCalls.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor(openPrContext, "event-step-started-open-pr"); + + const outcome = yield* executor.execute(openPrContext); + + assert.deepEqual(outcome, { + _tag: "completed", + output: { prNumber: 1, url: "https://example/pull/1" }, + }); + assert.deepEqual(setupCalls, []); + assert.equal(pullRequestServiceCalls.length, 1); + const call = pullRequestServiceCalls[0] as { + readonly action: string; + readonly input: { + readonly ticketId: string; + readonly stepRunId: string; + readonly repoRoot: string; + readonly worktreePath: string; + readonly worktreeRef: string; + readonly step: { readonly action: string }; + }; + }; + assert.equal(call.action, "open"); + assert.equal(call.input.ticketId, "ticket-open-pr"); + assert.equal(call.input.stepRunId, "step-run-open-pr"); + assert.equal(call.input.repoRoot, "/tmp/repo-ticket-1"); + assert.equal(call.input.worktreePath, "/tmp/wt-ticket-1"); + assert.equal(call.input.worktreeRef, "wt-ticket-1"); + assert.equal(call.input.step.action, "open"); + }), + ); + + it.effect("routes a pullRequest land step through the PR service", () => + Effect.gen(function* () { + pullRequestServiceCalls.length = 0; + setupCalls.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStartedFor(landPrContext, "event-step-started-land-pr"); + + const outcome = yield* executor.execute(landPrContext); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.deepEqual(setupCalls, []); + assert.equal(pullRequestServiceCalls.length, 1); + const call = pullRequestServiceCalls[0] as { + readonly action: string; + readonly input: { readonly worktreeRef: string }; + }; + assert.equal(call.action, "land"); + assert.equal(call.input.worktreeRef, "wt-ticket-1"); + }), + ); + + 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, StubTicketPullRequestServiceLayer, 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"); + }), + ); +}); + +const providerSessionCalls: Array = []; +const timeoutDispatchInputs: Array = []; + +const terminalTimeoutLayer = 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) => + Effect.succeed(kind === "pre" ? preRef : postRef), + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderDispatchOutbox, { + confirmStep: () => Effect.void, + ensureStarted: (input) => + Effect.sync(() => { + timeoutDispatchInputs.push(input); + return { turnId: "turn-stub" as never }; + }), + getDispatchForStep: () => Effect.succeed(null), + awaitTerminal: () => + Effect.succeed({ + ok: false, + error: "turn did not reach a terminal state before timeout", + }), + awaitStepTerminal: () => + Effect.succeed({ + ok: false, + error: "turn did not reach a terminal state before timeout", + }), + recoverPending: () => Effect.void, + }), + ), + Layer.provideMerge( + Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused startSession"), + sendTurn: () => Effect.die("unused sendTurn"), + interruptTurn: (input) => + Effect.sync(() => { + providerSessionCalls.push(`interrupt:${input.threadId as string}:${input.turnId as string}`); + }), + respondToRequest: () => Effect.die("unused respondToRequest"), + respondToUserInput: () => Effect.die("unused respondToUserInput"), + stopSession: (input) => + Effect.sync(() => { + providerSessionCalls.push(`stop:${input.threadId as string}`); + }), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die("unused getCapabilities"), + getInstanceInfo: () => Effect.die("unused getInstanceInfo"), + rollbackConversation: () => Effect.die("unused rollbackConversation"), + streamEvents: Stream.empty, + }), + ), + Layer.provideMerge( + Layer.succeed(CapturedStepOutputReader, { + read: () => Effect.void, + }), + ), + Layer.provideMerge(Layer.mergeAll(StubTicketMergeServiceLayer, StubTicketPullRequestServiceLayer, WorkflowEventCommitterLive)), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(realStepExecutorTestSupport), + Layer.provideMerge(NodeServices.layer), + ), +); + +terminalTimeoutLayer("RealStepExecutor terminal-wait timeout", (it) => { + it.effect("stops the provider session when the turn never reached a terminal state", () => + Effect.gen(function* () { + providerSessionCalls.length = 0; + timeoutDispatchInputs.length = 0; + const executor = yield* StepExecutor; + yield* seedStepStarted; + + const outcome = yield* executor.execute(context); + + assert.deepEqual(outcome, { + _tag: "failed", + error: "turn did not reach a terminal state before timeout", + }); + // The still-live agent must be interrupted and its session stopped so + // it cannot keep mutating the worktree after the pipeline routed on. + const threadId = (timeoutDispatchInputs[0] as { readonly threadId: string }).threadId; + assert.deepEqual(providerSessionCalls, [ + `interrupt:${threadId}:turn-stub`, + `stop:${threadId}`, + ]); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/RealStepExecutor.ts b/apps/server/src/workflow/Layers/RealStepExecutor.ts new file mode 100644 index 00000000000..254f433220d --- /dev/null +++ b/apps/server/src/workflow/Layers/RealStepExecutor.ts @@ -0,0 +1,698 @@ +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 { TicketPullRequestService } from "../Services/TicketPullRequestService.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 pullRequests = yield* TicketPullRequestService; + 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 cleanupStepSession = (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* cleanupStepSession(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; + } + // The turn may still be live (e.g. the terminal-wait timed out): stop + // the provider session so the agent cannot keep mutating the worktree + // while the pipeline routes on. Interrupting an already-terminal turn + // is a harmless no-op. + yield* cleanupStepSession(result.threadId, result.turnId); + 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 }, + ); + } + if (step.type === "pullRequest") { + // PR steps need no project dependencies installed in the worktree — + // they push/merge via gh. open and land share the same worktree prep. + return yield* prepareWorktreeStep( + ctx, + (worktree) => + step.action === "open" + ? pullRequests.open({ + ticketId: ctx.ticketId, + stepRunId: ctx.stepRunId, + repoRoot: worktree.repoRoot, + worktreePath: worktree.path, + worktreeRef: worktree.worktreeRef, + step, + }) + : pullRequests.land({ + ticketId: ctx.ticketId, + stepRunId: ctx.stepRunId, + repoRoot: worktree.repoRoot, + worktreePath: worktree.path, + worktreeRef: worktree.worktreeRef, + step, + }), + { 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..064e4864e0f --- /dev/null +++ b/apps/server/src/workflow/Layers/ScriptCommandRunner.test.ts @@ -0,0 +1,243 @@ +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"); + }; + }), + getSnapshot: () => Effect.succeed(null), + 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 ?? "*"}`); + }), + getSnapshot: () => Effect.succeed(null), + 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, + getSnapshot: () => Effect.succeed(null), + 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 ?? "*"}`); + }), + getSnapshot: () => Effect.succeed(null), + 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..39010c938ed --- /dev/null +++ b/apps/server/src/workflow/Layers/SetupRunService.test.ts @@ -0,0 +1,131 @@ +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 { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { + SetupRunService, + SetupTerminalPort, +} from "../Services/SetupRunService.ts"; +import { SetupRunServiceLive, SetupTerminalPortLive } from "./SetupRunService.ts"; +import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts"; + +const stubTerminal = (exitCode: number) => + Layer.succeed(SetupTerminalPort, { + launch: () => Effect.succeed({ threadId: "workflow-setup:/tmp/wt-1", 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); + }), + ); +}); + +// --------------------------------------------------------------------------- +// SetupTerminalPortLive — subscribe-then-check race (Fix 2) +// --------------------------------------------------------------------------- + +// Test that awaitExit resolves immediately when the terminal is already exited +// at the time the listener is installed (no live event required). +const preExitedTerminalLayer = (exitCode: number) => + Layer.succeed(TerminalManager, { + open: () => Effect.die("unused"), + attachStream: () => Effect.die("unused"), + attachHistoryStream: () => Effect.die("unused"), + write: () => Effect.die("unused"), + resize: () => Effect.die("unused"), + clear: () => Effect.die("unused"), + restart: () => Effect.die("unused"), + close: () => Effect.void, + getSnapshot: () => + Effect.succeed({ + threadId: "workflow-setup:/tmp/pre-exited", + terminalId: "term-pre-exited", + cwd: "/tmp/pre-exited", + worktreePath: null, + status: "exited" as const, + pid: null, + history: "", + exitCode, + exitSignal: null, + label: "pre-exited", + updatedAt: "2026-01-01T00:00:00.000Z", + sequence: 0, + }), + subscribe: () => Effect.succeed(() => undefined), + subscribeMetadata: () => Effect.succeed(() => undefined), + }); + +const stubSetupRunner = Layer.succeed(ProjectSetupScriptRunner, { + runForThread: () => + Effect.succeed({ + status: "started", + scriptId: "script-1", + scriptName: "setup", + terminalId: "term-pre-exited", + cwd: "/tmp/pre-exited", + }), +}); + +it.layer( + SetupTerminalPortLive.pipe( + Layer.provideMerge(preExitedTerminalLayer(0)), + Layer.provideMerge(stubSetupRunner), + ), +)("SetupTerminalPortLive pre-exited terminal", (it) => { + it.effect("resolves immediately when terminal already exited before listener installed", () => + Effect.gen(function* () { + const port = yield* SetupTerminalPort; + // The terminal manager stub never fires any events — if the subscribe- + // then-check race fix is working, awaitExit must resolve via the + // getSnapshot check rather than waiting for a live event. + // We use a short timeout: without the fix this would time out (returning + // exitCode -1 via orElseSucceed), with the fix it resolves immediately. + const result = yield* port.awaitExit({ + threadId: "workflow-setup:/tmp/pre-exited", + terminalId: "term-pre-exited", + timeoutMs: 50, + }); + assert.equal( + result.exitCode, + 0, + "awaitExit should return the recorded exitCode for a pre-exited terminal", + ); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/SetupRunService.ts b/apps/server/src/workflow/Layers/SetupRunService.ts new file mode 100644 index 00000000000..6d8a0313e29 --- /dev/null +++ b/apps/server/src/workflow/Layers/SetupRunService.ts @@ -0,0 +1,195 @@ +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 { threadId: launchedThreadId, terminalId } = yield* terminal.launch({ + worktreePath, + ...(projectId === undefined ? {} : { projectId }), + }); + const exit = + terminalId === null + ? { exitCode: 0 } + : yield* terminal + .awaitExit({ threadId: launchedThreadId, 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 threadId: string; readonly terminalId: string | null; readonly timeoutMs?: number }, +): Effect.Effect<{ readonly exitCode: number }, WorkflowEventStoreError> => { + const { terminalId } = input; + if (terminalId === null) { + return Effect.succeed({ exitCode: 0 }); + } + + return Effect.gen(function* () { + const done = yield* Deferred.make<{ readonly exitCode: number }>(); + // Subscribe FIRST so we don't miss an exit event that races with our check. + const unsubscribe = yield* terminals.subscribe((event) => { + if (event.type !== "exited" || event.terminalId !== terminalId) { + return Effect.void; + } + return Deferred.succeed(done, { exitCode: event.exitCode ?? 1 }).pipe(Effect.asVoid); + }); + // THEN check current status: if the terminal already exited before we + // subscribed, resolve the deferred immediately with its recorded exit code. + const currentSnapshot = yield* terminals.getSnapshot({ + threadId: input.threadId, + terminalId, + }); + if (currentSnapshot !== null && currentSnapshot.status === "exited") { + yield* Deferred.succeed(done, { exitCode: currentSnapshot.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" + ? { threadId: setupInput.threadId, terminalId: null } + : { threadId: setupInput.threadId, 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/TicketPullRequestService.test.ts b/apps/server/src/workflow/Layers/TicketPullRequestService.test.ts new file mode 100644 index 00000000000..47f3d9b875d --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketPullRequestService.test.ts @@ -0,0 +1,408 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { PullRequestStep, StepRunId, TicketId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { GitHubPort, type GitHubPortShape } from "../Services/GitHubPort.ts"; +import { MergeGitPort, type MergeGitResult } from "../Services/TicketMergeService.ts"; +import { + TicketPullRequestService, + type TicketPullRequestInput, +} from "../Services/TicketPullRequestService.ts"; +import { + WorkflowEventCommitter, + type WorkflowEventCommitterShape, +} from "../Services/WorkflowEventCommitter.ts"; +import type { WorkflowEventInput } from "../Services/WorkflowEventStore.ts"; +import { WorkflowIds, type WorkflowIdsShape } from "../Services/WorkflowIds.ts"; +import { + WorkflowReadModel, + type TicketPrStateRow, + type WorkflowReadModelShape, +} from "../Services/WorkflowReadModel.ts"; +import { TicketPullRequestServiceLive } from "./TicketPullRequestService.ts"; + +interface RecordedGitCall { + readonly cwd: string; + readonly args: ReadonlyArray; +} + +interface OpenPrCall { + readonly cwd: string; + readonly branch: string; + readonly base: string; + readonly title: string; + readonly body: string; + readonly draft: boolean; +} + +interface MergePrCall { + readonly cwd: string; + readonly prNumber: number; + readonly strategy: "squash" | "merge" | "rebase"; + readonly deleteBranch: boolean; + readonly branch: string; + readonly remoteName: string; +} + +interface Harness { + readonly gitCalls: Array; + readonly openPrCalls: Array; + readonly mergePrCalls: Array; + readonly committed: Array; + readonly resolveRemoteCalls: { count: number }; + readonly layer: Layer.Layer; +} + +interface HarnessScript { + readonly worktreeStatus?: string; + readonly preflight?: { ok: true } | { ok: false; reason: string }; + readonly defaultBranch?: string; + readonly remote?: { remoteName: string; repo: string }; + readonly openPrResult?: { number: number; url: string; adopted: boolean }; + // When set, openPr fails with a WorkflowEventStoreError carrying this message + // (used to exercise the diverged-push blocked path through a resolved layer). + readonly openPrError?: string; + // land: the stored PR state for the ticket (null → nothing to land). + readonly prState?: TicketPrStateRow | null; + // land: mergePr outcome (defaults to a successful merge). + readonly mergePrResult?: { ok: true } | { ok: false; reason: string }; +} + +const TICKET_ID = "ticket-pr" as TicketId; + +const prInput = (step: Partial = {}): TicketPullRequestInput => ({ + ticketId: TICKET_ID, + stepRunId: "step-run-1" as StepRunId, + repoRoot: "/repo", + worktreePath: "/repo-worktrees/ticket-pr", + worktreeRef: "workflow/ticket-pr", + step: { + key: "open-pr" as never, + type: "pullRequest" as const, + action: "open" as const, + ...step, + }, +}); + +const stubReadModel = (script: HarnessScript) => + Layer.succeed(WorkflowReadModel, { + getTicketDetail: () => + Effect.succeed({ + ticket: { + ticketId: "ticket-pr", + boardId: "board-1", + title: "Fix login", + description: "Make login work again", + currentLaneKey: "open-pr", + currentLaneEntryToken: "token-1", + queuedAt: null, + status: "running", + }, + steps: [], + messages: [], + }), + getTicketPrState: () => Effect.succeed(script.prState ?? null), + } as unknown as WorkflowReadModelShape); + +const stubIds = Layer.succeed(WorkflowIds, { + eventId: () => Effect.succeed("event-1"), +} as unknown as WorkflowIdsShape); + +const makeHarness = (script: HarnessScript): Harness => { + const gitCalls: Array = []; + const openPrCalls: Array = []; + const mergePrCalls: Array = []; + const committed: Array = []; + const resolveRemoteCalls = { count: 0 }; + + const gitHubPort = Layer.succeed(GitHubPort, { + preflight: () => Effect.succeed(script.preflight ?? { ok: true }), + resolveRemote: () => + Effect.sync(() => { + resolveRemoteCalls.count += 1; + return script.remote ?? { remoteName: "origin", repo: "acme/widgets" }; + }), + defaultBranch: () => Effect.succeed(script.defaultBranch ?? "main"), + openPr: (input: OpenPrCall) => + Effect.suspend(() => { + openPrCalls.push({ + cwd: input.cwd, + branch: input.branch, + base: input.base, + title: input.title, + body: input.body, + draft: input.draft, + }); + if (script.openPrError !== undefined) { + return Effect.fail(new WorkflowEventStoreError({ message: script.openPrError })); + } + return Effect.succeed( + script.openPrResult ?? { + number: 42, + url: "https://github.com/acme/widgets/pull/42", + adopted: false, + }, + ); + }), + mergePr: (input: MergePrCall) => + Effect.sync(() => { + mergePrCalls.push({ + cwd: input.cwd, + prNumber: input.prNumber, + strategy: input.strategy, + deleteBranch: input.deleteBranch, + branch: input.branch, + remoteName: input.remoteName, + }); + return script.mergePrResult ?? { ok: true }; + }), + } as unknown as GitHubPortShape); + + const mergeGitPort = Layer.succeed(MergeGitPort, { + run: (input: { cwd: string; args: ReadonlyArray }) => + Effect.sync(() => { + gitCalls.push({ cwd: input.cwd, args: input.args }); + const command = input.args[0]; + if (command === "status") { + return { + exitCode: 0, + stdout: script.worktreeStatus ?? "", + stderr: "", + } satisfies MergeGitResult; + } + return { exitCode: 0, stdout: "", stderr: "" } satisfies MergeGitResult; + }), + } as never); + + const committer = Layer.succeed(WorkflowEventCommitter, { + commit: (event: WorkflowEventInput) => + Effect.sync(() => { + committed.push(event); + }), + commitMany: () => Effect.void, + appendManyUnlocked: () => Effect.succeed([]), + publishTicketView: () => Effect.void, + } as WorkflowEventCommitterShape); + + const layer = TicketPullRequestServiceLive.pipe( + Layer.provideMerge(gitHubPort), + Layer.provideMerge(mergeGitPort), + Layer.provideMerge(committer), + Layer.provideMerge(stubReadModel(script)), + Layer.provideMerge(stubIds), + ); + + return { gitCalls, openPrCalls, mergePrCalls, committed, resolveRemoteCalls, layer }; +}; + +const runOpen = (harness: Harness, input: TicketPullRequestInput) => + Effect.gen(function* () { + const service = yield* TicketPullRequestService; + return yield* service.open(input); + }).pipe(Effect.provide(harness.layer)); + +const runLand = (harness: Harness, input: TicketPullRequestInput) => + Effect.gen(function* () { + const service = yield* TicketPullRequestService; + return yield* service.land(input); + }).pipe(Effect.provide(harness.layer)); + +const prStateRow = (overrides: Partial = {}): TicketPrStateRow => ({ + prNumber: 42, + prUrl: "https://github.com/acme/widgets/pull/42", + branch: "workflow/ticket-pr", + remoteName: "origin", + repo: "acme/widgets", + prState: "open", + lastHeadSha: null, + lastCiState: null, + lastReviewDecision: null, + lastCommentCursor: null, + ...overrides, +}); + +describe("TicketPullRequestService open", () => { + it.effect("snapshots a dirty worktree before opening the PR", () => + Effect.gen(function* () { + const harness = makeHarness({ worktreeStatus: " M src/app.ts\n" }); + const outcome = yield* runOpen(harness, prInput()); + + assert.equal(outcome._tag, "completed"); + const statusIndex = harness.gitCalls.findIndex((c) => c.args[0] === "status"); + const addCall = harness.gitCalls.find((c) => c.args[0] === "add"); + const commitCall = harness.gitCalls.find((c) => c.args[0] === "commit"); + assert.ok(statusIndex >= 0); + assert.deepEqual(addCall?.args, ["add", "-A"]); + assert.equal(commitCall?.cwd, "/repo-worktrees/ticket-pr"); + assert.deepEqual(commitCall?.args, ["commit", "--no-verify", "-m", "Fix login (ticket-pr)"]); + }), + ); + + it.effect("opens a PR with defaults and commits TicketPrOpened", () => + Effect.gen(function* () { + const harness = makeHarness({}); + const outcome = yield* runOpen(harness, prInput()); + + assert.deepEqual(outcome, { + _tag: "completed", + output: { prNumber: 42, url: "https://github.com/acme/widgets/pull/42" }, + }); + + const call = harness.openPrCalls[0]; + assert.equal(call?.branch, "workflow/ticket-pr"); + assert.equal(call?.base, "main"); + assert.equal(call?.title, "Fix login"); + assert.equal(call?.draft, false); + assert.ok(call?.body.endsWith("t3-ticket: ticket-pr")); + + assert.equal(harness.committed.length, 1); + const event = harness.committed[0]; + assert.equal(event?.type, "TicketPrOpened"); + assert.deepEqual((event as { payload: unknown }).payload, { + stepRunId: "step-run-1", + prNumber: 42, + url: "https://github.com/acme/widgets/pull/42", + branch: "workflow/ticket-pr", + remoteName: "origin", + repo: "acme/widgets", + }); + }), + ); + + it.effect("honors explicit base, templates, and draft", () => + Effect.gen(function* () { + const harness = makeHarness({}); + const outcome = yield* runOpen( + harness, + prInput({ + base: "develop" as never, + draft: true, + titleTemplate: "{{ticket.title}} PR" as never, + bodyTemplate: "Body." as never, + }), + ); + + assert.equal(outcome._tag, "completed"); + const call = harness.openPrCalls[0]; + assert.equal(call?.base, "develop"); + assert.equal(call?.title, "Fix login PR"); + assert.equal(call?.body, "Body.\n\nt3-ticket: ticket-pr"); + assert.equal(call?.draft, true); + }), + ); + + it.effect("commits TicketPrOpened when an existing PR is adopted", () => + Effect.gen(function* () { + const harness = makeHarness({ + openPrResult: { number: 7, url: "https://github.com/acme/widgets/pull/7", adopted: true }, + }); + const outcome = yield* runOpen(harness, prInput()); + + assert.deepEqual(outcome, { + _tag: "completed", + output: { prNumber: 7, url: "https://github.com/acme/widgets/pull/7" }, + }); + assert.equal(harness.committed.length, 1); + assert.equal(harness.committed[0]?.type, "TicketPrOpened"); + }), + ); + + it.effect("blocks and does nothing when preflight fails", () => + Effect.gen(function* () { + const harness = makeHarness({ + preflight: { ok: false, reason: "gh not authenticated; run gh auth login" }, + }); + const outcome = yield* runOpen(harness, prInput()); + + assert.deepEqual(outcome, { + _tag: "blocked", + reason: "gh not authenticated; run gh auth login", + }); + assert.equal(harness.openPrCalls.length, 0); + assert.equal(harness.committed.length, 0); + assert.equal(harness.resolveRemoteCalls.count, 0); + assert.ok(harness.gitCalls.every((c) => c.args[0] !== "commit")); + }), + ); + + it.effect("blocks on a diverged push and commits nothing", () => + Effect.gen(function* () { + const harness = makeHarness({ + openPrError: "branch diverged: remote push rejected", + }); + const outcome = yield* runOpen(harness, prInput()); + + assert.equal(outcome._tag, "blocked"); + assert.ok(outcome._tag === "blocked" && outcome.reason.startsWith("branch diverged")); + assert.equal(harness.openPrCalls.length, 1); + assert.equal(harness.committed.length, 0); + // resolveRemote runs only after the push guard clears. + assert.equal(harness.resolveRemoteCalls.count, 0); + }), + ); +}); + +describe("TicketPullRequestService land", () => { + const landInput = (step: Partial = {}) => + prInput({ action: "land" as const, ...step }); + + it.effect("blocks and never merges when there is no recorded PR state", () => + Effect.gen(function* () { + const harness = makeHarness({ prState: null }); + const outcome = yield* runLand(harness, landInput()); + + assert.deepEqual(outcome, { _tag: "blocked", reason: "no PR to land" }); + assert.equal(harness.mergePrCalls.length, 0); + }), + ); + + it.effect("merges with default squash + deleteBranch and completes", () => + Effect.gen(function* () { + const harness = makeHarness({ prState: prStateRow() }); + const outcome = yield* runLand(harness, landInput()); + + assert.deepEqual(outcome, { _tag: "completed" }); + assert.equal(harness.mergePrCalls.length, 1); + const call = harness.mergePrCalls[0]; + assert.equal(call?.cwd, "/repo-worktrees/ticket-pr"); + assert.equal(call?.prNumber, 42); + assert.equal(call?.strategy, "squash"); + assert.equal(call?.deleteBranch, true); + assert.equal(call?.branch, "workflow/ticket-pr"); + assert.equal(call?.remoteName, "origin"); + }), + ); + + it.effect("threads through an explicit strategy and deleteBranch:false", () => + Effect.gen(function* () { + const harness = makeHarness({ prState: prStateRow() }); + const outcome = yield* runLand( + harness, + landInput({ strategy: "rebase", deleteBranch: false }), + ); + + assert.deepEqual(outcome, { _tag: "completed" }); + const call = harness.mergePrCalls[0]; + assert.equal(call?.strategy, "rebase"); + assert.equal(call?.deleteBranch, false); + }), + ); + + it.effect("blocks with the gh reason when the PR is not mergeable", () => + Effect.gen(function* () { + const harness = makeHarness({ + prState: prStateRow(), + mergePrResult: { ok: false, reason: "branch protection: review required" }, + }); + const outcome = yield* runLand(harness, landInput()); + + assert.deepEqual(outcome, { + _tag: "blocked", + reason: "branch protection: review required", + }); + assert.equal(harness.mergePrCalls.length, 1); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/TicketPullRequestService.ts b/apps/server/src/workflow/Layers/TicketPullRequestService.ts new file mode 100644 index 00000000000..34268d597e2 --- /dev/null +++ b/apps/server/src/workflow/Layers/TicketPullRequestService.ts @@ -0,0 +1,166 @@ +import type { StepOutcome } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { applyInstructionTemplate, type TicketTemplateVars } from "../instructionTemplate.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { GitHubPort } from "../Services/GitHubPort.ts"; +import { MergeGitPort } from "../Services/TicketMergeService.ts"; +import { + TicketPullRequestService, + type TicketPullRequestInput, + type TicketPullRequestServiceShape, +} from "../Services/TicketPullRequestService.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import type { WorkflowEventInput } from "../Services/WorkflowEventStore.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; + +const blocked = (reason: string): StepOutcome => ({ _tag: "blocked", reason }); + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +const make = Effect.gen(function* () { + const github = yield* GitHubPort; + const git = yield* MergeGitPort; + const read = yield* WorkflowReadModel; + const committer = yield* WorkflowEventCommitter; + const ids = yield* WorkflowIds; + + const open: TicketPullRequestServiceShape["open"] = (input) => + Effect.gen(function* () { + // 1. Preflight: a missing/unauthenticated gh is human-fixable, so block + // (nothing has been pushed) rather than fail. + const preflight = yield* github.preflight(input.worktreePath); + if (!preflight.ok) { + return blocked(preflight.reason); + } + + // Ticket detail backs the template vars and the snapshot-commit message. + const detail = yield* read.getTicketDetail(input.ticketId); + const ticketTitle = detail?.ticket.title ?? "workflow ticket"; + const vars: TicketTemplateVars = { + title: ticketTitle, + description: detail?.ticket.description ?? "", + id: input.ticketId as string, + baseRef: input.step.base ?? "", + discussion: "", + }; + + // 2. Snapshot any uncommitted agent work onto the ticket branch so the + // PR carries the full accumulated state (mirrors TicketMergeService). + const snapshotMessage = + input.step.titleTemplate !== undefined + ? applyInstructionTemplate(input.step.titleTemplate, vars) + : `${ticketTitle} (${input.ticketId})`; + 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", snapshotMessage], + }); + } + + // 3. Resolve the base branch. + const base = + input.step.base ?? (yield* github.defaultBranch(input.worktreePath)); + + // 4. Render the PR title and body, always appending the ticket trailer. + const title = + input.step.titleTemplate !== undefined + ? applyInstructionTemplate(input.step.titleTemplate, vars) + : ticketTitle; + const renderedBody = + input.step.bodyTemplate !== undefined + ? applyInstructionTemplate(input.step.bodyTemplate, vars) + : ""; + const body = `${renderedBody}${renderedBody ? "\n\n" : ""}t3-ticket: ${input.ticketId}`; + + // 5. Push + open (or adopt) the PR. A diverged remote is human-fixable; + // map it to blocked. Other failures are real infra faults — let them + // propagate on the error channel. + const result = yield* github + .openPr({ + cwd: input.worktreePath, + branch: input.worktreeRef, + base, + title, + body, + draft: input.step.draft ?? false, + }) + .pipe( + Effect.catchIf( + (error) => error.message.startsWith("branch diverged"), + (error) => Effect.succeed({ _blocked: error.message } as const), + ), + ); + if ("_blocked" in result) { + return blocked(result._blocked); + } + + // 6. Resolve the remote for the TicketPrOpened projection. Done only + // after the push guard so a resolveRemote fault can't pre-empt a block. + const remote = yield* github.resolveRemote(input.worktreePath); + + // 7. Emit TicketPrOpened. The projection upsert is idempotent, so this is + // safe whether the PR was freshly created or adopted. + const eventId = yield* ids.eventId(); + yield* committer.commit({ + type: "TicketPrOpened", + eventId, + ticketId: input.ticketId, + occurredAt: yield* nowIso, + payload: { + stepRunId: input.stepRunId, + prNumber: result.number, + url: result.url, + branch: input.worktreeRef, + remoteName: remote.remoteName, + repo: remote.repo, + }, + } as WorkflowEventInput); + + // 8. Completed. + return { + _tag: "completed", + output: { prNumber: result.number, url: result.url }, + }; + }); + + const land: TicketPullRequestServiceShape["land"] = (input) => + Effect.gen(function* () { + // 1. The PR to land is whichever one `open` recorded for this ticket. No + // recorded state means there is nothing to merge — block (human-fixable) + // rather than fail. + const state = yield* read.getTicketPrState(input.ticketId); + if (state === null) { + return blocked("no PR to land"); + } + + // 2. Merge through gh. Branch cleanup (deleteBranch) is best-effort inside + // the port; a not-mergeable PR (branch protection, failing checks, review + // required) is human-fixable, so map it to blocked. Real infra faults + // propagate on the error channel. + const result = yield* github.mergePr({ + cwd: input.worktreePath, + prNumber: state.prNumber, + strategy: input.step.strategy ?? "squash", + deleteBranch: input.step.deleteBranch ?? true, + branch: state.branch, + remoteName: state.remoteName, + }); + if (!result.ok) { + return blocked(result.reason); + } + return { _tag: "completed" }; + }); + + return { open, land } satisfies TicketPullRequestServiceShape; +}); + +export const TicketPullRequestServiceLive = Layer.effect(TicketPullRequestService, make); 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..1a0ad7b80af --- /dev/null +++ b/apps/server/src/workflow/Layers/TurnStateReader.ts @@ -0,0 +1,160 @@ +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%' + AND detail NOT LIKE '%unknown pending user input request%' + AND detail NOT LIKE '%unknown pending codex 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/WorkSourceConnectionStore.test.ts b/apps/server/src/workflow/Layers/WorkSourceConnectionStore.test.ts new file mode 100644 index 00000000000..916770ca200 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkSourceConnectionStore.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { MigrationsLive } from "../../persistence/Migrations.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import { WorkSourceConnectionStore } from "../Services/WorkSourceConnectionStore.ts"; +import { WorkSourceConnectionStoreLive } from "./WorkSourceConnectionStore.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; + +// --------------------------------------------------------------------------- +// Stub ServerSecretStore backed by an in-memory Map +// --------------------------------------------------------------------------- +const makeInMemorySecretStore = () => { + const store = new Map(); + const layer = Layer.succeed(ServerSecretStore.ServerSecretStore, { + get: (name) => Effect.succeed(store.get(name) ?? null), + set: (name, value) => + Effect.sync(() => { + store.set(name, value); + }), + create: (name, value) => + Effect.sync(() => { + store.set(name, value); + }), + getOrCreateRandom: (_name, _bytes) => Effect.die("not needed in test"), + remove: (name) => + Effect.sync(() => { + store.delete(name); + }), + } satisfies ServerSecretStore.ServerSecretStoreShape); + return { layer, store }; +}; + +// --------------------------------------------------------------------------- +// Test layer +// --------------------------------------------------------------------------- +const buildTestLayer = () => { + const { layer: secretStoreLayer, store: secretStore } = makeInMemorySecretStore(); + + const layer = WorkSourceConnectionStoreLive.pipe( + Layer.provide(DeterministicWorkflowIds), + Layer.provide(secretStoreLayer), + Layer.provide(MigrationsLive), + Layer.provide(SqlitePersistenceMemory), + ); + return { layer, secretStore }; +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe("WorkSourceConnectionStore", () => { + it.effect("create inserts a row and stores the token in the secret store", () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + + const view = yield* store.create({ + provider: "github", + displayName: "My GitHub", + token: "ghp_test1234", + }); + + expect(view.provider).toBe("github"); + expect(view.displayName).toBe("My GitHub"); + expect(typeof view.connectionRef).toBe("string"); + expect(view.connectionRef.length).toBeGreaterThan(0); + + // Token must be retrievable + const token = yield* store.getToken(view.connectionRef, "github"); + expect(token).toBe("ghp_test1234"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("getToken fails with WorkSourceAuthError when expectedProvider does not match the row", () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + const view = yield* store.create({ + provider: "github", + displayName: "GH conn", + token: "ghp_bound", + }); + + // Wrong provider for this connectionRef → must NOT return the github token. + const error = yield* store.getToken(view.connectionRef, "asana").pipe(Effect.flip); + expect((error as { _tag: string })._tag).toBe("WorkSourceAuthError"); + + // Correct provider → token returned. + const token = yield* store.getToken(view.connectionRef, "github"); + expect(token).toBe("ghp_bound"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("list returns all views without the token", () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + + yield* store.create({ provider: "github", displayName: "GH 1", token: "tok-1" }); + yield* store.create({ provider: "asana", displayName: "Asana 1", token: "tok-2" }); + + const connections = yield* store.list(); + expect(connections).toHaveLength(2); + expect(connections.map((c) => c.provider).sort()).toEqual(["asana", "github"]); + // No token field in view + for (const conn of connections) { + expect((conn as Record)["token"]).toBeUndefined(); + } + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("getToken returns the stored PAT as a string", () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + const view = yield* store.create({ + provider: "asana", + displayName: "Asana connection", + token: "asana-pat-abc", + }); + + const token = yield* store.getToken(view.connectionRef, "asana"); + expect(token).toBe("asana-pat-abc"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("getToken fails with WorkSourceAuthError for unknown connectionRef", () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + const result = yield* Effect.exit(store.getToken("nonexistent-ref", "github")); + expect(result._tag).toBe("Failure"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); + + it.effect("getToken fails gracefully when the row exists but its secret is missing", () => { + // INSERT-before-secret create ordering can leave a row whose secret was + // never stored (or was removed out of band). getToken must degrade to a + // typed WorkSourceAuthError rather than crashing. + const { layer, secretStore } = buildTestLayer(); + return Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + const view = yield* store.create({ + provider: "github", + displayName: "Orphaned secret", + token: "soon-to-vanish", + }); + + // Simulate the row-exists-but-secret-missing state by deleting the secret + // directly from the backing map (the row remains in SQLite). + secretStore.delete(`work-source-token:${view.connectionRef}`); + + // getToken fails in the typed error channel (not a defect) with WorkSourceAuthError. + const error = yield* store.getToken(view.connectionRef, "github").pipe(Effect.flip); + expect((error as { _tag: string })._tag).toBe("WorkSourceAuthError"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("remove deletes the row and the secret", () => + Effect.gen(function* () { + const store = yield* WorkSourceConnectionStore; + + const view = yield* store.create({ + provider: "github", + displayName: "To be deleted", + token: "delete-me-token", + }); + + // Exists before remove + const before = yield* store.list(); + expect(before.some((c) => c.connectionRef === view.connectionRef)).toBe(true); + + yield* store.remove(view.connectionRef); + + // Gone after remove + const after = yield* store.list(); + expect(after.some((c) => c.connectionRef === view.connectionRef)).toBe(false); + + // Token is also gone + const tokenResult = yield* Effect.exit(store.getToken(view.connectionRef, "github")); + expect(tokenResult._tag).toBe("Failure"); + }).pipe(Effect.provide(buildTestLayer().layer)), + ); +}); + +// Suppress unused import warning for FileSystem / Path (used indirectly via SqlitePersistenceMemory) +void FileSystem; +void Path; diff --git a/apps/server/src/workflow/Layers/WorkSourceConnectionStore.ts b/apps/server/src/workflow/Layers/WorkSourceConnectionStore.ts new file mode 100644 index 00000000000..1d458f9b222 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkSourceConnectionStore.ts @@ -0,0 +1,191 @@ +/** + * WorkSourceConnectionStore — Layer implementation. + * + * Persists connection metadata to the `work_source_connection` SQLite table + * and stores the PAT bytes in `ServerSecretStore` under + * `work-source-token:`. + * + * getToken: SELECT row → secrets.get(token_secret_name) → TextDecoder. + * Missing row or missing secret → WorkSourceAuthError. + * + * create: generate connectionRef via WorkflowIds.eventId() (produces a + * prefixed uuid, e.g. "evt-"), derive token_secret_name, store + * secret bytes, INSERT row, return view (no token). + * + * list: SELECT all rows, map to WorkSourceConnectionView (no token). + * + * remove: secrets.remove(token_secret_name) + DELETE row. + * v1 does NOT check for boards still referencing the connectionRef — + * a dangling ref will cause WorkSourceAuthError at sync time, which + * the syncer handles gracefully (exponential backoff per source). + */ +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 { WorkSourceConnectionView } from "@t3tools/contracts/workSource"; +import type { WorkSourceProviderName } from "@t3tools/contracts/workSource"; +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkSourceAuthError } from "../Services/WorkSourceProvider.ts"; +import { + WorkSourceConnectionStore, + WorkSourceConnectionStoreError, + type WorkSourceConnectionStoreShape, +} from "../Services/WorkSourceConnectionStore.ts"; + +interface ConnectionRow { + readonly connection_ref: string; + readonly provider: string; + readonly display_name: string; + readonly auth_mode: string; + readonly token_secret_name: string; + readonly created_at: string; +} + +const toWorkSourceConnectionStoreError = (message: string) => (cause: unknown) => + new WorkSourceConnectionStoreError({ message, cause }); + +const toView = (row: ConnectionRow): WorkSourceConnectionView => ({ + connectionRef: row.connection_ref as never, + provider: row.provider as WorkSourceProviderName, + displayName: row.display_name as never, +}); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const ids = yield* WorkflowIds; + + const getToken: WorkSourceConnectionStoreShape["getToken"] = (connectionRef, expectedProvider) => + Effect.gen(function* () { + // Provider-bound: only return a token when BOTH the ref AND the provider + // match. A row found under a different provider must not satisfy the + // request (else a source could use the wrong provider's credential). + const rows = yield* sql` + SELECT connection_ref, provider, display_name, auth_mode, token_secret_name, created_at + FROM work_source_connection + WHERE connection_ref = ${connectionRef} AND provider = ${expectedProvider} + `.pipe( + Effect.mapError( + (cause) => new WorkSourceAuthError({ connectionRef, cause } as never), + ), + ); + + const row = rows[0]; + if (row === undefined) { + return yield* new WorkSourceAuthError({ connectionRef }); + } + + const bytes = yield* secretStore + .get(row.token_secret_name) + .pipe(Effect.mapError((cause) => new WorkSourceAuthError({ connectionRef, cause } as never))); + + if (bytes === null) { + return yield* new WorkSourceAuthError({ connectionRef }); + } + + return new TextDecoder().decode(bytes); + }).pipe(Effect.withSpan("WorkSourceConnectionStore.getToken")); + + const create: WorkSourceConnectionStoreShape["create"] = (input) => + Effect.gen(function* () { + const connectionRef = yield* ids.eventId().pipe( + Effect.map((id) => `conn-${id}`), + ); + const tokenSecretName = `work-source-token:${connectionRef}`; + const now = yield* DateTime.now; + const createdAt = DateTime.formatIso(now); + + // INSERT the row BEFORE storing the secret. If the INSERT fails we leave + // no orphaned, unreachable secret behind. The reverse failure mode (row + // exists, secret missing) is graceful: getToken fails with + // WorkSourceAuthError and remove can still clean up the row. + yield* sql` + INSERT INTO work_source_connection ( + connection_ref, + provider, + display_name, + auth_mode, + token_secret_name, + created_at + ) VALUES ( + ${connectionRef}, + ${input.provider}, + ${input.displayName}, + ${"pat"}, + ${tokenSecretName}, + ${createdAt} + ) + `.pipe( + Effect.mapError( + toWorkSourceConnectionStoreError("Failed to insert work source connection"), + ), + ); + + yield* secretStore + .set(tokenSecretName, new TextEncoder().encode(input.token)) + .pipe( + Effect.mapError( + toWorkSourceConnectionStoreError("Failed to store connection token"), + ), + ); + + return { + connectionRef: connectionRef as never, + provider: input.provider, + displayName: input.displayName as never, + } satisfies WorkSourceConnectionView; + }).pipe(Effect.withSpan("WorkSourceConnectionStore.create")); + + const list: WorkSourceConnectionStoreShape["list"] = () => + sql` + SELECT connection_ref, provider, display_name, auth_mode, token_secret_name, created_at + FROM work_source_connection + ORDER BY created_at ASC + `.pipe( + Effect.map((rows) => rows.map(toView)), + Effect.mapError( + toWorkSourceConnectionStoreError("Failed to list work source connections"), + ), + Effect.withSpan("WorkSourceConnectionStore.list"), + ); + + const remove: WorkSourceConnectionStoreShape["remove"] = (connectionRef) => + Effect.gen(function* () { + const rows = yield* sql<{ readonly token_secret_name: string }>` + SELECT token_secret_name FROM work_source_connection WHERE connection_ref = ${connectionRef} + `.pipe( + Effect.mapError( + toWorkSourceConnectionStoreError("Failed to look up connection for removal"), + ), + ); + + const row = rows[0]; + if (row !== undefined) { + yield* secretStore.remove(row.token_secret_name).pipe( + Effect.mapError( + toWorkSourceConnectionStoreError("Failed to remove connection token secret"), + ), + ); + } + + yield* sql` + DELETE FROM work_source_connection WHERE connection_ref = ${connectionRef} + `.pipe( + Effect.mapError( + toWorkSourceConnectionStoreError("Failed to delete work source connection row"), + ), + ); + }).pipe(Effect.withSpan("WorkSourceConnectionStore.remove")); + + return { + getToken, + create, + list, + remove, + } satisfies WorkSourceConnectionStoreShape; +}); + +export const WorkSourceConnectionStoreLive = Layer.effect(WorkSourceConnectionStore, make); diff --git a/apps/server/src/workflow/Layers/WorkSourceProviderRegistry.test.ts b/apps/server/src/workflow/Layers/WorkSourceProviderRegistry.test.ts new file mode 100644 index 00000000000..7a042217237 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkSourceProviderRegistry.test.ts @@ -0,0 +1,46 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { + AsanaProvider, + GithubIssuesProvider, + WorkSourceProviderRegistry, + type WorkSourceProvider, +} from "../Services/WorkSourceProvider.ts"; +import { WorkSourceProviderRegistryLive } from "./WorkSourceProviderRegistry.ts"; + +const makeStub = (name: "github" | "asana"): WorkSourceProvider => ({ + provider: name, + selectorSchema: Schema.Unknown, + listPage: () => Effect.succeed({ items: [] }), + getItem: () => Effect.succeed(null), +}); + +const githubStubLayer = Layer.succeed(GithubIssuesProvider, makeStub("github")); +const asanaStubLayer = Layer.succeed(AsanaProvider, makeStub("asana")); + +const testLayer = WorkSourceProviderRegistryLive.pipe( + Layer.provide(Layer.merge(githubStubLayer, asanaStubLayer)), +); + +const layer = it.layer(testLayer); + +layer("WorkSourceProviderRegistry", (it) => { + it.effect("get('github') returns the github provider", () => + Effect.gen(function* () { + const registry = yield* WorkSourceProviderRegistry; + const provider = registry.get("github"); + assert.equal(provider.provider, "github"); + }), + ); + + it.effect("get('asana') returns the asana provider", () => + Effect.gen(function* () { + const registry = yield* WorkSourceProviderRegistry; + const provider = registry.get("asana"); + assert.equal(provider.provider, "asana"); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkSourceProviderRegistry.ts b/apps/server/src/workflow/Layers/WorkSourceProviderRegistry.ts new file mode 100644 index 00000000000..a47021b3be2 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkSourceProviderRegistry.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { + AsanaProvider, + GithubIssuesProvider, + WorkSourceProviderRegistry, + type WorkSourceProviderRegistryShape, +} from "../Services/WorkSourceProvider.ts"; + +const make = Effect.gen(function* () { + const github = yield* GithubIssuesProvider; + const asana = yield* AsanaProvider; + + return { + get: (provider: "github" | "asana") => { + if (provider === "github") return github; + return asana; + }, + } satisfies WorkSourceProviderRegistryShape; +}); + +export const WorkSourceProviderRegistryLive = Layer.effect(WorkSourceProviderRegistry, make); 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..ba95213728a --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardEvents.test.ts @@ -0,0 +1,66 @@ +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"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; + +const layer = it.layer( + WorkflowEventCommitterLive.pipe( + Layer.provideMerge(WorkflowBoardEventsLive), + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + 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/WorkflowBoardNotificationDispatcher.test.ts b/apps/server/src/workflow/Layers/WorkflowBoardNotificationDispatcher.test.ts new file mode 100644 index 00000000000..a07938be886 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardNotificationDispatcher.test.ts @@ -0,0 +1,379 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayBoardTicketState } from "@t3tools/contracts/relay"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowBoardNotificationDispatcher } from "../Services/WorkflowBoardNotificationDispatcher.ts"; +import { WorkflowBoardNotificationRelay } from "../Services/WorkflowBoardNotificationRelay.ts"; +import { + WorkflowReadModel, + type TicketDetail, + type TicketRow, +} from "../Services/WorkflowReadModel.ts"; +import { makeWorkflowBoardNotificationDispatcherLive } from "./WorkflowBoardNotificationDispatcher.ts"; + +const ENV_ID = "env-1" as EnvironmentId; + +interface PublishCall { + readonly environmentId: EnvironmentId; + readonly boardId: string; + readonly ticketId: string; + readonly state: RelayBoardTicketState; +} + +// Mutable per-test recorder for the stub relay. Reset in each test setup. +interface RelayRecorder { + calls: Array; + failQueue: Array<"ok" | "fail">; +} + +const makeRecorder = (failQueue: ReadonlyArray<"ok" | "fail"> = []): RelayRecorder => ({ + calls: [], + failQueue: [...failQueue], +}); + +const stubRelayLayer = (recorder: RelayRecorder) => + Layer.succeed(WorkflowBoardNotificationRelay, { + publishTicket: (input) => + Effect.suspend(() => { + recorder.calls.push(input); + const outcome = recorder.failQueue.length === 0 ? "ok" : recorder.failQueue.shift()!; + return outcome === "fail" + ? Effect.fail(new WorkflowEventStoreError({ message: "stub relay failure" })) + : Effect.void; + }), + } satisfies WorkflowBoardNotificationRelay["Service"]); + +const makeTicketRow = (over: Partial = {}): TicketRow => ({ + ticketId: "ticket-1", + boardId: "board-1", + title: "Fix the thing", + description: null, + currentLaneKey: "review", + currentLaneEntryToken: null, + status: "waiting_on_user", + queuedAt: null, + totalTokens: null, + totalDurationMs: null, + attentionKind: "waiting_for_input", + attentionReason: "please review", + ...over, +}); + +const detail = (ticket: TicketRow): TicketDetail => ({ ticket, steps: [], messages: [] }); + +// Stub read model: only getTicketDetail is exercised by the dispatcher. +const stubReadModelLayer = (byTicket: Record) => + Layer.succeed(WorkflowReadModel, { + getTicketDetail: (ticketId: string) => Effect.succeed(byTicket[ticketId] ?? null), + } as unknown as WorkflowReadModel["Service"]) as Layer.Layer; + +const serverEnvironmentLayer = Layer.succeed(ServerEnvironment, { + getEnvironmentId: Effect.succeed(ENV_ID), + getDescriptor: Effect.die("unsupported descriptor read"), +} as unknown as ServerEnvironment["Service"]) as Layer.Layer; + +const insertOutboxRow = (over: { + readonly outboxId: string; + readonly ticketId: string; + readonly boardId: string; + readonly sequence: number; + readonly status: string; + readonly attentionKind?: string | null; + readonly attentionReason?: string | null; + readonly deliveryState?: string; + readonly attemptCount?: number; + readonly createdAt?: string; +}) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO workflow_notification_outbox ( + outbox_id, ticket_id, board_id, sequence, status, + attention_kind, attention_reason, delivery_state, attempt_count, created_at + ) VALUES ( + ${over.outboxId}, ${over.ticketId}, ${over.boardId}, ${over.sequence}, ${over.status}, + ${over.attentionKind ?? null}, ${over.attentionReason ?? null}, + ${over.deliveryState ?? "pending"}, ${over.attemptCount ?? 0}, + ${over.createdAt ?? "2026-06-12T00:00:00.000Z"} + ) + `; + }); + +const readOutbox = (outboxId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ + readonly delivery_state: string; + readonly attempt_count: number; + }>` + SELECT delivery_state AS "delivery_state", attempt_count AS "attempt_count" + FROM workflow_notification_outbox WHERE outbox_id = ${outboxId} + `; + return rows[0]!; + }); + +const buildLayer = ( + recorder: RelayRecorder, + byTicket: Record, +) => + makeWorkflowBoardNotificationDispatcherLive({ sweepIntervalMs: 60_000 }).pipe( + Layer.provideMerge(stubRelayLayer(recorder)), + Layer.provideMerge(stubReadModelLayer(byTicket)), + Layer.provideMerge(serverEnvironmentLayer), + Layer.provideMerge(SqlitePersistenceMemory), + ); + +describe.sequential("WorkflowBoardNotificationDispatcher", () => { + it.effect("publishes a pending needs-you row and marks it sent", () => { + const recorder = makeRecorder(); + return Effect.gen(function* () { + yield* insertOutboxRow({ + outboxId: "ob-1", + ticketId: "ticket-1", + boardId: "board-1", + sequence: 7, + status: "waiting_on_user", + attentionKind: "waiting_for_input", + attentionReason: "please review", + }); + const dispatcher = yield* WorkflowBoardNotificationDispatcher; + const result = yield* dispatcher.sweep(); + + assert.strictEqual(result.claimed, 1); + assert.strictEqual(result.sent, 1); + assert.strictEqual(result.superseded, 0); + assert.strictEqual(result.failed, 0); + assert.strictEqual(recorder.calls.length, 1); + const call = recorder.calls[0]!; + assert.strictEqual(call.boardId, "board-1"); + assert.strictEqual(call.ticketId, "ticket-1"); + assert.strictEqual(call.state.attentionKind, "waiting_for_input"); + assert.strictEqual(call.state.title, "Fix the thing"); + assert.strictEqual(call.state.body, "please review"); + assert.strictEqual(call.state.deepLink, "/tickets/env-1/board-1/ticket-1"); + assert.strictEqual(call.state.transitionId, "7"); + + const row = yield* readOutbox("ob-1"); + assert.strictEqual(row.delivery_state, "sent"); + }).pipe( + Effect.provide( + buildLayer(recorder, { + "ticket-1": detail(makeTicketRow({ status: "waiting_on_user" })), + }), + ), + ); + }); + + it.effect("supersedes a row whose ticket has left needs-you", () => { + const recorder = makeRecorder(); + return Effect.gen(function* () { + yield* insertOutboxRow({ + outboxId: "ob-2", + ticketId: "ticket-2", + boardId: "board-1", + sequence: 8, + status: "waiting_on_user", + attentionKind: "waiting_for_input", + attentionReason: "stale", + }); + yield* insertOutboxRow({ + outboxId: "ob-3", + ticketId: "ticket-3", + boardId: "board-1", + sequence: 9, + status: "waiting_on_user", + attentionKind: "waiting_for_input", + attentionReason: "gone", + }); + const dispatcher = yield* WorkflowBoardNotificationDispatcher; + const result = yield* dispatcher.sweep(); + + assert.strictEqual(result.superseded, 2); + assert.strictEqual(result.sent, 0); + assert.strictEqual(recorder.calls.length, 0); + assert.strictEqual((yield* readOutbox("ob-2")).delivery_state, "superseded"); + assert.strictEqual((yield* readOutbox("ob-3")).delivery_state, "superseded"); + }).pipe( + Effect.provide( + buildLayer(recorder, { + // ticket-2 left needs-you (now running); ticket-3 detail missing (null). + "ticket-2": detail( + makeTicketRow({ ticketId: "ticket-2", status: "running" }), + ), + "ticket-3": null, + }), + ), + ); + }); + + it.effect("retries on relay failure then gives up at the attempt ceiling", () => { + // Five consecutive failures across five sweeps → row ends 'failed'. + const recorder = makeRecorder(["fail", "fail", "fail", "fail", "fail"]); + return Effect.gen(function* () { + yield* insertOutboxRow({ + outboxId: "ob-4", + ticketId: "ticket-4", + boardId: "board-1", + sequence: 10, + status: "blocked", + attentionKind: "blocked", + attentionReason: "needs help", + }); + const dispatcher = yield* WorkflowBoardNotificationDispatcher; + + // Sweeps 1-4: stays pending, attempt_count climbs. + for (let i = 1; i <= 4; i++) { + const r = yield* dispatcher.sweep(); + assert.strictEqual(r.failed, 1, `sweep ${i} failed count`); + const row = yield* readOutbox("ob-4"); + assert.strictEqual(row.delivery_state, "pending", `sweep ${i} state`); + assert.strictEqual(row.attempt_count, i, `sweep ${i} attempts`); + } + + // Sweep 5: 5th attempt hits the ceiling → 'failed'. + const r5 = yield* dispatcher.sweep(); + assert.strictEqual(r5.failed, 1); + const after = yield* readOutbox("ob-4"); + assert.strictEqual(after.delivery_state, "failed"); + assert.strictEqual(after.attempt_count, 5); + assert.strictEqual(recorder.calls.length, 5); + + // Sweep 6: failed rows are not re-selected → no new publish. + const r6 = yield* dispatcher.sweep(); + assert.strictEqual(r6.claimed, 0); + assert.strictEqual(recorder.calls.length, 5); + }).pipe( + Effect.provide( + buildLayer(recorder, { + "ticket-4": detail( + makeTicketRow({ + ticketId: "ticket-4", + status: "blocked", + attentionKind: "blocked", + attentionReason: "needs help", + }), + ), + }), + ), + ); + }); + + it.effect("drains a pre-existing pending row (startup drain)", () => { + const recorder = makeRecorder(); + return Effect.gen(function* () { + yield* insertOutboxRow({ + outboxId: "ob-5", + ticketId: "ticket-5", + boardId: "board-1", + sequence: 11, + status: "waiting_on_user", + attentionKind: "waiting_for_approval", + attentionReason: "approve me", + }); + const dispatcher = yield* WorkflowBoardNotificationDispatcher; + const result = yield* dispatcher.sweep(); + assert.strictEqual(result.sent, 1); + assert.strictEqual(recorder.calls[0]!.state.attentionKind, "waiting_for_approval"); + assert.strictEqual((yield* readOutbox("ob-5")).delivery_state, "sent"); + }).pipe( + Effect.provide( + buildLayer(recorder, { + "ticket-5": detail( + makeTicketRow({ + ticketId: "ticket-5", + status: "waiting_on_user", + attentionKind: "waiting_for_approval", + attentionReason: "approve me", + }), + ), + }), + ), + ); + }); + + it.effect("falls back to a non-empty title when the ticket title is blank", () => { + const recorder = makeRecorder(); + return Effect.gen(function* () { + yield* insertOutboxRow({ + outboxId: "ob-8", + ticketId: "ticket-8", + boardId: "board-1", + sequence: 14, + status: "waiting_on_user", + attentionKind: "waiting_for_input", + attentionReason: "please review", + }); + const dispatcher = yield* WorkflowBoardNotificationDispatcher; + const result = yield* dispatcher.sweep(); + + assert.strictEqual(result.sent, 1); + assert.strictEqual(recorder.calls.length, 1); + const title = recorder.calls[0]!.state.title; + // The relay decodes title as TrimmedNonEmptyString; a whitespace title + // must be replaced with the non-empty fallback before publish. + assert.isTrue(title.trim().length > 0, "blank title falls back to non-empty title"); + assert.strictEqual(title, "Ticket needs your attention"); + }).pipe( + Effect.provide( + buildLayer(recorder, { + "ticket-8": detail( + makeTicketRow({ ticketId: "ticket-8", title: " " }), + ), + }), + ), + ); + }); + + it.effect("redacts and caps the body, and falls back when reason is empty", () => { + const recorder = makeRecorder(); + const secret = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + const longReason = `token leak ${secret} ` + "x".repeat(400); + return Effect.gen(function* () { + yield* insertOutboxRow({ + outboxId: "ob-6", + ticketId: "ticket-6", + boardId: "board-1", + sequence: 12, + status: "waiting_on_user", + attentionKind: "waiting_for_input", + attentionReason: longReason, + }); + yield* insertOutboxRow({ + outboxId: "ob-7", + ticketId: "ticket-7", + boardId: "board-1", + sequence: 13, + status: "waiting_on_user", + attentionKind: "waiting_for_input", + attentionReason: "", + }); + const dispatcher = yield* WorkflowBoardNotificationDispatcher; + yield* dispatcher.sweep(); + + const byTicket = Object.fromEntries(recorder.calls.map((c) => [c.ticketId, c])); + const redactedBody = byTicket["ticket-6"]!.state.body; + assert.isFalse(redactedBody.includes(secret), "raw secret must not appear"); + assert.isAtMost(redactedBody.length, 240, "body capped to MAX_NOTIFICATION_BODY"); + + const fallbackBody = byTicket["ticket-7"]!.state.body; + assert.isTrue(fallbackBody.trim().length > 0, "empty reason falls back to non-empty body"); + }).pipe( + Effect.provide( + buildLayer(recorder, { + "ticket-6": detail( + makeTicketRow({ ticketId: "ticket-6", attentionReason: longReason }), + ), + "ticket-7": detail( + makeTicketRow({ ticketId: "ticket-7", attentionReason: "" }), + ), + }), + ), + ); + }); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowBoardNotificationDispatcher.ts b/apps/server/src/workflow/Layers/WorkflowBoardNotificationDispatcher.ts new file mode 100644 index 00000000000..e6493d1b9c6 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardNotificationDispatcher.ts @@ -0,0 +1,250 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayBoardTicketState } from "@t3tools/contracts/relay"; +import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; +import { + WorkflowBoardNotificationDispatcher, + type WorkflowBoardNotificationDispatcherShape, + type WorkflowBoardNotificationSweepResult, +} from "../Services/WorkflowBoardNotificationDispatcher.ts"; +import { WorkflowBoardNotificationRelay } from "../Services/WorkflowBoardNotificationRelay.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { redactSensitiveText, truncateKeepingTail } from "../redactSensitiveText.ts"; + +const DEFAULT_SWEEP_INTERVAL_MS = 5_000; +const DEFAULT_MAX_PER_SWEEP = 20; +const MAX_ATTEMPTS = 5; +// Push-notification body cap. Push payloads are tiny; 240 chars is a generous +// single-screen preview that still leaves room for the truncation marker. +const MAX_NOTIFICATION_BODY = 240; +const DEFAULT_BODY = "Needs your attention"; + +// Statuses that mean the ticket still wants a human. Anything else (running, +// idle, done, failed, or a vanished ticket) means it self-resolved before we +// notified — supersede the row so we don't buzz. +const NEEDS_YOU_STATUSES = new Set(["waiting_on_user", "blocked"]); + +const VALID_ATTENTION_KINDS = new Set([ + "waiting_for_approval", + "waiting_for_input", + "blocked", +]); + +const normalizeAttentionKind = ( + raw: string | null, +): RelayBoardTicketState["attentionKind"] => + raw !== null && VALID_ATTENTION_KINDS.has(raw as RelayBoardTicketState["attentionKind"]) + ? (raw as RelayBoardTicketState["attentionKind"]) + : "waiting_for_input"; + +interface OutboxRow { + readonly outboxId: string; + readonly ticketId: string; + readonly boardId: string; + readonly sequence: number; + readonly status: string; + readonly attentionKind: string | null; + readonly attentionReason: string | null; + readonly attemptCount: number; +} + +export interface WorkflowBoardNotificationDispatcherLiveOptions { + readonly sweepIntervalMs?: number; + readonly maxPerSweep?: number; +} + +const makeWorkflowBoardNotificationDispatcher = ( + options?: WorkflowBoardNotificationDispatcherLiveOptions, +) => + Effect.gen(function* () { + const relay = yield* WorkflowBoardNotificationRelay; + const readModel = yield* WorkflowReadModel; + const serverEnvironment = yield* ServerEnvironment; + const sql = yield* SqlClient.SqlClient; + + const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS); + const maxPerSweep = Math.max(1, Math.floor(options?.maxPerSweep ?? DEFAULT_MAX_PER_SWEEP)); + + const buildBody = (reason: string | null): string => { + const redacted = truncateKeepingTail( + redactSensitiveText(reason ?? ""), + MAX_NOTIFICATION_BODY, + ); + return redacted.trim().length === 0 ? DEFAULT_BODY : redacted; + }; + + const markState = (outboxId: string, deliveryState: string, attemptCount?: number) => + attemptCount === undefined + ? sql`UPDATE workflow_notification_outbox SET delivery_state = ${deliveryState} WHERE outbox_id = ${outboxId}` + : sql`UPDATE workflow_notification_outbox SET delivery_state = ${deliveryState}, attempt_count = ${attemptCount} WHERE outbox_id = ${outboxId}`; + + // Process a single row. Returns the outcome category for the sweep summary. + // Per-row errors are caught here so one bad row can't abort the sweep. + const processRow = ( + row: OutboxRow, + envId: EnvironmentId, + ): Effect.Effect<"sent" | "superseded" | "failed"> => + Effect.gen(function* () { + const detail = yield* readModel.getTicketDetail(row.ticketId as never); + + // Relevance recheck: ticket self-resolved → supersede, don't buzz. + if (detail === null || !NEEDS_YOU_STATUSES.has(detail.ticket.status)) { + yield* markState(row.outboxId, "superseded"); + return "superseded" as const; + } + + // The relay decodes title as TrimmedNonEmptyString; a blank/whitespace + // ticket title would be rejected → retries → lost notification. Fall + // back to a non-empty default. + const safeTitle = + detail.ticket.title.trim().length > 0 + ? detail.ticket.title + : "Ticket needs your attention"; + + const state: RelayBoardTicketState = { + environmentId: envId, + boardId: row.boardId, + ticketId: row.ticketId, + attentionKind: normalizeAttentionKind(row.attentionKind), + title: safeTitle, + body: buildBody(row.attentionReason), + deepLink: `/tickets/${encodeURIComponent(envId)}/${encodeURIComponent( + row.boardId, + )}/${encodeURIComponent(row.ticketId)}`, + transitionId: String(row.sequence), + }; + + const published = yield* relay + .publishTicket({ + environmentId: envId, + boardId: row.boardId, + ticketId: row.ticketId, + state, + }) + .pipe(Effect.result); + + if (Result.isSuccess(published)) { + yield* markState(row.outboxId, "sent"); + return "sent" as const; + } + + const nextAttempt = row.attemptCount + 1; + if (nextAttempt >= MAX_ATTEMPTS) { + yield* Effect.logError("workflow.board-notification.give-up", { + outboxId: row.outboxId, + ticketId: row.ticketId, + sequence: row.sequence, + attemptCount: nextAttempt, + error: published.failure, + }); + yield* markState(row.outboxId, "failed", nextAttempt); + return "failed" as const; + } + yield* markState(row.outboxId, "pending", nextAttempt); + return "failed" as const; + }).pipe( + Effect.catchCause((cause) => + // Re-raise defects (programming bugs) so the sweep-level catchDefect + // guard surfaces them; only swallow expected/transient failures as a + // per-row "failed" so one bad row can't abort the whole sweep. + // Re-dying with the squashed cause keeps the error channel `never`. + Cause.hasDies(cause) || Cause.hasInterrupts(cause) + ? Effect.die(Cause.squash(cause)) + : Effect.logWarning("workflow.board-notification.row-failed", { + outboxId: row.outboxId, + ticketId: row.ticketId, + cause, + }).pipe(Effect.as("failed" as const)), + ), + ); + + const sweep: WorkflowBoardNotificationDispatcherShape["sweep"] = () => + Effect.gen(function* () { + const rows = yield* sql` + SELECT + outbox_id AS "outboxId", + ticket_id AS "ticketId", + board_id AS "boardId", + sequence, + status, + attention_kind AS "attentionKind", + attention_reason AS "attentionReason", + attempt_count AS "attemptCount" + FROM workflow_notification_outbox + WHERE delivery_state = 'pending' + ORDER BY created_at ASC + LIMIT ${maxPerSweep} + `.pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.board-notification.select-failed", { cause }).pipe( + Effect.as([] as ReadonlyArray), + ), + ), + ); + + let claimed = 0; + let sent = 0; + let superseded = 0; + let failed = 0; + + if (rows.length === 0) { + return { claimed, sent, superseded, failed }; + } + + // Resolve the environment id once per sweep. + const envId = yield* serverEnvironment.getEnvironmentId; + + for (const row of rows) { + claimed += 1; + const outcome = yield* processRow(row, envId); + if (outcome === "sent") sent += 1; + else if (outcome === "superseded") superseded += 1; + else if (outcome === "failed") failed += 1; + } + + if (claimed > 0) { + yield* Effect.logInfo("workflow.board-notification.sweep-complete", { + claimed, + sent, + superseded, + failed, + }); + } + + return { claimed, sent, superseded, failed } satisfies WorkflowBoardNotificationSweepResult; + }); + + const start: WorkflowBoardNotificationDispatcherShape["start"] = () => + Effect.gen(function* () { + yield* Effect.forkScoped( + sweep().pipe( + Effect.catchDefect((defect: unknown) => + Effect.logWarning("workflow.board-notification.sweep-defect", { defect }), + ), + Effect.repeat(Schedule.spaced(Duration.millis(sweepIntervalMs))), + ), + ); + + yield* Effect.logInfo("workflow.board-notification.started", { sweepIntervalMs }); + }); + + return { sweep, start } satisfies WorkflowBoardNotificationDispatcherShape; + }); + +export const makeWorkflowBoardNotificationDispatcherLive = ( + options?: WorkflowBoardNotificationDispatcherLiveOptions, +) => + Layer.effect( + WorkflowBoardNotificationDispatcher, + makeWorkflowBoardNotificationDispatcher(options), + ); + +export const WorkflowBoardNotificationDispatcherLive = + makeWorkflowBoardNotificationDispatcherLive(); diff --git a/apps/server/src/workflow/Layers/WorkflowBoardNotificationRelay.test.ts b/apps/server/src/workflow/Layers/WorkflowBoardNotificationRelay.test.ts new file mode 100644 index 00000000000..18b317140ef --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardNotificationRelay.test.ts @@ -0,0 +1,349 @@ +// @effect-diagnostics globalFetch:off - test harness installs a stable fetch dispatcher to defeat FetchHttpClient.Fetch memoization across cases. +// @effect-diagnostics nodeBuiltinImport:off - test seeds a deterministic Ed25519 key pair so the published proof JWT can be verified. +import * as NodeCrypto from "node:crypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; + +import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import type { RelayBoardTicketState } from "@t3tools/contracts/relay"; +import { RELAY_BOARD_TICKET_PUBLISH_TYP } from "@t3tools/contracts/relay"; +import { normalizeRelayIssuer, verifyRelayJwt } from "@t3tools/shared/relayJwt"; +import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; + +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import { + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + RELAY_ISSUER_SECRET, + RELAY_URL_SECRET, +} from "../../cloud/config.ts"; +import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; +import { WorkflowBoardNotificationRelay } from "../Services/WorkflowBoardNotificationRelay.ts"; +import { WorkflowBoardNotificationRelayLive } from "./WorkflowBoardNotificationRelay.ts"; + +const environmentId = "env-1" as EnvironmentId; +const boardId = "board-1"; +const ticketId = "ticket-1"; + +const state: RelayBoardTicketState = { + environmentId, + boardId, + ticketId, + attentionKind: "waiting_for_approval", + title: "Needs approval", + body: "The agent is waiting for your approval.", + deepLink: "/boards/env-1/board-1/ticket-1", + transitionId: "transition-1", +}; + +// Deterministic environment signing key pair. Seeding it into the secret store +// under the same name getOrCreateEnvironmentKeyPairFromSecretStore reads means the +// layer signs with this private key, so the test can verify the proof with the +// matching public key. +const ENVIRONMENT_KEY_PAIR_SECRET = "cloud-link-ed25519-key-pair"; +const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, +}); +const testIssuer = "https://issuer.example.test"; + +const descriptor = { + environmentId, + label: "Test Desktop", + platform: { + os: "darwin", + arch: "arm64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, +} satisfies ExecutionEnvironmentDescriptor; + +const encodeSecret = (value: string): Uint8Array => new TextEncoder().encode(value); + +function makeMemorySecretStore() { + const values = new Map(); + const store = { + get: ((name) => + Effect.sync( + () => values.get(name) ?? null, + )) satisfies ServerSecretStore.ServerSecretStoreShape["get"], + set: ((name, value) => + Effect.sync(() => { + values.set(name, Uint8Array.from(value)); + })) satisfies ServerSecretStore.ServerSecretStoreShape["set"], + create: ((name, value) => + Effect.sync(() => { + values.set(name, Uint8Array.from(value)); + })) satisfies ServerSecretStore.ServerSecretStoreShape["create"], + getOrCreateRandom: ((name, bytes) => + Effect.sync(() => { + const existing = values.get(name); + if (existing) { + return existing; + } + const generated = new Uint8Array(bytes); + values.set(name, generated); + return generated; + })) satisfies ServerSecretStore.ServerSecretStoreShape["getOrCreateRandom"], + remove: ((name) => + Effect.sync(() => { + values.delete(name); + })) satisfies ServerSecretStore.ServerSecretStoreShape["remove"], + } satisfies ServerSecretStore.ServerSecretStoreShape; + return { + store, + setString: (name: string, value: string) => store.set(name, encodeSecret(value)), + }; +} + +// effect's FetchHttpClient.Fetch is a Context.Reference whose default +// (`globalThis.fetch`) is read and memoized on first use, which would otherwise +// pin every test in this process to whichever fetch stub ran first. We install a +// single stable dispatcher into `globalThis.fetch` that delegates to a mutable +// per-test handler, so each test's override stays live regardless of memoization. +type FetchFn = typeof globalThis.fetch; +const realFetch = globalThis.fetch; +let currentFetch: FetchFn = realFetch; +globalThis.fetch = ((...args: Parameters) => + currentFetch(...args)) as unknown as FetchFn; + +function useFetch(handler: FetchFn): Effect.Effect { + return Effect.acquireRelease( + Effect.sync(() => { + currentFetch = handler; + }), + () => + Effect.sync(() => { + currentFetch = realFetch; + }), + ).pipe(Effect.asVoid); +} + +function makeLayer(secrets: ReturnType) { + return WorkflowBoardNotificationRelayLive.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), + Layer.succeed(ServerEnvironment, { + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.succeed(descriptor), + }), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); +} + +describe.sequential("WorkflowBoardNotificationRelay", () => { + it.effect("signs a board-ticket proof and publishes it to the relay", () => + Effect.scoped( + Effect.gen(function* () { + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + const requestSeen = yield* Deferred.make<{ + readonly url: URL; + readonly body: unknown; + }>(); + const secrets = makeMemorySecretStore(); + + yield* useFetch((( + input: Parameters[0], + init?: Parameters[1], + ) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + const readBody = async (): Promise => { + if (input instanceof Request) { + const text = await input.clone().text(); + return text ? JSON.parse(text) : null; + } + const rawBody = init?.body; + if (typeof rawBody === "string") { + return JSON.parse(rawBody); + } + if (rawBody instanceof Uint8Array || rawBody instanceof ArrayBuffer) { + const text = new TextDecoder().decode(rawBody); + return text ? JSON.parse(text) : null; + } + return null; + }; + runFork( + Effect.promise(readBody).pipe( + Effect.flatMap((body) => Deferred.succeed(requestSeen, { url, body })), + ), + ); + return Promise.resolve(Response.json({ ok: true, deliveries: [] })); + }) as unknown as typeof fetch); + + yield* secrets.setString( + ENVIRONMENT_KEY_PAIR_SECRET, + // @effect-diagnostics-next-line preferSchemaOverJson:off - mirrors the on-disk JSON envelope getOrCreateEnvironmentKeyPairFromSecretStore decodes. + JSON.stringify({ privateKey: keyPair.privateKey, publicKey: keyPair.publicKey }), + ); + + yield* Effect.gen(function* () { + yield* secrets.setString(RELAY_URL_SECRET, "https://transport.example.test"); + yield* secrets.setString(RELAY_ISSUER_SECRET, testIssuer); + yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); + + const relay = yield* WorkflowBoardNotificationRelay; + yield* relay.publishTicket({ environmentId, boardId, ticketId, state }); + + const seen = yield* Deferred.await(requestSeen).pipe(Effect.timeout("2 seconds")); + expect(seen.url.origin).toBe("https://transport.example.test"); + expect(seen.url.pathname).toBe( + `/v1/environments/${environmentId}/tickets/${ticketId}/board-activity`, + ); + const body = seen.body as { readonly state: unknown; readonly proof: unknown }; + expect(body.state).toMatchObject({ ticketId, boardId, attentionKind: "waiting_for_approval" }); + expect(typeof body.proof).toBe("string"); + expect((body.proof as string).length).toBeGreaterThan(0); + + // Decode and verify the proof JWT the way the relay side (Task 10) will, + // asserting every signed claim — not just that a non-empty proof exists. + // it.effect runs under a TestClock anchored at epoch 0, so the proof's + // iat/exp are 0/300. Verify at a point inside that window. + const verified = yield* verifyRelayJwt({ + publicKey: keyPair.publicKey, + token: body.proof as string, + typ: RELAY_BOARD_TICKET_PUBLISH_TYP, + issuer: `t3-env:${environmentId}`, + audience: normalizeRelayIssuer(testIssuer), + nowEpochSeconds: 150, + }); + expect(verified.iss).toBe(`t3-env:${environmentId}`); + expect(verified.sub).toBe(environmentId); + expect(verified.aud).toBe(normalizeRelayIssuer(testIssuer)); + expect((verified as { environmentId?: unknown }).environmentId).toBe(environmentId); + expect((verified as { boardId?: unknown }).boardId).toBe(boardId); + expect((verified as { ticketId?: unknown }).ticketId).toBe(ticketId); + expect((verified as { state?: unknown }).state).toEqual(state); + expect(typeof verified.iat).toBe("number"); + expect(typeof verified.exp).toBe("number"); + expect((verified.exp as number) > (verified.iat as number)).toBe(true); + }).pipe(Effect.provide(makeLayer(secrets))); + }), + ), + ); + + it.effect("is a no-op success when relay config is missing", () => + Effect.scoped( + Effect.gen(function* () { + const secrets = makeMemorySecretStore(); + let fetchCalls = 0; + + yield* useFetch((() => { + fetchCalls += 1; + return Promise.resolve(Response.json({ ok: true, deliveries: [] })); + }) as unknown as typeof fetch); + + yield* Effect.gen(function* () { + const relay = yield* WorkflowBoardNotificationRelay; + yield* relay.publishTicket({ environmentId, boardId, ticketId, state }); + }).pipe(Effect.provide(makeLayer(secrets))); + + expect(fetchCalls).toBe(0); + }), + ), + ); + + it.effect("fails (not standby success) when reading relay config secrets errors", () => + Effect.scoped( + Effect.gen(function* () { + // Seed the env key pair so the layer build succeeds, but make get() for + // the relay config secrets FAIL — a real secret-store read error must + // propagate as a failure, not be swallowed into a standby no-op success + // (which would let the dispatcher mark the row sent and drop the buzz). + const backing = makeMemorySecretStore(); + yield* backing.setString( + ENVIRONMENT_KEY_PAIR_SECRET, + // @effect-diagnostics-next-line preferSchemaOverJson:off - mirrors the on-disk JSON envelope getOrCreateEnvironmentKeyPairFromSecretStore decodes. + JSON.stringify({ privateKey: keyPair.privateKey, publicKey: keyPair.publicKey }), + ); + const RELAY_CONFIG_SECRETS = new Set([ + RELAY_URL_SECRET, + RELAY_ISSUER_SECRET, + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + ]); + let fetchCalls = 0; + yield* useFetch((() => { + fetchCalls += 1; + return Promise.resolve(Response.json({ ok: true, deliveries: [] })); + }) as unknown as typeof fetch); + + const failingStore: ServerSecretStore.ServerSecretStoreShape = { + ...backing.store, + get: ((name) => + RELAY_CONFIG_SECRETS.has(name) + ? Effect.fail( + new ServerSecretStore.SecretStoreError({ + message: `boom reading ${name}`, + }), + ) + : backing.store.get( + name, + )) satisfies ServerSecretStore.ServerSecretStoreShape["get"], + }; + const failingLayer = WorkflowBoardNotificationRelayLive.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ServerSecretStore.ServerSecretStore, failingStore), + Layer.succeed(ServerEnvironment, { + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.succeed(descriptor), + }), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); + + const exit = yield* Effect.gen(function* () { + const relay = yield* WorkflowBoardNotificationRelay; + return yield* relay + .publishTicket({ environmentId, boardId, ticketId, state }) + .pipe(Effect.exit); + }).pipe(Effect.provide(failingLayer)); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect((error as { _tag?: string })._tag).toBe("WorkflowEventStoreError"); + } + expect(fetchCalls).toBe(0); + }), + ), + ); + + it.effect("fails with WorkflowEventStoreError when the relay HTTP call fails", () => + Effect.scoped( + Effect.gen(function* () { + const secrets = makeMemorySecretStore(); + + yield* useFetch((() => + Promise.reject(new Error("upstream boom"))) as unknown as typeof fetch); + + const exit = yield* Effect.gen(function* () { + yield* secrets.setString(RELAY_URL_SECRET, "https://transport.example.test"); + yield* secrets.setString(RELAY_ISSUER_SECRET, "https://issuer.example.test"); + yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); + + const relay = yield* WorkflowBoardNotificationRelay; + return yield* relay.publishTicket({ environmentId, boardId, ticketId, state }).pipe( + Effect.exit, + ); + }).pipe(Effect.provide(makeLayer(secrets))); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause); + expect((error as { _tag?: string })._tag).toBe("WorkflowEventStoreError"); + } + }), + ), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowBoardNotificationRelay.ts b/apps/server/src/workflow/Layers/WorkflowBoardNotificationRelay.ts new file mode 100644 index 00000000000..f9424ef65bd --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowBoardNotificationRelay.ts @@ -0,0 +1,165 @@ +import { + RelayApi, + RELAY_BOARD_TICKET_PUBLISH_TYP, + type RelayBoardTicketPublishProofPayload, + type RelayBoardTicketState, +} from "@t3tools/contracts/relay"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { signRelayJwt, normalizeRelayIssuer } from "@t3tools/shared/relayJwt"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { FetchHttpClient } from "effect/unstable/http"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../../cloud/environmentKeys.ts"; +import { + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + RELAY_ISSUER_SECRET, + RELAY_URL_SECRET, +} from "../../cloud/config.ts"; +import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorkflowBoardNotificationRelay, + type WorkflowBoardNotificationRelayShape, +} from "../Services/WorkflowBoardNotificationRelay.ts"; + +function relayEnvironmentClient(token: string) { + return HttpClient.mapRequest(HttpClientRequest.setHeader("authorization", `Bearer ${token}`)); +} + +const make = Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverEnvironment = yield* ServerEnvironment; + const crypto = yield* Crypto.Crypto; + const environmentKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); + + const readSecretString = (name: string) => + secrets.get(name).pipe(Effect.map((bytes) => (bytes ? new TextDecoder().decode(bytes) : null))); + + const readRelayConfig = Effect.gen(function* () { + const [url, issuer, environmentCredential] = yield* Effect.all([ + readSecretString(RELAY_URL_SECRET), + readSecretString(RELAY_ISSUER_SECRET), + readSecretString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + ]); + return url && environmentCredential + ? { url, issuer: issuer ?? url, environmentCredential } + : null; + }); + + const makeRelayClient = (relayConfig: { + readonly url: string; + readonly environmentCredential: string; + }) => + HttpApiClient.make(RelayApi, { + baseUrl: relayConfig.url, + transformClient: relayEnvironmentClient(relayConfig.environmentCredential), + }).pipe(Effect.provide(FetchHttpClient.layer)); + + const makePublishProof = (input: { + readonly relayIssuer: string; + readonly environmentId: EnvironmentId; + readonly boardId: string; + readonly ticketId: string; + readonly state: RelayBoardTicketState; + readonly jti: string; + }) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const expiresAt = DateTime.add(now, { minutes: 5 }); + const payload = { + iss: `t3-env:${input.environmentId}`, + aud: normalizeRelayIssuer(input.relayIssuer), + sub: input.environmentId, + jti: input.jti, + iat: Math.floor(now.epochMilliseconds / 1_000), + exp: Math.floor(expiresAt.epochMilliseconds / 1_000), + environmentId: input.environmentId, + boardId: input.boardId, + ticketId: input.ticketId, + state: input.state, + } satisfies RelayBoardTicketPublishProofPayload; + return yield* signRelayJwt({ + privateKey: environmentKeyPair.privateKey, + typ: RELAY_BOARD_TICKET_PUBLISH_TYP, + payload, + }); + }); + + const publishTicket: WorkflowBoardNotificationRelayShape["publishTicket"] = (input) => + Effect.gen(function* () { + // Absent config legitimately returns null (standby no-op). A real + // secret-store READ error propagates here and is wrapped by the outer + // catchCause into a WorkflowEventStoreError, so the dispatcher retries + // instead of marking the row sent and silently dropping the notification. + const relayConfig = yield* readRelayConfig; + if (!relayConfig) { + yield* Effect.logDebug("board ticket notification standby; T3 Connect config missing", { + boardId: input.boardId, + ticketId: input.ticketId, + }); + return; + } + + const relayClient = yield* makeRelayClient(relayConfig); + const proof = yield* makePublishProof({ + relayIssuer: relayConfig.issuer, + environmentId: input.environmentId, + boardId: input.boardId, + ticketId: input.ticketId, + state: input.state, + jti: yield* crypto.randomUUIDv4, + }); + + yield* Effect.logInfo("publishing board ticket attention", { + environmentId: input.environmentId, + boardId: input.boardId, + ticketId: input.ticketId, + attentionKind: input.state.attentionKind, + }); + + const response = yield* relayClient.server.publishBoardTicket({ + params: { + environmentId: input.environmentId, + ticketId: input.ticketId, + }, + payload: { + state: input.state, + proof, + }, + }); + + yield* Effect.logInfo("board ticket attention publish completed", { + environmentId: input.environmentId, + boardId: input.boardId, + ticketId: input.ticketId, + ok: response.ok, + }); + }).pipe( + Effect.catchCause((cause) => + Effect.fail( + new WorkflowEventStoreError({ + message: `board ticket relay publish failed for ticket ${input.ticketId}`, + cause: Cause.squash(cause), + }), + ), + ), + Effect.withSpan("WorkflowBoardNotificationRelay.publishTicket"), + ); + + return { + publishTicket, + } satisfies WorkflowBoardNotificationRelayShape; +}); + +export const WorkflowBoardNotificationRelayLive = Layer.effect( + WorkflowBoardNotificationRelay, + 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..5499015f7d1 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.concurrency.test.ts @@ -0,0 +1,701 @@ +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("applies a raised maxConcurrentTickets without a server restart", () => + Effect.gen(function* () { + activeExecutions = 0; + maxActiveExecutions = 0; + + const registry = yield* BoardRegistry; + yield* registry.register("b-resize" as never, definition); + const engine = yield* WorkflowEngine; + + yield* Effect.all( + [ + engine.createTicket({ + boardId: "b-resize" as never, + title: "First", + initialLane: "impl" as never, + }), + engine.createTicket({ + boardId: "b-resize" as never, + title: "Second", + initialLane: "impl" as never, + }), + ], + { concurrency: "unbounded" }, + ); + assert.equal(maxActiveExecutions, 1); + + // Saving the definition with a higher limit must take effect for the + // very next pipeline — not only after a restart. + yield* registry.register("b-resize" as never, { + ...definition, + settings: { maxConcurrentTickets: 2 }, + }); + activeExecutions = 0; + maxActiveExecutions = 0; + + yield* Effect.all( + [ + engine.createTicket({ + boardId: "b-resize" as never, + title: "Third", + initialLane: "impl" as never, + }), + engine.createTicket({ + boardId: "b-resize" as never, + title: "Fourth", + initialLane: "impl" as never, + }), + ], + { concurrency: "unbounded" }, + ); + assert.equal(maxActiveExecutions, 2); + }), + ); + + 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..af534877109 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.externalEvents.test.ts @@ -0,0 +1,481 @@ +// @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 * 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 { 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"); + }), + ); +}); + +// Board definitions for pr.* predicate context tests. +const prCiGateDefinition = { + name: "pr-ci-gate", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "manual", + onEvent: [ + { + name: "pr.approved", + when: { "==": [{ var: "pr.ciState" }, "success"] }, + to: "land", + }, + ], + }, + { key: "land", name: "Land", entry: "manual", terminal: true }, + ], +}; + +const prReviewGateDefinition = { + name: "pr-review-gate", + lanes: [ + { + key: "implement", + name: "Implement", + entry: "manual", + onEvent: [ + { + name: "ci.passed", + when: { "==": [{ var: "pr.reviewDecision" }, "approved"] }, + to: "land", + }, + ], + }, + { key: "land", name: "Land", entry: "manual", terminal: true }, + ], +}; + +layer("WorkflowEngine pr.* predicate context", (it) => { + it.effect( + "pr.approved with pr.ciState=success moves the ticket; with pending stays noop", + () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-pr-ci-success" as never, prCiGateDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + + const ticketId = yield* engine.createTicket({ + boardId: "b-pr-ci-success" as never, + title: "My PR ticket", + initialLane: "implement" as never, + }); + + // Seed workflow_pr_state with last_ci_state='success' + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, + pr_state, last_ci_state, last_review_decision, updated_at + ) VALUES ( + ${ticketId}, 42, 'https://github.com/o/r/pull/42', + 'workflow/my-ticket', 'origin', 'o/r', + 'open', 'success', 'none', '2026-06-12T00:00:00.000Z' + ) + `; + + // Ingest pr.approved with ci passing → should move + const moved = yield* engine.ingestExternalEvent({ + boardId: "b-pr-ci-success" as never, + name: "pr.approved", + ticketId, + payload: null, + }); + assert.equal(moved.outcome, "moved"); + assert.equal(moved.toLane, "land"); + + const detail = yield* read.getTicketDetail(ticketId); + assert.equal(detail?.ticket.currentLaneKey, "land"); + }), + ); + + it.effect("pr.approved with pr.ciState=pending stays noop", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-pr-ci-pending" as never, prCiGateDefinition); + const engine = yield* WorkflowEngine; + const sql = yield* SqlClient.SqlClient; + + const ticketId = yield* engine.createTicket({ + boardId: "b-pr-ci-pending" as never, + title: "Pending CI ticket", + initialLane: "implement" as never, + }); + + // Seed workflow_pr_state with last_ci_state='pending' + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, + pr_state, last_ci_state, last_review_decision, updated_at + ) VALUES ( + ${ticketId}, 43, 'https://github.com/o/r/pull/43', + 'workflow/pending-ticket', 'origin', 'o/r', + 'open', 'pending', 'none', '2026-06-12T00:00:00.000Z' + ) + `; + + const noop = yield* engine.ingestExternalEvent({ + boardId: "b-pr-ci-pending" as never, + name: "pr.approved", + ticketId, + payload: null, + }); + assert.equal(noop.outcome, "noop"); + }), + ); + + it.effect("pr.approved with no workflow_pr_state row stays noop", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-pr-ci-norow" as never, prCiGateDefinition); + const engine = yield* WorkflowEngine; + + const ticketId = yield* engine.createTicket({ + boardId: "b-pr-ci-norow" as never, + title: "No PR row ticket", + initialLane: "implement" as never, + }); + + // No workflow_pr_state row → pr.ciState is null → predicate fails + const noop = yield* engine.ingestExternalEvent({ + boardId: "b-pr-ci-norow" as never, + name: "pr.approved", + ticketId, + payload: null, + }); + assert.equal(noop.outcome, "noop"); + }), + ); + + it.effect( + "ci.passed with pr.reviewDecision=approved moves the ticket; with none stays noop", + () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-pr-review-approved" as never, prReviewGateDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + + const ticketId = yield* engine.createTicket({ + boardId: "b-pr-review-approved" as never, + title: "Approved review ticket", + initialLane: "implement" as never, + }); + + // Seed workflow_pr_state with last_review_decision='approved' + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, + pr_state, last_ci_state, last_review_decision, updated_at + ) VALUES ( + ${ticketId}, 44, 'https://github.com/o/r/pull/44', + 'workflow/review-ticket', 'origin', 'o/r', + 'open', 'success', 'approved', '2026-06-12T00:00:00.000Z' + ) + `; + + // Ingest ci.passed with review approved → should move + const moved = yield* engine.ingestExternalEvent({ + boardId: "b-pr-review-approved" as never, + name: "ci.passed", + ticketId, + payload: null, + }); + assert.equal(moved.outcome, "moved"); + assert.equal(moved.toLane, "land"); + + const detail = yield* read.getTicketDetail(ticketId); + assert.equal(detail?.ticket.currentLaneKey, "land"); + }), + ); + + it.effect("ci.passed with pr.reviewDecision=none stays noop", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-pr-review-none" as never, prReviewGateDefinition); + const engine = yield* WorkflowEngine; + const sql = yield* SqlClient.SqlClient; + + const ticketId = yield* engine.createTicket({ + boardId: "b-pr-review-none" as never, + title: "None review ticket", + initialLane: "implement" as never, + }); + + // Seed workflow_pr_state with last_review_decision='none' + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, + pr_state, last_ci_state, last_review_decision, updated_at + ) VALUES ( + ${ticketId}, 45, 'https://github.com/o/r/pull/45', + 'workflow/none-review-ticket', 'origin', 'o/r', + 'open', 'pending', 'none', '2026-06-12T00:00:00.000Z' + ) + `; + + const noop = yield* engine.ingestExternalEvent({ + boardId: "b-pr-review-none" as never, + name: "ci.passed", + ticketId, + payload: null, + }); + assert.equal(noop.outcome, "noop"); + }), + ); + + it.effect("flows pr context (ciState + reviewDecision) through to predicate evaluation", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-pr-context-flow" as never, prCiGateDefinition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + + const ticketId = yield* engine.createTicket({ + boardId: "b-pr-context-flow" as never, + title: "Context flow ticket", + initialLane: "implement" as never, + }); + + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, + pr_state, last_ci_state, last_review_decision, updated_at + ) VALUES ( + ${ticketId}, 46, 'https://github.com/o/r/pull/46', + 'workflow/context-flow', 'origin', 'o/r', + 'open', 'success', 'approved', '2026-06-12T00:00:00.000Z' + ) + `; + + // Sanity-check the seeded state the engine will read. + const prStateRow = yield* read.getTicketPrState(ticketId); + assert.equal(prStateRow?.lastCiState, "success"); + assert.equal(prStateRow?.lastReviewDecision, "approved"); + + // The engine reads workflow_pr_state once and exposes pr.ciState / + // pr.reviewDecision to the onEvent.when predicate. The single-read property + // (engine reads pr state once before resolveTarget; revalidate reuses that + // snapshot instead of re-reading) is enforced structurally in + // WorkflowEngine.ts — see the comment at the getTicketPrState call site. + const moved = yield* engine.ingestExternalEvent({ + boardId: "b-pr-context-flow" as never, + name: "pr.approved", + ticketId, + payload: null, + }); + assert.equal(moved.outcome, "moved"); + assert.equal(moved.toLane, "land"); + }), + ); +}); 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..843119ed85b --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.integration.test.ts @@ -0,0 +1,2029 @@ +// @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 rejects attachment-only answers and keeps the step awaiting", () => + 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); + + // Provider responses are text-only: an attachment-only reply could never + // resume the parked turn, so it must fail before posting any message. + const exit = yield* Effect.exit( + 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", + }, + ], + }), + ); + assert.isTrue(Exit.isFailure(exit)); + if (Exit.isFailure(exit)) { + assert.include(String(exit.cause), "requires text"); + } + + 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.deepEqual( + detail?.messages.map((message) => [message.author, message.body]), + [["agent", "Attach a screenshot."]], + ); + }).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..74b3a32afbd --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.ts @@ -0,0 +1,2586 @@ +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 => { + const retryPolicy = step.type === "agent" || step.type === "script" ? step.retry : undefined; + if (retryPolicy === undefined) { + return 1; + } + return Math.min(Math.max(1, retryPolicy.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< + Map + >(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 && existing.permits === permits) { + return Effect.succeed([existing.semaphore, current] as const); + } + + // Effect semaphores are not resizable, so a changed maxConcurrentTickets + // swaps in a fresh semaphore. In-flight holders drain on the old + // semaphore and are invisible to the new one, so total concurrency can + // transiently exceed the new limit (whether raised or lowered) until + // they finish — bounded by the previously running pipelines and + // self-correcting, which is accepted here. + return Semaphore.make(permits).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(key, { semaphore, permits }); + 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); + }); + + // Public exposure of the per-board admission semaphore (the WIP read-decide + // serializer). Reuses the SAME `admissionSemaphores` instance via + // `withAdmissionLock` — there is no second semaphore map. The source committer + // MUST wrap its chunk in this (OUTER) -> the board save lock (INNER) -> the + // transaction, matching the public enterLane lock order (admission->save), so + // its sync admits serialize against concurrent user moves and cannot violate a + // WIP limit. The unlocked enterLane cores assume this is already held. + const withBoardAdmissionLock: WorkflowEngineShape["withBoardAdmissionLock"] = (boardId, effect) => + withAdmissionLock(boardId, effect); + + 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", + ); + }); + + // The ticket's current lane/token as stored in the projection. A pipeline + // start may have been SNAPSHOTTED (e.g. by recoverBoardWip) before a + // concurrent user/source move changed the ticket's lane entry token; this is + // the authority for "is this start still current?". + const ticketLaneTokenRow = (ticketId: TicketId) => + wrapSql(sql<{ readonly currentLaneKey: string; readonly currentLaneEntryToken: string | null }>` + SELECT + current_lane_key AS "currentLaneKey", + current_lane_entry_token AS "currentLaneEntryToken" + FROM projection_ticket + WHERE ticket_id = ${ticketId} + LIMIT 1 + `).pipe(Effect.map((rows) => rows[0] ?? null)); + + const startPipeline = ( + ticketId: TicketId, + boardId: BoardId, + lane: WorkflowLane, + laneEntryToken: LaneEntryToken, + ) => + Effect.gen(function* () { + const fiber = yield* SynchronizedRef.modifyEffect(runningPipelines, (current) => + Effect.gen(function* () { + const key = ticketId as string; + const active = current.get(key); + if (active?.laneEntryToken === laneEntryToken) { + return [null, current] as const; + } + + // Stale-start guard: re-read the ticket and require its current lane + // entry token AND lane key still match the start this call is for. A + // snapshot-then-start path (recoverBoardWip) can race a user/source + // move that re-tokened or re-laned the ticket between the snapshot and + // here; starting then would run a pipeline for a lane the ticket has + // already left (and the manual move could not interrupt it because it + // was not yet in runningPipelines). This read runs INSIDE the + // runningPipelines modify (and the caller holds the admission lock), + // so a stale start is prevented atomically with the map insert. + const row = yield* ticketLaneTokenRow(ticketId); + if ( + row === null || + row.currentLaneEntryToken !== (laneEntryToken as string) || + row.currentLaneKey !== (lane.key as string) + ) { + return [null, current] as const; + } + + return yield* 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 }); + } + }; + + // How a lane-entry body persists its events. The locked emitter (default) + // re-acquires the board save lock + opens a transaction per emission through + // commit/commitMany, and publishes live ticket views — used by every existing + // caller. The unlocked emitter (committer-driven Task 9 path) appends+projects + // through the committer's appendManyUnlocked, which ASSUMES the caller already + // holds the board save lock + an open transaction and does NOT publish; the + // committer publishes after releasing the lock. + type EmitEvents = ( + events: ReadonlyArray, + ) => Effect.Effect; + + const lockedEmit: EmitEvents = (events) => + events.length === 0 + ? Effect.void + : events.length === 1 + ? commit(events[0] as UnstampedWorkflowEventInput) + : commitMany(events); + + const stampEvent = (event: UnstampedWorkflowEventInput) => + Effect.gen(function* () { + const eventId = yield* ids.eventId(); + return { + ...event, + eventId: eventId as WorkflowEventId, + occurredAt: (yield* nowIso) as never, + } as WorkflowEventInput; + }); + + // Append+project through the caller's already-held board save lock + open + // transaction. Asserts (via the committer's contract) that the caller opened + // the lock + tx — it never acquires either itself. + const unlockedEmit: EmitEvents = (events) => + Effect.gen(function* () { + if (events.length === 0) { + return; + } + const stamped: Array = []; + for (const event of events) { + stamped.push(yield* stampEvent(event)); + } + yield* committer.appendManyUnlocked(stamped); + }); + + // Sweeps queued tickets into a lane up to its WIP limit. Runs under either + // emitter — the locked public path (default `lockedEmit`) or the unlocked + // source-committer path (caller passes `unlockedEmit`) — so it carries no + // "Locked" suffix; the caller owns the serialization (admission lock). + const admitNext = ( + boardId: BoardId, + laneKey: LaneKey, + emit: EmitEvents = lockedEmit, + ): 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* emit([ + { + type: "TicketAdmitted", + ticketId: queuedTicketId, + payload: { lane: laneKey, laneEntryToken }, + }, + ]); + collectStartAction(starts, queuedTicketId, boardId, lane, laneEntryToken); + } + + return starts; + }); + + interface EnterLaneCoreOptions { + readonly routedOptions?: RoutedEnterLaneOptions | undefined; + readonly externalOptions?: ExternalEnterLaneOptions | undefined; + // Persists the lane-entry events. Defaults to the locked emitter; the + // committer-driven unlocked path passes unlockedEmit. + readonly emit?: EmitEvents | undefined; + // Serializes the WIP read-decide body. The board SAVE lock does NOT + // serialize the WIP decision: it is taken only transiently at commit time + // (after the admit/queue decision), so concurrent paths can both read + // occupancy and both admit. The ADMISSION lock is what serializes the + // read-decide. The public path therefore wraps the body in the board + // admission lock. The unlocked path (the source committer) passes + // `Effect.uninterruptible` here ONLY because it MUST already hold the + // admission lock for the whole chunk via `withBoardAdmissionLock` (OUTER) -> + // save lock (INNER) -> transaction. Taking the admission lock again here, + // under the save lock, would invert that admission->save order and deadlock. + readonly serialize?: ((body: Effect.Effect) => Effect.Effect) | undefined; + // Runs the manual/external supersession (interrupt pipeline + cancel turns + + // tombstone dispatches). Injected so the unlocked path reuses the identical + // side effect. + readonly supersedeRunningWork: Effect.Effect; + } + + // The in-lock / in-tx body of a lane entry: revalidation, WIP/admission/queue + // decision, emit, and prior-lane sweep. Returns the pipeline starts to run + // AFTER the lock (and, for the unlocked path, after the caller's transaction) + // plus the outcome. Assumes the ticket already exists. Used by the public + // enterLane (locked emit + admission lock) and by the committer-facing unlocked + // ops (unlocked emit; they take no admission lock HERE only because the source + // committer must already hold it via `withBoardAdmissionLock` — the save lock + // alone does NOT serialize the WIP read-decide). + const enterLaneCore = ( + ticketId: TicketId, + boardId: BoardId, + toLane: LaneKey, + reason: MoveReason, + options: EnterLaneCoreOptions, + ): Effect.Effect< + { readonly starts: ReadonlyArray; readonly acted: "moved" | "queued" | "none" }, + WorkflowEventStoreError + > => { + const { routedOptions, externalOptions, supersedeRunningWork } = options; + const emit = options.emit ?? lockedEmit; + const serialize = + options.serialize ?? + ((body: Effect.Effect) => + withAdmissionLock(boardId, Effect.uninterruptible(body))); + return serialize( + 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; + yield* emit(routeEvent === null ? [queueEvent] : [routeEvent, queueEvent]); + } else { + const laneEntryToken = yield* ids.token(); + const moveEvent = { + type: "TicketMovedToLane", + ticketId, + payload: { toLane, laneEntryToken, reason }, + } as UnstampedWorkflowEventInput; + yield* emit(routeEvent === null ? [moveEvent] : [routeEvent, moveEvent]); + collectStartAction(starts, ticketId, boardId, targetLane, laneEntryToken); + } + + if (priorWasAdmitted && priorLane !== undefined && priorLane !== toLane) { + starts.push(...(yield* admitNext(boardId, priorLane, emit))); + } + + return { starts, acted }; + }), + ); + }; + + 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* enterLaneCore(ticketId, boardId, toLane, reason, { + routedOptions, + externalOptions, + supersedeRunningWork, + }); + + 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 admitNext, 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* admitNext(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; + }, + subject: "message" | "answer", + ): 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 ${subject} requires text or an attachment`, + }); + } + if (text.length > MAX_TICKET_ANSWER_BODY_LENGTH) { + return yield* new WorkflowEventStoreError({ + message: `ticket ${subject} body exceeds ${MAX_TICKET_ANSWER_BODY_LENGTH} characters`, + }); + } + if (attachments.length > MAX_TICKET_ANSWER_ATTACHMENT_COUNT) { + return yield* new WorkflowEventStoreError({ + message: `ticket ${subject} supports at most ${MAX_TICKET_ANSWER_ATTACHMENT_COUNT} attachments`, + }); + } + if (attachments.some((attachment) => attachment.kind !== "image")) { + return yield* new WorkflowEventStoreError({ + message: `ticket ${subject} 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 ${subject} 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 ${subject} attachments exceed the 10 MiB encoded limit`, + }); + } + return { text, attachments }; + }); + + const postTicketMessage: WorkflowEngineShape["postTicketMessage"] = (input) => + Effect.gen(function* () { + const { text, attachments } = yield* validateTicketMessageInput(input, "message"); + 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, attachments } = yield* validateTicketMessageInput(input, "answer"); + // Provider responses are text-only, so an attachment-only answer could + // never resume the awaiting step — reject before committing anything. + if (text.length === 0) { + return yield* new WorkflowEventStoreError({ + message: "answering an awaiting step requires text — add a note alongside attachments", + }); + } + + 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, + }, + }); + + 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 currentDetail = yield* read.getTicketDetail(ticketId); + if (!currentDetail) { + return; + } + yield* moveToLane(ticketId, currentDetail.ticket.boardId as BoardId, toLane, "manual"); + }); + + // --------------------------------------------------------------------------- + // Committer-facing UNLOCKED engine ops (Task 9 work-source syncer). EVERY one + // of these ASSUMES the caller already holds the board save lock for the + // ticket's board AND is inside an open `sql.withTransaction`, AND — for any op + // that makes a WIP admit/queue decision — already holds the board ADMISSION + // lock via `withBoardAdmissionLock` (OUTER) wrapping the save lock (INNER) + // wrapping the transaction. They never acquire the save lock, never open a + // transaction, and never take the admission lock themselves: the save lock is + // taken only transiently at commit time and does NOT serialize the WIP + // read-decide, so the committer must hold the admission lock to be WIP-safe + // against concurrent public enterLane moves. Calling the public + // commit/commitMany/enterLane/moveTicket from here would deadlock the + // non-reentrant save lock or nest the transaction. Pipeline starts are forked + // detached (non-blocking) so they merely queue behind the save lock the + // caller still holds and run once it is released. + // --------------------------------------------------------------------------- + + // Post-tx provider cancellation for a source-closed ticket. Does ONLY the live + // side effects — interrupt the running pipeline fiber and cancel the provider + // turns — and performs NO DB writes (the in-tx close already tombstoned the + // dispatch outbox rows). Idempotent: interrupting an already-cleared fiber or + // cancelling an absent/stopped session is a no-op. The `turns` snapshot is + // captured by the committer INSIDE the chunk tx (before the tombstone hid the + // pending/started rows) and replayed here after the tx commits. + const supersedeProviderWorkForTicket: WorkflowEngineShape["supersedeProviderWorkForTicket"] = ( + ticketId, + turns, + ) => + Effect.gen(function* () { + yield* interruptRunningPipeline(ticketId); + yield* cancelProviderTurns(turns).pipe(Effect.catch(() => Effect.void)); + }); + + const cancellableProviderTurnsForTicket: WorkflowEngineShape["cancellableProviderTurnsForTicket"] = + (ticketId) => + cancellableProviderDispatchesForTicket(ticketId).pipe( + Effect.map((rows) => + rows.map((row) => ({ threadId: row.threadId, turnId: row.turnId })), + ), + ); + + const createTicketAndEnterUnlocked: WorkflowEngineShape["createTicketAndEnterUnlocked"] = ( + input, + ) => + Effect.gen(function* () { + const ticketId = yield* ids.ticketId(); + yield* unlockedEmit([ + { + type: "TicketCreated", + ticketId, + payload: { + boardId: input.boardId, + title: input.title, + laneKey: input.destinationLane, + ...(input.description === undefined ? {} : { description: input.description }), + }, + } as UnstampedWorkflowEventInput, + ]); + // Pipeline starts are intentionally DROPPED here: starting a pipeline + // commits through the locked path, which would open a transaction while + // the caller's chunk transaction is still open (the SQLite connection has + // a single global transaction). The committer (Task 9) is responsible for + // triggering auto-lane pipeline starts (e.g. recoverBoardWip) AFTER it + // closes the chunk transaction and releases the save lock. + const { acted } = yield* enterLaneCore( + ticketId, + input.boardId, + input.destinationLane, + "initial", + { + emit: unlockedEmit, + serialize: Effect.uninterruptible, + supersedeRunningWork: Effect.void, + }, + ); + return { ticketId, outcome: acted }; + }); + + const closeTicketFromSourceUnlocked: WorkflowEngineShape["closeTicketFromSourceUnlocked"] = ( + ticketId, + closedLane, + ) => + Effect.gen(function* () { + const detail = yield* read.getTicketDetail(ticketId); + if (detail === null) { + return; + } + const boardId = detail.ticket.boardId as BoardId; + const fromLane = detail.ticket.currentLaneKey as LaneKey; + const routeEvent = { + type: "TicketRouteDecided", + ticketId, + payload: { + fromLane, + toLane: closedLane, + source: "work_source", + // The event schema requires contextSnapshot; a work-source close has + // no pipeline/event context, so record an empty snapshot. + contextSnapshot: null, + }, + } as UnstampedWorkflowEventInput; + // Reuse the EXTERNAL move path so the close lands via the same stale-lane + // guard, but the supersession here is DB-ONLY: it tombstones the ticket's + // dispatch outbox rows (tx-safe — rolls back with the chunk if a later + // delta fails). It does NOT interrupt the running pipeline fiber or call + // provider interruptTurn/stopSession, because those are live side effects + // that cannot be rolled back and must not run inside the chunk + // transaction. The committer drives the fiber-interrupt + provider-cancel + // AFTER the chunk commits, via supersedeProviderWorkForTicket. revalidate + // succeeds unconditionally — the work source is the authority on closing, + // there is no stale-matcher concern. Pipeline starts the close might admit + // in the prior lane are dropped for the same single-transaction reason as + // createTicketAndEnterUnlocked (closed lanes are terminal in practice; the + // committer sweeps starts after the chunk). + yield* enterLaneCore(ticketId, boardId, closedLane, "external", { + emit: unlockedEmit, + serialize: Effect.uninterruptible, + supersedeRunningWork: abandonTicketDispatches(ticketId).pipe( + Effect.catch(() => Effect.void), + ), + externalOptions: { + expectedFromLane: fromLane, + routeEvent, + revalidate: Effect.succeed(true), + }, + }); + }); + + const editTicketFieldsUnlocked: WorkflowEngineShape["editTicketFieldsUnlocked"] = ( + ticketId, + fields, + ) => + Effect.gen(function* () { + // Mirror the locked editTicket: a whitespace-only TITLE is dropped rather + // than written, so the projection never overwrites the stored title with + // an empty string. (editTicket errors; here we silently OMIT the field — + // the syncer must not blank a title and has no caller to surface an error + // to.) + // DESCRIPTION is treated differently: an empty-string description is a + // VALID CLEAR (source-owned descriptions are authoritative), so when a + // description is PROVIDED — including "" — it is emitted and written. Only + // `undefined` (not provided) leaves the description unchanged. The guard + // below therefore checks `=== undefined` (not falsiness) for description. + const trimmed = fields.title === undefined ? undefined : fields.title.trim(); + const title = trimmed !== undefined && trimmed.length === 0 ? undefined : trimmed; + if (title === undefined && fields.description === undefined) { + return; + } + yield* unlockedEmit([ + { + type: "TicketEdited", + ticketId, + payload: { + ...(title === undefined ? {} : { title: title as never }), + ...(fields.description === undefined ? {} : { description: fields.description }), + }, + } as UnstampedWorkflowEventInput, + ]); + }); + + 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(admitNext(boardId, lane.key)), + ); + } + + const tickets = yield* read.listTickets(boardId); + // admitNext 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; + // Read once; revalidate reuses this snapshot — do not re-read. + // resolveTarget closes over the eventContext built below, so the lock-guarded + // revalidate inside enterLane re-runs the same matcher against the same pr + // context without a second DB read (design finding #1: single read prevents + // desync between the initial evaluation and the revalidate recheck). + const prState = yield* read.getTicketPrState(input.ticketId); + const eventContext = { + event: { name: input.name, payload: input.payload ?? null }, + pr: { + ciState: prState?.lastCiState ?? null, + reviewDecision: prState?.lastReviewDecision ?? 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 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, + createTicketAndEnterUnlocked, + closeTicketFromSourceUnlocked, + cancellableProviderTurnsForTicket, + supersedeProviderWorkForTicket, + editTicketFieldsUnlocked, + withBoardAdmissionLock, + 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/WorkflowEngine.workSource.test.ts b/apps/server/src/workflow/Layers/WorkflowEngine.workSource.test.ts new file mode 100644 index 00000000000..16bdefdae3c --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEngine.workSource.test.ts @@ -0,0 +1,611 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +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 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 { 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 { 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 { WorkflowReadModelLive } from "./WorkflowReadModel.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +// A step that blocks forever so a ticket admitted into an auto lane keeps a +// running pipeline we can prove the external supersession path interrupts. +const blockingExecutor = Layer.succeed(StepExecutor, { + execute: () => Effect.never, +} 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(blockingExecutor), + 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), + ), +); + +// inbox lane is WIP-limited (1) so a second create queues; work lane is auto so +// an admitted ticket starts a (blocking) pipeline we can supersede; done is the +// terminal lane work_source closes tickets into. +const definition = { + name: "work source", + lanes: [ + { + key: "inbox", + name: "Inbox", + entry: "manual", + wipLimit: 1, + }, + { + key: "work", + name: "Work", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +// The committer holds the board save lock + an open transaction once per chunk; +// the unlocked engine ops assume that context. This mirrors how Task 9's syncer +// will drive them — fetch the lock + sql from context and wrap the body. +const inLockAndTx = (boardId: string, body: Effect.Effect) => + Effect.gen(function* () { + const saveLocks = yield* WorkflowBoardSaveLocks; + const sql = yield* SqlClient.SqlClient; + return yield* saveLocks.withSaveLock(boardId as never, sql.withTransaction(body)); + }); + +layer("WorkflowEngine work_source unlocked ops", (it) => { + it.effect("createTicketAndEnterUnlocked admits into an empty lane", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ws-empty" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + + const result = yield* inLockAndTx( + "b-ws-empty", + engine.createTicketAndEnterUnlocked({ + boardId: "b-ws-empty" as never, + title: "First", + description: "from a work source", + destinationLane: "inbox" as never, + }), + ); + + assert.equal(result.outcome, "moved"); + + const detail = yield* read.getTicketDetail(result.ticketId); + assert.equal(detail?.ticket.currentLaneKey, "inbox"); + assert.equal(detail?.ticket.title, "First"); + assert.equal(detail?.ticket.description, "from a work source"); + + const events = yield* Stream.runCollect(store.readByTicket(result.ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isDefined(events.find((event) => event.type === "TicketCreated")); + assert.isDefined(events.find((event) => event.type === "TicketMovedToLane")); + }), + ); + + it.effect("createTicketAndEnterUnlocked queues when the WIP-1 lane is occupied", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ws-wip" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + + const first = yield* inLockAndTx( + "b-ws-wip", + engine.createTicketAndEnterUnlocked({ + boardId: "b-ws-wip" as never, + title: "Occupant", + destinationLane: "inbox" as never, + }), + ); + assert.equal(first.outcome, "moved"); + + const second = yield* inLockAndTx( + "b-ws-wip", + engine.createTicketAndEnterUnlocked({ + boardId: "b-ws-wip" as never, + title: "Queued", + destinationLane: "inbox" as never, + }), + ); + assert.equal(second.outcome, "queued"); + + const detail = yield* read.getTicketDetail(second.ticketId); + assert.equal(detail?.ticket.currentLaneKey, "inbox"); + assert.equal(detail?.ticket.queuedAt !== null, true); + + const events = yield* Stream.runCollect(store.readByTicket(second.ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isDefined(events.find((event) => event.type === "TicketCreated")); + assert.isDefined(events.find((event) => event.type === "TicketQueued")); + }), + ); + + it.effect("closeTicketFromSourceUnlocked moves to the closed lane and records work_source", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ws-close" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + + const created = yield* inLockAndTx( + "b-ws-close", + engine.createTicketAndEnterUnlocked({ + boardId: "b-ws-close" as never, + title: "Close me", + destinationLane: "inbox" as never, + }), + ); + + yield* inLockAndTx( + "b-ws-close", + engine.closeTicketFromSourceUnlocked(created.ticketId, "done" as never), + ); + + const detail = yield* read.getTicketDetail(created.ticketId); + assert.equal(detail?.ticket.currentLaneKey, "done"); + + const events = yield* Stream.runCollect(store.readByTicket(created.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, "work_source"); + assert.equal(decision.payload.toLane, "done"); + assert.equal(decision.payload.fromLane, "inbox"); + } + 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(created.ticketId); + assert.isDefined(decisions.find((row) => row.source === "work_source")); + }), + ); + + it.effect("closeTicketFromSourceUnlocked supersedes running work via the external path", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ws-supersede" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + + const created = yield* inLockAndTx( + "b-ws-supersede", + engine.createTicketAndEnterUnlocked({ + boardId: "b-ws-supersede" as never, + title: "Running work", + destinationLane: "inbox" as never, + }), + ); + + // Seed a pending dispatch outbox row standing in for in-flight work. The + // external supersession path (which closeTicketFromSourceUnlocked reuses) + // tombstones pending/started rows to 'confirmed' so restart recovery never + // re-dispatches the superseded work. + 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-ws-1', ${created.ticketId}, 'step-ws-1', 'thread-ws-1', 'claude_main', + 'sonnet', 'do it', '/tmp/wt', 'pending', '2026-06-13T00:00:00.000Z' + ) + `; + + yield* inLockAndTx( + "b-ws-supersede", + engine.closeTicketFromSourceUnlocked(created.ticketId, "done" as never), + ); + + const live = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_dispatch_outbox + WHERE ticket_id = ${created.ticketId} AND status IN ('pending', 'started') + `; + assert.equal(live[0]?.count ?? 0, 0); + + const detail = yield* read.getTicketDetail(created.ticketId); + assert.equal(detail?.ticket.currentLaneKey, "done"); + }), + ); + + it.effect("editTicketFieldsUnlocked appends a TicketEdited event", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ws-edit" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + + const created = yield* inLockAndTx( + "b-ws-edit", + engine.createTicketAndEnterUnlocked({ + boardId: "b-ws-edit" as never, + title: "Old title", + destinationLane: "inbox" as never, + }), + ); + + yield* inLockAndTx( + "b-ws-edit", + engine.editTicketFieldsUnlocked(created.ticketId, { + title: "New title", + description: "New description", + }), + ); + + const detail = yield* read.getTicketDetail(created.ticketId); + assert.equal(detail?.ticket.title, "New title"); + assert.equal(detail?.ticket.description, "New description"); + + const events = yield* Stream.runCollect(store.readByTicket(created.ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isDefined(events.find((event) => event.type === "TicketEdited")); + }), + ); + + it.effect("editTicketFieldsUnlocked does not blank the stored title for a whitespace-only title", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ws-blank" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + + const created = yield* inLockAndTx( + "b-ws-blank", + engine.createTicketAndEnterUnlocked({ + boardId: "b-ws-blank" as never, + title: "Keep me", + destinationLane: "inbox" as never, + }), + ); + + // A whitespace-only title must be OMITTED (mirrors locked editTicket): + // with no other field, nothing changes and no event is emitted; the + // stored title stays intact rather than being overwritten to "". + yield* inLockAndTx( + "b-ws-blank", + engine.editTicketFieldsUnlocked(created.ticketId, { title: " " }), + ); + + const detail = yield* read.getTicketDetail(created.ticketId); + assert.equal(detail?.ticket.title, "Keep me"); + + const events = yield* Stream.runCollect(store.readByTicket(created.ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isUndefined(events.find((event) => event.type === "TicketEdited")); + + // A whitespace-only title alongside a real description still drops the + // title but applies the description. + yield* inLockAndTx( + "b-ws-blank", + engine.editTicketFieldsUnlocked(created.ticketId, { + title: " ", + description: "real desc", + }), + ); + const after = yield* read.getTicketDetail(created.ticketId); + assert.equal(after?.ticket.title, "Keep me"); + assert.equal(after?.ticket.description, "real desc"); + }), + ); + + it.effect("Fix 1: editTicketFieldsUnlocked WRITES an empty-string description (clear), distinct from undefined (leave)", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-ws-clear" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + + const created = yield* inLockAndTx( + "b-ws-clear", + engine.createTicketAndEnterUnlocked({ + boardId: "b-ws-clear" as never, + title: "Title", + description: "Has a body", + destinationLane: "inbox" as never, + }), + ); + const before = yield* read.getTicketDetail(created.ticketId); + assert.equal(before?.ticket.description, "Has a body"); + + // A PROVIDED empty-string description is a valid CLEAR: it must emit a + // TicketEdited{description:""} and the projection must show "" — NOT keep + // the old body and NOT be dropped like an empty title. + yield* inLockAndTx( + "b-ws-clear", + engine.editTicketFieldsUnlocked(created.ticketId, { description: "" }), + ); + + const after = yield* read.getTicketDetail(created.ticketId); + assert.equal(after?.ticket.description, ""); + + const events = yield* Stream.runCollect(store.readByTicket(created.ticketId)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + const edited = events.filter((event) => event.type === "TicketEdited"); + assert.isAbove(edited.length, 0); + }), + ); + + it.effect("withBoardAdmissionLock mutually excludes bodies for the same board", () => + Effect.gen(function* () { + const engine = yield* WorkflowEngine; + const inside = yield* Ref.make(0); + const maxConcurrent = yield* Ref.make(0); + const firstEntered = yield* Deferred.make(); + const release = yield* Deferred.make(); + + // Body A enters the admission lock, signals it is inside, then blocks on + // `release`. If the lock did NOT mutually exclude, body B would enter + // concurrently and push the observed concurrency above 1. + const bodyA = engine.withBoardAdmissionLock( + "b-admit-mutex" as never, + Effect.gen(function* () { + const n = yield* Ref.updateAndGet(inside, (c) => c + 1); + yield* Ref.update(maxConcurrent, (m) => Math.max(m, n)); + yield* Deferred.succeed(firstEntered, undefined); + yield* Deferred.await(release); + yield* Ref.update(inside, (c) => c - 1); + }), + ); + + const bodyB = engine.withBoardAdmissionLock( + "b-admit-mutex" as never, + Effect.gen(function* () { + const n = yield* Ref.updateAndGet(inside, (c) => c + 1); + yield* Ref.update(maxConcurrent, (m) => Math.max(m, n)); + yield* Ref.update(inside, (c) => c - 1); + }), + ); + + const fiberA = yield* bodyA.pipe(Effect.forkScoped); + yield* Deferred.await(firstEntered); + // B is launched while A is provably still inside the lock. + const fiberB = yield* bodyB.pipe(Effect.forkScoped); + // Give B a chance to (wrongly) enter if the lock were not exclusive. + yield* Effect.yieldNow; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(fiberA); + yield* Fiber.join(fiberB); + + assert.equal(yield* Ref.get(maxConcurrent), 1); + }), + ); + + it.effect( + "withBoardAdmissionLock serializes an unlocked admit against the public path: exactly one admitted into a WIP-1 lane", + () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-admit-race" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + + // Seed one ticket sitting (admitted) in a NON-target lane so the public + // path can move it into the WIP-1 `inbox` lane. The unlocked path + // creates a second ticket destined for the same `inbox` lane. + const mover = yield* inLockAndTx( + "b-admit-race", + engine.createTicketAndEnterUnlocked({ + boardId: "b-admit-race" as never, + title: "Mover", + destinationLane: "done" as never, + }), + ); + + // PUBLIC path: moveTicket wraps its WIP read-decide in the admission + // lock (admission OUTER) and takes the save lock at commit (INNER). + const publicAdmit = engine.moveTicket(mover.ticketId, "inbox" as never); + + // UNLOCKED path mirrors the source committer: admission lock (OUTER) -> + // save lock (INNER) -> transaction, then the unlocked create+enter. + const unlockedAdmit = engine.withBoardAdmissionLock( + "b-admit-race" as never, + inLockAndTx( + "b-admit-race", + engine.createTicketAndEnterUnlocked({ + boardId: "b-admit-race" as never, + title: "Syncer", + destinationLane: "inbox" as never, + }), + ), + ); + + // Run them concurrently. Because both serialize their WIP read-decide + // under the SAME per-board admission semaphore, exactly one wins + // admission into the WIP-1 lane; the other is queued. Without the shared + // admission lock both could read occupancy=0 and both admit. + yield* Effect.all([publicAdmit, unlockedAdmit], { concurrency: "unbounded" }); + + const admittedCount = yield* read.countAdmittedInLane( + "b-admit-race" as never, + "inbox" as never, + ); + assert.equal(admittedCount, 1); + }), + ); + +}); + +// Fix 1 (stale-token start guard). recoverBoardWip / runLane snapshot a ticket's +// lane+token, then start its pipeline LATER. A user/source move in between can +// change the ticket's current_lane_entry_token, leaving the snapshot stale. +// startPipeline must re-read the live projection and SKIP a start whose +// lane/token no longer matches. We prove it by decorating the read model so +// runLane is handed a STALE token while the live projection (which the guard +// reads via raw SQL) still holds the real, current token: the guard must skip, +// so no pipeline run is ever created for the stale token. +it.effect("startPipeline skips a stale-token start whose ticket has moved on", () => + Effect.gen(function* () { + const staleToken = yield* Ref.make<{ + readonly ticketId: string; + readonly token: string; + } | null>(null); + + // Decorator: requires the real WorkflowReadModel and re-publishes it, + // overriding getTicketDetail to swap in a stale token for the targeted + // ticket. Everything else delegates unchanged. + const StaleReadModel = Layer.effect( + WorkflowReadModel, + Effect.gen(function* () { + const base = yield* WorkflowReadModel; + const override: typeof base.getTicketDetail = (ticketId) => + Effect.gen(function* () { + const detail = yield* base.getTicketDetail(ticketId); + const stale = yield* Ref.get(staleToken); + if (detail === null || stale === null || (ticketId as string) !== stale.ticketId) { + return detail; + } + return { + ...detail, + ticket: { ...detail.ticket, currentLaneEntryToken: stale.token }, + }; + }); + return { ...base, getTicketDetail: override } satisfies typeof base; + }), + ).pipe(Layer.provide(WorkflowReadModelLive)); + + const testLayer = WorkflowEngineLayer.pipe( + Layer.provide(StaleReadModel), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(blockingExecutor), + 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), + ); + + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-stale-start" as never, definition); + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + + // Admit a ticket into the auto `work` lane (start dropped under the + // unlocked path). It now holds the real, current token in `work`. + const created = yield* inLockAndTx( + "b-stale-start", + engine.createTicketAndEnterUnlocked({ + boardId: "b-stale-start" as never, + title: "Moved on", + destinationLane: "work" as never, + }), + ); + + // Point the decorator at this ticket with a token that does NOT match the + // live projection — modelling a move that re-tokened the ticket after the + // snapshot but before the start. + yield* Ref.set(staleToken, { + ticketId: created.ticketId as string, + token: "stale-entry-token", + }); + + // runLane reads the (stale) detail and asks startPipeline to start the + // stale token. The guard re-reads the live token and skips. + yield* engine.runLane(created.ticketId); + yield* Effect.yieldNow; + + const staleRuns = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_pipeline_run + WHERE ticket_id = ${created.ticketId as string} + AND lane_entry_token = 'stale-entry-token' + `; + assert.equal(staleRuns[0]?.count ?? 0, 0); + + // No pipeline run at all was created from the stale start. + const anyRuns = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_pipeline_run + WHERE ticket_id = ${created.ticketId as string} + `; + assert.equal(anyRuns[0]?.count ?? 0, 0); + + // With the stale override cleared, the ticket is still legitimately + // admitted in `work`: runLane now starts the real, current token. + yield* Ref.set(staleToken, null); + const liveDetail = yield* read.getTicketDetail(created.ticketId); + assert.equal(liveDetail?.ticket.currentLaneKey, "work"); + yield* engine.runLane(created.ticketId); + yield* Effect.yieldNow; + const liveRuns = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_pipeline_run + WHERE ticket_id = ${created.ticketId as string} + AND lane_entry_token = ${liveDetail?.ticket.currentLaneEntryToken as string} + `; + assert.isAbove(liveRuns[0]?.count ?? 0, 0); + }).pipe(Effect.provide(testLayer)); + }), +); 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..ecfcf39fb25 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEventCommitter.test.ts @@ -0,0 +1,938 @@ +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 { WorkflowIds } from "../Services/WorkflowIds.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"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; + +const layer = it.layer( + WorkflowEventCommitterLive.pipe( + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + Layer.provideMerge(DeterministicWorkflowIds), + 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; + }); + +interface OutboxRow { + readonly outboxId: string; + readonly ticketId: string; + readonly boardId: string; + readonly sequence: number; + readonly status: string; + readonly attentionKind: string | null; + readonly attentionReason: string | null; + readonly deliveryState: string; + readonly attemptCount: number; +} + +const outboxRows = (ticketId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* sql` + SELECT + outbox_id AS "outboxId", + ticket_id AS "ticketId", + board_id AS "boardId", + sequence, + status, + attention_kind AS "attentionKind", + attention_reason AS "attentionReason", + delivery_state AS "deliveryState", + attempt_count AS "attemptCount" + FROM workflow_notification_outbox + WHERE ticket_id = ${ticketId} + ORDER BY sequence ASC + `; + }); + +const outboxCount = (ticketId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_notification_outbox + 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(DeterministicWorkflowIds), + 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( + // Tagged queries (status diff selects + outbox insert) return empty rows; + // this batch never crosses into a needs-you status so no insert is asserted. + (() => Effect.succeed([])) 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, + listNeedsAttentionTickets: () => Effect.succeed([]), + listDependentTicketIds: () => Effect.succeed([]), + listTicketRouteDecisions: unsupportedEffect, + getTicketPrState: 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"]); + const fakeIds = Layer.succeed(WorkflowIds, { + ticketId: unsupportedEffect, + pipelineRunId: unsupportedEffect, + scriptRunId: unsupportedEffect, + stepRunId: unsupportedEffect, + messageId: unsupportedEffect, + eventId: () => Effect.succeed("evt-fake" as never), + token: unsupportedEffect, + mappingId: unsupportedEffect, + } satisfies WorkflowIds["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(fakeIds), + 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); + }), + ); + + it.effect( + "writes exactly one outbox row when an event flips a ticket into waiting_on_user", + () => + Effect.gen(function* () { + const boardId = "b-outbox-waiting"; + const ticketId = "t-outbox-waiting"; + const committer = yield* WorkflowEventCommitter; + yield* registerBoard(boardId); + yield* insertProjectedTicket({ ticketId, boardId, title: "Waiting", status: "running" }); + + const persisted = yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "e-outbox-waiting" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + stepRunId: "step-outbox-waiting" as never, + waitingReason: "Need input", + providerResponseKind: "user-input", + }, + }); + + const rows = yield* outboxRows(ticketId); + assert.equal(rows.length, 1); + const row = rows[0]!; + assert.equal(row.ticketId, ticketId); + assert.equal(row.boardId, boardId); + assert.equal(row.status, "waiting_on_user"); + assert.equal(row.attentionKind, "waiting_for_input"); + assert.equal(row.attentionReason, "Need input"); + assert.equal(row.deliveryState, "pending"); + assert.equal(row.attemptCount, 0); + // sequence matches the persisted event's sequence (the commit returns it) + const eventRows = yield* (yield* SqlClient.SqlClient)<{ readonly sequence: number }>` + SELECT sequence FROM workflow_events WHERE ticket_id = ${ticketId} + `; + assert.equal(row.sequence, eventRows[0]?.sequence); + assert.isNotNull(persisted); + }), + ); + + it.effect("writes a blocked outbox row when a ticket is blocked", () => + Effect.gen(function* () { + const boardId = "b-outbox-blocked"; + const ticketId = "t-outbox-blocked"; + const committer = yield* WorkflowEventCommitter; + yield* registerBoard(boardId); + yield* insertProjectedTicket({ ticketId, boardId, title: "Blocked", status: "running" }); + + yield* committer.commit({ + type: "TicketBlocked", + eventId: "e-outbox-blocked" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { reason: "dependency missing" }, + }); + + const rows = yield* outboxRows(ticketId); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.status, "blocked"); + assert.equal(rows[0]?.attentionKind, "blocked"); + assert.equal(rows[0]?.attentionReason, "dependency missing"); + }), + ); + + it.effect("writes no outbox row when an event does not cross into a needs-you state", () => + Effect.gen(function* () { + const boardId = "b-outbox-no-cross"; + const ticketId = "t-outbox-no-cross"; + const committer = yield* WorkflowEventCommitter; + yield* registerBoard(boardId); + yield* insertProjectedTicket({ ticketId, boardId, title: "Plain", status: "running" }); + + // A plain lane move keeps the ticket out of any needs-you status. + yield* committer.commit({ + type: "TicketMovedToLane", + eventId: "e-outbox-no-cross" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + toLane: "impl" as never, + laneEntryToken: "tok-no-cross" as never, + reason: "routed", + }, + }); + + assert.equal(yield* outboxCount(ticketId), 0); + }), + ); + + it.effect("does not write a second outbox row when the ticket stays in the same needs-you status", () => + Effect.gen(function* () { + const boardId = "b-outbox-stay"; + const ticketId = "t-outbox-stay"; + const committer = yield* WorkflowEventCommitter; + yield* registerBoard(boardId); + yield* insertProjectedTicket({ ticketId, boardId, title: "Stay", status: "running" }); + + // First transition into waiting_on_user → one row. + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "e-outbox-stay-1" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + stepRunId: "step-outbox-stay" as never, + waitingReason: "First", + providerResponseKind: "user-input", + }, + }); + assert.equal(yield* outboxCount(ticketId), 1); + + // A second StepAwaitingUser while already waiting_on_user → still one row + // (newStatus is needs-you but newStatus === prevStatus, so no new transition). + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "e-outbox-stay-2" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { + stepRunId: "step-outbox-stay" as never, + waitingReason: "Second", + providerResponseKind: "user-input", + }, + }); + assert.equal(yield* outboxCount(ticketId), 1); + }), + ); + + it.effect( + "supersedes the prior pending row when a ticket rapidly transitions to a new needs-you state", + () => + Effect.gen(function* () { + const boardId = "b-outbox-supersede"; + const ticketId = "t-outbox-supersede"; + const committer = yield* WorkflowEventCommitter; + yield* registerBoard(boardId); + yield* insertProjectedTicket({ ticketId, boardId, title: "Supersede", status: "running" }); + + // 1) waiting_on_user → outbox row A (pending) + yield* committer.commit({ + type: "StepAwaitingUser", + eventId: "e-outbox-supersede-1" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { + stepRunId: "step-outbox-supersede" as never, + waitingReason: "approve deploy?", + providerResponseKind: "request", + }, + }); + // 2) blocked → outbox row B (pending); row A must be superseded + yield* committer.commit({ + type: "TicketBlocked", + eventId: "e-outbox-supersede-2" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:02.000Z" as never, + payload: { reason: "merge conflict" }, + }); + + const rows = yield* outboxRows(ticketId); + assert.equal(rows.length, 2); + const pending = rows.filter((row) => row.deliveryState === "pending"); + const superseded = rows.filter((row) => row.deliveryState === "superseded"); + // Exactly one pending row remains — the latest (blocked) transition. + assert.equal(pending.length, 1); + assert.equal(pending[0]?.status, "blocked"); + assert.equal(pending[0]?.attentionKind, "blocked"); + assert.equal(pending[0]?.attentionReason, "merge conflict"); + // The earlier waiting row is superseded (never delivered). + assert.equal(superseded.length, 1); + assert.equal(superseded[0]?.status, "waiting_on_user"); + // The pending row is the highest sequence. + assert.isAbove(pending[0]!.sequence, superseded[0]!.sequence); + }), + ); + + it.effect( + "the supersede guard does not strand the current row on idempotent re-projection of the same sequence", + () => + Effect.gen(function* () { + // The committer's event store enforces UNIQUE(event_id), so a duplicate + // commit fails at append before reaching the outbox path. This test instead + // exercises the load-bearing `sequence != persisted.sequence` guard SQL + // directly: a row already pending at sequence S must survive a re-run of the + // supersede+insert pair for that SAME sequence S, while a genuinely older + // pending row (different sequence) gets superseded. + const ticketId = "t-outbox-idempotent"; + const boardId = "b-outbox-idempotent"; + const sql = yield* SqlClient.SqlClient; + + const insertPending = (outboxId: string, sequence: number) => + sql` + INSERT OR IGNORE INTO workflow_notification_outbox ( + outbox_id, ticket_id, board_id, sequence, status, + attention_kind, attention_reason, delivery_state, attempt_count, created_at + ) VALUES ( + ${outboxId}, ${ticketId}, ${boardId}, ${sequence}, 'waiting_on_user', + 'waiting_for_input', 'r', 'pending', 0, '2026-06-07T00:00:00.000Z' + ) + `; + const supersedeOthers = (sequence: number) => + sql` + UPDATE workflow_notification_outbox + SET delivery_state = 'superseded' + WHERE ticket_id = ${ticketId} + AND delivery_state = 'pending' + AND sequence != ${sequence} + `; + + // Older pending row at sequence 1, current pending row at sequence 2. + yield* insertPending("ob-old", 1); + yield* insertPending("ob-current", 2); + + // Re-projection of the SAME event (sequence 2): supersede others, then + // re-insert (ignored as a duplicate). The current row must stay pending. + yield* supersedeOthers(2); + yield* insertPending("ob-current", 2); + + const rows = yield* outboxRows(ticketId); + assert.equal(rows.length, 2); + const current = rows.find((row) => row.sequence === 2); + const older = rows.find((row) => row.sequence === 1); + // The != guard protected the current sequence's own row. + assert.equal(current?.deliveryState, "pending"); + // Genuinely older pending row was superseded. + assert.equal(older?.deliveryState, "superseded"); + }), + ); +}); + +it.effect( + "rolls back both the event and the outbox row when projection fails inside the commit transaction", + () => + Effect.gen(function* () { + const boardId = "b-outbox-atomic" as BoardId; + const ticketId = "t-outbox-atomic"; + + const committer = yield* WorkflowEventCommitter; + + // Match the other integration tests' setup: register the board (registry + + // projection_board) then seed a running projection_ticket row. + yield* registerBoard(boardId); + yield* insertProjectedTicket({ ticketId, boardId, title: "Atomic", status: "running" }); + + const exit = yield* Effect.exit( + committer.commit({ + type: "TicketBlocked", + eventId: "e-outbox-atomic" as never, + ticketId: ticketId as never, + occurredAt: "2026-06-07T00:00:01.000Z" as never, + payload: { reason: "boom" }, + }), + ); + assert.isTrue(Exit.isFailure(exit)); + + // The failing projection must roll back the appended event AND prevent any + // outbox row from being written (single-commit path is transactional). + assert.equal(yield* workflowEventCount(ticketId), 0); + assert.equal(yield* outboxCount(ticketId), 0); + }).pipe( + Effect.provide( + WorkflowEventCommitterLive.pipe( + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowBoardSaveLocksLive), + // Replace the real projection pipeline with one that always fails so the + // surrounding transaction must roll back the appended event. + Layer.provideMerge( + Layer.succeed(WorkflowProjectionPipeline, { + projectEvent: () => Effect.fail("projection blew up") as never, + } satisfies WorkflowProjectionPipeline["Service"]), + ), + Layer.provideMerge(DeterministicWorkflowIds), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), + ), + ), +); diff --git a/apps/server/src/workflow/Layers/WorkflowEventCommitter.ts b/apps/server/src/workflow/Layers/WorkflowEventCommitter.ts new file mode 100644 index 00000000000..bbd5966e986 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowEventCommitter.ts @@ -0,0 +1,414 @@ +import type { BoardId, BoardTicketView, LaneKey, TicketId, TicketStatus } 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 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 { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { WorkflowProjectionPipeline } from "../Services/WorkflowProjectionPipeline.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +// Statuses that mean "a human needs to act". Crossing INTO one of these (and only +// into — not staying) emits exactly one durable notification outbox row. +const NEEDS_YOU_STATUSES = new Set(["waiting_on_user", "blocked"]); + +// Only these two event types can ever project a needs-you status (per the +// projection audit: StepAwaitingUser → waiting_on_user, TicketBlocked → blocked). +// Every other event skips the status-diff reads entirely, keeping the hot step +// loop (StepStarted/StepCompleted/StepRefsCaptured/PipelineStarted/...) free of +// the two extra projection_ticket point-reads. +const NOTIFIABLE_EVENT_TYPES = new Set(["StepAwaitingUser", "TicketBlocked"]); + +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 ids = yield* WorkflowIds; + 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; + }); + + // Shared by both commit paths. Runs inside a transaction (single-commit wraps it + // in sql.withTransaction; commitMany wraps the whole batch). Diffs the ticket + // status across the projection and, when the event crosses INTO a needs-you + // status, writes one durable outbox row keyed by the event sequence (UNIQUE). + const appendAndProjectUnlocked = (event: CommitEvent) => + Effect.gen(function* () { + // Fast path: events that can never set a needs-you status skip the two + // projection_ticket point-reads and the insert entirely. + if (!NOTIFIABLE_EVENT_TYPES.has(event.type)) { + const persisted = yield* store.append(event); + yield* pipeline.projectEvent(persisted); + return persisted; + } + const prevRows = yield* sql<{ readonly status: string }>` + SELECT status FROM projection_ticket WHERE ticket_id = ${event.ticketId} + `; + const prevStatus = prevRows[0]?.status ?? null; + const persisted = yield* store.append(event); + yield* pipeline.projectEvent(persisted); + const nextRows = yield* sql<{ + readonly status: string; + readonly boardId: string; + readonly attentionKind: string | null; + readonly attentionReason: string | null; + }>` + SELECT status, board_id AS "boardId", + attention_kind AS "attentionKind", attention_reason AS "attentionReason" + FROM projection_ticket WHERE ticket_id = ${event.ticketId} + `; + const next = nextRows[0]; + if ( + next !== undefined && + NEEDS_YOU_STATUSES.has(next.status) && + next.status !== prevStatus + ) { + const outboxId = yield* ids.eventId(); + const createdAt = yield* nowIso; + // Supersede any prior PENDING rows for this ticket so at most one pending + // row (the latest transition) ever reaches the dispatcher. Without this, a + // ticket that rapidly transitions through multiple needs-you states within + // one sweep window would push a stale earlier row's content. The + // `sequence != persisted.sequence` guard is load-bearing: an idempotent + // re-projection of the SAME event (row already pending at this sequence) + // must NOT supersede its own row and strand it — only genuinely older + // pending rows (different sequence) get superseded. + yield* sql` + UPDATE workflow_notification_outbox + SET delivery_state = 'superseded' + WHERE ticket_id = ${event.ticketId} + AND delivery_state = 'pending' + AND sequence != ${persisted.sequence} + `; + yield* sql` + INSERT OR IGNORE INTO workflow_notification_outbox ( + outbox_id, ticket_id, board_id, sequence, status, + attention_kind, attention_reason, delivery_state, attempt_count, created_at + ) VALUES ( + ${outboxId}, ${event.ticketId}, ${next.boardId}, ${persisted.sequence}, ${next.status}, + ${next.attentionKind}, ${next.attentionReason}, 'pending', 0, ${createdAt} + ) + `; + } + 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; + } + // Lock OUTSIDE, transaction INSIDE. appendAndProjectUnlocked never opens + // its own transaction, so commitMany (which already wraps the batch in a + // single withTransaction) does not nest. + return yield* sql + .withTransaction(appendAndProjectUnlocked(event)) + .pipe(Effect.mapError(toCommitterError)); + }), + ); + }); + + 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 } + : {}), + ...(ticket.pr === undefined ? {} : { pr: ticket.pr }), + } 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 }, + ); + }); + + // CALLER MUST already hold the board save lock for every affected board AND be + // inside an open sql.withTransaction. This intentionally does NOT take the lock + // or open a transaction (it would deadlock / nest) and does NOT publish ticket + // views — it only appends+projects each event in order, returning the persisted + // rows for the caller to publish after releasing the lock. + const appendManyUnlocked: WorkflowEventCommitterShape["appendManyUnlocked"] = (events) => + Effect.forEach(events, (event) => appendAndProjectUnlocked(event), { + concurrency: 1, + }).pipe(Effect.mapError(toCommitterError)); + + // Public, post-lock ticket-view publish for batch syncers driving + // appendManyUnlocked (which does NOT publish). Mirrors publishTicket: emits the + // ticket's current view and, when requested (a terminal/lane move), republishes + // dependents so waiting badges stay live. + const publishTicketView_: WorkflowEventCommitterShape["publishTicketView"] = ( + ticketId, + options, + ) => + Effect.gen(function* () { + yield* publishTicketView(ticketId); + if (options?.republishDependents === true) { + const dependents = yield* readModel + .listDependentTicketIds(ticketId) + .pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach(dependents, (dependent) => publishTicketView(dependent as never), { + discard: true, + }); + } + }); + + return { + commit, + commitMany, + appendManyUnlocked, + publishTicketView: publishTicketView_, + } 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..d34275d17b6 --- /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(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + 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(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + 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(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + 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(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + 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(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + 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(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + 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(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + 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..4a32c8170ac --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowFileLoader.ts @@ -0,0 +1,199 @@ +// @effect-diagnostics nodeBuiltinImport:off +import path from "node:path"; + +import { ProviderInstanceId, WorkflowDefinition, WorkflowRpcError } from "@t3tools/contracts"; +import { AsanaSelector, GithubSelector } from "@t3tools/contracts/workSource"; +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, + selectorSchemaFor: (p) => + p === "github" ? GithubSelector : p === "asana" ? AsanaSelector : 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/WorkflowGitHubPoller.test.ts b/apps/server/src/workflow/Layers/WorkflowGitHubPoller.test.ts new file mode 100644 index 00000000000..65109d5fc93 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowGitHubPoller.test.ts @@ -0,0 +1,983 @@ +// @effect-diagnostics globalTimers:off +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 { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { + GitHubPort, + type GitHubPrDetail, + type GitHubPortShape, + type GitHubReviewItem, +} from "../Services/GitHubPort.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowGitHubPoller } from "../Services/WorkflowGitHubPoller.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { MAX_TICKET_MESSAGE_BODY_LENGTH } from "../ticketMessageBody.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 { makeWorkflowGitHubPollerLive } from "./WorkflowGitHubPoller.ts"; +import { DeterministicWorkflowIds } from "./WorkflowIds.ts"; +import { WorkflowRoutingContextBuilderLive } from "./WorkflowRoutingContextBuilder.ts"; + +// --- Scriptable gh stub ----------------------------------------------------- +// +// Tests push a SEQUENCE of prDetail responses per prNumber; each prDetail() +// call pops the next one (the last is sticky). failingCheckLogs / review +// feedback are configured per prNumber too. A prNumber can be flagged to fail +// prDetail (gh error during observe). + +interface PrScript { + details: GitHubPrDetail[]; + failingLogs: string | null; + feedback: GitHubReviewItem[]; + failPrDetail: boolean; + // Optional side effect run when failingCheckLogs is invoked (during observe, + // before phase 1) — used to simulate a concurrent delete landing mid-sweep. + onFailingCheckLogs?: (sql: SqlClient.SqlClient) => Effect.Effect; +} + +const scripts = new Map(); +// Optional wrapper to force ingestExternalEvent to fail transiently N times. +let ingestFailureCount = 0; +// When true, ingestExternalEvent always fails with a non-terminal, +// non-transient error (poison-pill simulation). +let ingestAlwaysFails = false; +// When true, postTicketMessage always fails (persistently-failing post +// poison-pill simulation). +let postAlwaysFails = false; + +const resetScripts = () => { + scripts.clear(); + ingestFailureCount = 0; + ingestAlwaysFails = false; + postAlwaysFails = false; +}; + +const scriptPr = (prNumber: number, script: Partial) => { + scripts.set(prNumber, { + details: script.details ?? [], + failingLogs: script.failingLogs ?? null, + feedback: script.feedback ?? [], + failPrDetail: script.failPrDetail ?? false, + ...(script.onFailingCheckLogs === undefined + ? {} + : { onFailingCheckLogs: script.onFailingCheckLogs }), + }); +}; + +const GitHubPortStub = Layer.effect( + GitHubPort, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return { + preflight: () => Effect.succeed({ ok: true as const }), + resolveRemote: () => Effect.succeed({ remoteName: "origin", repo: "acme/widgets" }), + defaultBranch: () => Effect.succeed("main"), + openPr: () => Effect.succeed({ number: 0, url: "", adopted: false }), + prDetail: ({ prNumber }) => + Effect.suspend(() => { + const script = scripts.get(prNumber); + if (script === undefined || script.failPrDetail) { + return Effect.fail( + new WorkflowEventStoreError({ message: `gh prDetail failed for #${prNumber}` }), + ); + } + const next = script.details.length > 1 ? script.details.shift()! : script.details[0]; + if (next === undefined) { + return Effect.fail( + new WorkflowEventStoreError({ message: `no scripted detail for #${prNumber}` }), + ); + } + return Effect.succeed(next); + }), + findPrForBranch: () => Effect.succeed(null), + mergePr: () => Effect.succeed({ ok: true as const }), + failingCheckLogs: ({ prNumber }) => + Effect.gen(function* () { + const script = scripts.get(prNumber); + if (script?.onFailingCheckLogs !== undefined) { + yield* script.onFailingCheckLogs(sql); + } + return script?.failingLogs ?? null; + }), + listReviewFeedback: ({ prNumber }) => + Effect.sync(() => scripts.get(prNumber)?.feedback ?? []), + } satisfies GitHubPortShape; + }), +); + +const succeedingExecutor = Layer.succeed(StepExecutor, { + execute: () => Effect.succeed({ _tag: "completed" as const }), +} satisfies StepExecutorShape); + +// Wrap the real engine so ingestExternalEvent can be made to fail transiently +// for the retry test, without disturbing every other engine method. +const EngineWrapper = Layer.effect( + WorkflowEngine, + Effect.gen(function* () { + const inner = yield* WorkflowEngine; + return { + ...inner, + postTicketMessage: (input) => + Effect.suspend(() => + postAlwaysFails + ? Effect.fail(new WorkflowEventStoreError({ message: "poison-pill post failure" })) + : inner.postTicketMessage(input), + ), + ingestExternalEvent: (input) => + Effect.suspend(() => { + if (ingestAlwaysFails) { + return Effect.fail( + new WorkflowEventStoreError({ message: "poison-pill ingest failure" }), + ); + } + if (ingestFailureCount > 0) { + ingestFailureCount -= 1; + return Effect.fail( + new WorkflowEventStoreError({ message: "transient ingest failure" }), + ); + } + return inner.ingestExternalEvent(input); + }), + } satisfies typeof inner; + }), +).pipe(Layer.provide(WorkflowEngineLayer)); + +const baseEngine = 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), +); + +// Engine + persistence + gh stub all share one SqlClient (the in-memory DB), +// so the poller, the wrapped engine and the gh stub's side-effecting SQL all +// hit the same database. +const supportLayer = Layer.mergeAll(EngineWrapper, GitHubPortStub).pipe( + Layer.provideMerge(baseEngine), +); + +const pollerLayer = makeWorkflowGitHubPollerLive({ + sweepIntervalMs: 60_000, + maxTicketsPerSweep: 20, +}).pipe(Layer.provideMerge(supportLayer)); + +const layer = it.layer(pollerLayer); + +// --- helpers ---------------------------------------------------------------- + +const prBoard = { + name: "pr-flow", + lanes: [ + { + key: "review", + name: "Review", + entry: "manual", + onEvent: [ + { name: "ci.failed", to: "work" }, + { name: "pr.changes_requested", to: "work" }, + { name: "pr.merged", to: "done" }, + { name: "pr.closed", to: "done" }, + ], + }, + { + key: "work", + name: "Work", + entry: "manual", + onEvent: [ + { name: "ci.failed", to: "work" }, + { name: "pr.changes_requested", to: "work" }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const seedPrState = ( + sql: SqlClient.SqlClient, + input: { + ticketId: string; + prNumber: number; + lastHeadSha?: string | null; + lastCiState?: string | null; + lastReviewDecision?: string | null; + lastCommentCursor?: string | null; + prState?: string; + }, +) => + sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, + pr_state, last_head_sha, last_ci_state, last_review_decision, + last_comment_cursor, updated_at + ) VALUES ( + ${input.ticketId}, + ${input.prNumber}, + ${`https://github.com/acme/widgets/pull/${input.prNumber}`}, + ${`workflow/${input.ticketId}`}, + 'origin', + 'acme/widgets', + ${input.prState ?? "open"}, + ${input.lastHeadSha ?? null}, + ${input.lastCiState ?? null}, + ${input.lastReviewDecision ?? null}, + ${input.lastCommentCursor ?? null}, + '2026-06-12T00:00:00.000Z' + ) + `; + +const detail = (over: Partial): GitHubPrDetail => ({ + number: over.number ?? 1, + url: over.url ?? "https://github.com/acme/widgets/pull/1", + state: over.state ?? "open", + headSha: over.headSha ?? "sha1", + reviewDecision: over.reviewDecision ?? "none", + ciState: over.ciState ?? "pending", +}); + +interface ObservationRow { + readonly dedupKey: string; + readonly eventName: string; + readonly status: string; + readonly messageBody: string | null; + readonly payloadJson: string; + readonly attemptCount: number; +} + +const observationsFor = (sql: SqlClient.SqlClient, ticketId: string) => + sql` + SELECT + dedup_key AS "dedupKey", + event_name AS "eventName", + status, + message_body AS "messageBody", + payload_json AS "payloadJson", + attempt_count AS "attemptCount" + FROM workflow_pr_observation + WHERE ticket_id = ${ticketId} + ORDER BY created_at ASC, observation_id ASC + `; + +// it.layer shares one in-memory DB across the suite. Clear the PR tables at the +// start of each test so sweep-wide totals (observedTickets / recordedObservations) +// reflect only this test's tickets. +const resetDb = (sql: SqlClient.SqlClient) => + Effect.gen(function* () { + yield* sql`DELETE FROM workflow_pr_observation`; + yield* sql`DELETE FROM workflow_pr_state`; + }); + +const prStateFor = (sql: SqlClient.SqlClient, ticketId: string) => + sql<{ + readonly prState: string; + readonly lastCiState: string | null; + readonly lastReviewDecision: string | null; + readonly lastCommentCursor: string | null; + readonly lastHeadSha: string | null; + }>` + SELECT + pr_state AS "prState", + last_ci_state AS "lastCiState", + last_review_decision AS "lastReviewDecision", + last_comment_cursor AS "lastCommentCursor", + last_head_sha AS "lastHeadSha" + FROM workflow_pr_state + WHERE ticket_id = ${ticketId} + `.pipe(Effect.map((rows) => rows[0])); + +layer("WorkflowGitHubPoller", (it) => { + it.effect("1. ci pending -> failure: observation, message, ci.failed ingested, applied", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b1" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b1" as never, + title: "PR ticket", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 1, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + scriptPr(1, { + details: [detail({ number: 1, headSha: "sha1", ciState: "failure" })], + failingLogs: "boom: secret=ghp_aaaaaaaaaaaaaaaaaaaaaaaa\nassertion failed", + }); + + const result = yield* poller.sweep(); + assert.equal(result.recordedObservations, 1); + assert.equal(result.appliedObservations, 1); + + const obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs.length, 1); + assert.equal(obs[0]!.dedupKey, `ci:${ticketId as string}:sha1:failure`); + assert.equal(obs[0]!.eventName, "ci.failed"); + assert.equal(obs[0]!.status, "applied"); + // message_body cleared after posting. + assert.equal(obs[0]!.messageBody, null); + // token redacted in the persisted payload summary. + assert.isFalse(obs[0]!.payloadJson.includes("ghp_aaaa")); + + // Message posted to the discussion (redacted). + const messages = yield* read.listTicketMessages(ticketId as never); + assert.equal(messages.length, 1); + assert.isTrue(messages[0]!.body.includes("[redacted]")); + assert.isFalse(messages[0]!.body.includes("ghp_aaaa")); + + // ci.failed routed review -> work. + const ticketDetail = yield* read.getTicketDetail(ticketId as never); + assert.equal(ticketDetail?.ticket.currentLaneKey, "work"); + + const state = yield* prStateFor(sql, ticketId as string); + assert.equal(state?.lastCiState, "failure"); + }), + ); + + it.effect("2. same prDetail observed twice -> no second observation (dedup)", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b2" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b2" as never, + title: "dedup", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 2, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + scriptPr(2, { + details: [detail({ number: 2, headSha: "sha1", ciState: "success" })], + }); + + const first = yield* poller.sweep(); + assert.equal(first.recordedObservations, 1); + const second = yield* poller.sweep(); + assert.equal(second.recordedObservations, 0); + + const obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs.length, 1); + assert.equal(obs[0]!.dedupKey, `ci:${ticketId as string}:sha1:success`); + }), + ); + + it.effect("3. new head sha after failure -> fresh ci: fires again", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b3" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b3" as never, + title: "newsha", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 3, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + // First sweep: sha1 failure. Second sweep: sha2 failure. + scriptPr(3, { + details: [ + detail({ number: 3, headSha: "sha1", ciState: "failure" }), + detail({ number: 3, headSha: "sha2", ciState: "failure" }), + ], + failingLogs: "still broken", + }); + + yield* poller.sweep(); + yield* poller.sweep(); + + const obs = yield* observationsFor(sql, ticketId as string); + const keys = obs.map((row) => row.dedupKey).sort(); + assert.deepEqual(keys, [ + `ci:${ticketId as string}:sha1:failure`, + `ci:${ticketId as string}:sha2:failure`, + ]); + }), + ); + + it.effect("4. transient ingest failure stays pending, re-drives next sweep", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b4" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b4" as never, + title: "retry", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 4, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + scriptPr(4, { + details: [detail({ number: 4, headSha: "sha1", ciState: "success" })], + }); + + // First ingest attempt fails transiently. + ingestFailureCount = 1; + const first = yield* poller.sweep(); + assert.equal(first.recordedObservations, 1); + // Observation recorded but NOT applied (ingest failed, left pending). + assert.equal(first.appliedObservations, 0); + let obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "pending"); + + // Second sweep: observe is a no-op (dedup) but phase 2 re-drives pending. + const second = yield* poller.sweep(); + assert.equal(second.recordedObservations, 0); + assert.equal(second.appliedObservations, 1); + obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "applied"); + }), + ); + + it.effect("5. changes_requested with 2 feedback items -> 2 messages + 1 routing event", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b5" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b5" as never, + title: "review", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 5, + lastHeadSha: "sha1", + lastReviewDecision: "none", + }); + scriptPr(5, { + details: [ + detail({ + number: 5, + headSha: "sha1", + ciState: "pending", + reviewDecision: "changes_requested", + }), + ], + feedback: [ + { + id: "c1", + author: "alice", + body: "fix this", + submittedAt: "2026-06-12T01:00:00.000Z", + }, + { + id: "c2", + author: "bob", + body: "and that", + submittedAt: "2026-06-12T02:00:00.000Z", + }, + ], + }); + + const result = yield* poller.sweep(); + // 2 comment observations (with body) + 1 routing observation. + assert.equal(result.recordedObservations, 3); + + const obs = yield* observationsFor(sql, ticketId as string); + const keys = obs.map((row) => row.dedupKey).sort(); + assert.deepEqual(keys, [ + `comment:${ticketId as string}:c1`, + `comment:${ticketId as string}:c2`, + `review:${ticketId as string}:sha1:changes_requested`, + ]); + const withBody = obs.filter((row) => row.eventName === "pr.changes_requested"); + assert.equal(withBody.length, 3); + + // 2 messages posted. + const messages = yield* read.listTicketMessages(ticketId as never); + assert.equal(messages.length, 2); + assert.isTrue(messages.some((m) => m.body.includes("**@alice**"))); + assert.isTrue(messages.some((m) => m.body.includes("**@bob**"))); + + // Cursor advanced to newest feedback. + const state = yield* prStateFor(sql, ticketId as string); + assert.equal(state?.lastCommentCursor, "2026-06-12T02:00:00.000Z"); + assert.equal(state?.lastReviewDecision, "changes_requested"); + + // Routed to work. + const ticketDetail = yield* read.getTicketDetail(ticketId as never); + assert.equal(ticketDetail?.ticket.currentLaneKey, "work"); + + // Re-sweep adds nothing (same detail, cursor caught up). + scriptPr(5, { + details: [ + detail({ + number: 5, + headSha: "sha1", + ciState: "pending", + reviewDecision: "changes_requested", + }), + ], + feedback: [ + { + id: "c1", + author: "alice", + body: "fix this", + submittedAt: "2026-06-12T01:00:00.000Z", + }, + { + id: "c2", + author: "bob", + body: "and that", + submittedAt: "2026-06-12T02:00:00.000Z", + }, + ], + }); + const reSweep = yield* poller.sweep(); + assert.equal(reSweep.recordedObservations, 0); + }), + ); + + it.effect("6. merged -> pr.merged ingested, pr_state merged, not scanned next sweep", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b6" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b6" as never, + title: "merge", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 6, + lastHeadSha: "sha1", + lastCiState: "success", + lastReviewDecision: "approved", + }); + scriptPr(6, { + details: [ + detail({ + number: 6, + headSha: "sha1", + ciState: "success", + reviewDecision: "approved", + state: "merged", + }), + ], + }); + + const result = yield* poller.sweep(); + assert.equal(result.observedTickets, 1); + + const obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs.length, 1); + assert.equal(obs[0]!.dedupKey, `lifecycle:${ticketId as string}:merged`); + assert.equal(obs[0]!.eventName, "pr.merged"); + assert.equal(obs[0]!.status, "applied"); + + const state = yield* prStateFor(sql, ticketId as string); + assert.equal(state?.prState, "merged"); + + // Next sweep: ticket no longer watched (pr_state != open). + const second = yield* poller.sweep(); + assert.equal(second.observedTickets, 0); + }), + ); + + it.effect("7. ticket deleted between observe and phase 1 -> recheck skips, no rows", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b7" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b7" as never, + title: "deleted", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 7, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + // The ticket IS in the watched set when observe selects it. prDetail + // returns failure, so observe calls failingCheckLogs — we hook that call + // to delete the pr_state row, simulating a concurrent retention/board + // delete landing AFTER the watched-select but BEFORE phase 1's + // in-transaction recheck. The recheck must then find no open row and write + // nothing — even though observe produced an observation in memory. + scriptPr(7, { + details: [detail({ number: 7, headSha: "sha1", ciState: "failure" })], + failingLogs: "boom", + onFailingCheckLogs: (s) => + s`DELETE FROM workflow_pr_state WHERE ticket_id = ${ticketId as string}`.pipe( + Effect.asVoid, + Effect.orDie, + ), + }); + + const result = yield* poller.sweep(); + // Observe selected + ran (1), but the in-tx recheck found the row gone and + // wrote nothing. + assert.equal(result.observedTickets, 1); + assert.equal(result.recordedObservations, 0); + const obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs.length, 0); + }), + ); + + it.effect("8. gh error during observe -> sweep logs + continues, fiber survives", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b8" as never, prBoard); + const failTicket = yield* engine.createTicket({ + boardId: "b8" as never, + title: "fail", + initialLane: "review" as never, + }); + const okTicket = yield* engine.createTicket({ + boardId: "b8" as never, + title: "ok", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: failTicket as string, + prNumber: 81, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + yield* seedPrState(sql, { + ticketId: okTicket as string, + prNumber: 82, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + scriptPr(81, { failPrDetail: true }); + scriptPr(82, { + details: [detail({ number: 82, headSha: "sha1", ciState: "success" })], + }); + + // sweep() returns without throwing. + const result = yield* poller.sweep(); + assert.equal(result.observedTickets, 2); + assert.equal(result.failedTickets, 1); + // The healthy ticket still got its observation + ingest. + assert.equal(result.recordedObservations, 1); + + const okObs = yield* observationsFor(sql, okTicket as string); + assert.equal(okObs.length, 1); + assert.equal(okObs[0]!.dedupKey, `ci:${okTicket as string}:sha1:success`); + + const failObs = yield* observationsFor(sql, failTicket as string); + assert.equal(failObs.length, 0); + }), + ); + + it.effect("9. two tickets on one board both merge -> distinct lifecycle obs + both ingest", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b9" as never, prBoard); + const ticketA = yield* engine.createTicket({ + boardId: "b9" as never, + title: "A", + initialLane: "review" as never, + }); + const ticketB = yield* engine.createTicket({ + boardId: "b9" as never, + title: "B", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketA as string, + prNumber: 91, + lastHeadSha: "sha1", + }); + yield* seedPrState(sql, { + ticketId: ticketB as string, + prNumber: 92, + lastHeadSha: "sha1", + }); + // Both PRs reach merged. Bare `lifecycle:merged` keys would collide on the + // table-wide UNIQUE dedup_key, dropping B's observation while still + // flipping B's pr_state to merged — stranding B. Per-ticket keys avoid it. + scriptPr(91, { details: [detail({ number: 91, headSha: "sha1", state: "merged" })] }); + scriptPr(92, { details: [detail({ number: 92, headSha: "sha1", state: "merged" })] }); + + const result = yield* poller.sweep(); + assert.equal(result.recordedObservations, 2); + + const obsA = yield* observationsFor(sql, ticketA as string); + const obsB = yield* observationsFor(sql, ticketB as string); + assert.equal(obsA.length, 1); + assert.equal(obsB.length, 1); + assert.equal(obsA[0]!.dedupKey, `lifecycle:${ticketA as string}:merged`); + assert.equal(obsB[0]!.dedupKey, `lifecycle:${ticketB as string}:merged`); + // BOTH pr.merged events ingested (the bug dropped B's). + assert.equal(obsA[0]!.status, "applied"); + assert.equal(obsB[0]!.status, "applied"); + + const detailA = yield* read.getTicketDetail(ticketA as never); + const detailB = yield* read.getTicketDetail(ticketB as never); + assert.equal(detailA?.ticket.currentLaneKey, "done"); + assert.equal(detailB?.ticket.currentLaneKey, "done"); + }), + ); + + it.effect("10. poison-pill ingest: retried up to 5 times then marked failed", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b10" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b10" as never, + title: "poison", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 10, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + scriptPr(10, { + details: [detail({ number: 10, headSha: "sha1", ciState: "success" })], + }); + + // Ingest always fails with a non-terminal, non-transient error. + ingestAlwaysFails = true; + + // Sweep 1 records the observation; ingest fails -> attempt_count = 1, pending. + const first = yield* poller.sweep(); + assert.equal(first.recordedObservations, 1); + assert.equal(first.appliedObservations, 0); + let obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "pending"); + assert.equal(obs[0]!.attemptCount, 1); + + // Sweeps 2..4: still pending, attempt_count climbs (no new observation). + for (let i = 2; i <= 4; i += 1) { + yield* poller.sweep(); + obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "pending"); + assert.equal(obs[0]!.attemptCount, i); + } + + // Sweep 5: 5th failed attempt hits the ceiling -> marked 'failed'. + yield* poller.sweep(); + obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "failed"); + assert.equal(obs[0]!.attemptCount, 5); + + // Sweep 6: 'failed' is no longer drained -> attempt_count frozen at 5. + const sixth = yield* poller.sweep(); + assert.equal(sixth.appliedObservations, 0); + obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "failed"); + assert.equal(obs[0]!.attemptCount, 5); + }), + ); + + it.effect("11. oversized redacted message body posts (capped <= body limit, no throw)", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b11" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b11" as never, + title: "huge log", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 11, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + // A failing-check log far larger than the ticket body limit. redactAndCap + // (truncateKeepingTail with the marker INCLUDED in the budget) must bring + // it under MAX_TICKET_MESSAGE_BODY_LENGTH so postTicketMessage accepts it. + scriptPr(11, { + details: [detail({ number: 11, headSha: "sha1", ciState: "failure" })], + failingLogs: "x".repeat(MAX_TICKET_MESSAGE_BODY_LENGTH * 3), + }); + + const result = yield* poller.sweep(); + // Recorded AND applied — the post did not throw, the event ingested. + assert.equal(result.recordedObservations, 1); + assert.equal(result.appliedObservations, 1); + + const obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "applied"); + assert.equal(obs[0]!.attemptCount, 0); + + // The message landed in the discussion and fits under the body limit. + const messages = yield* read.listTicketMessages(ticketId as never); + assert.equal(messages.length, 1); + assert.isTrue(messages[0]!.body.length <= MAX_TICKET_MESSAGE_BODY_LENGTH); + assert.isTrue(messages[0]!.body.startsWith("…[truncated]\n")); + + // Routed review -> work via ci.failed. + const ticketDetail = yield* read.getTicketDetail(ticketId as never); + assert.equal(ticketDetail?.ticket.currentLaneKey, "work"); + }), + ); + + it.effect("12. poison-pill post: persistently-failing post retried to ceiling then failed", () => + Effect.gen(function* () { + resetScripts(); + const sql = yield* SqlClient.SqlClient; + yield* resetDb(sql); + const registry = yield* BoardRegistry; + const engine = yield* WorkflowEngine; + const read = yield* WorkflowReadModel; + const poller = yield* WorkflowGitHubPoller; + + yield* registry.register("b12" as never, prBoard); + const ticketId = yield* engine.createTicket({ + boardId: "b12" as never, + title: "bad post", + initialLane: "review" as never, + }); + yield* seedPrState(sql, { + ticketId: ticketId as string, + prNumber: 12, + lastHeadSha: "sha1", + lastCiState: "pending", + }); + // ci.failed carries a message body, so phase 2 hits postTicketMessage. + scriptPr(12, { + details: [detail({ number: 12, headSha: "sha1", ciState: "failure" })], + failingLogs: "boom", + }); + + // Every post attempt fails (non-terminal). It must count toward the + // ceiling, not retry forever. + postAlwaysFails = true; + + // Sweep 1: observation recorded; post fails -> attempt_count = 1, pending. + const first = yield* poller.sweep(); + assert.equal(first.recordedObservations, 1); + assert.equal(first.appliedObservations, 0); + let obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "pending"); + assert.equal(obs[0]!.attemptCount, 1); + // message_body NOT cleared (post never succeeded) so the message is still + // pending delivery. + assert.isNotNull(obs[0]!.messageBody); + + // Sweeps 2..4: still pending, attempt_count climbs. + for (let i = 2; i <= 4; i += 1) { + yield* poller.sweep(); + obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "pending"); + assert.equal(obs[0]!.attemptCount, i); + } + + // Sweep 5: 5th failed post hits the ceiling -> marked 'failed'. + yield* poller.sweep(); + obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.status, "failed"); + assert.equal(obs[0]!.attemptCount, 5); + + // No message was ever delivered, and a failed row is not re-attempted. + const messages = yield* read.listTicketMessages(ticketId as never); + assert.equal(messages.length, 0); + const sixth = yield* poller.sweep(); + assert.equal(sixth.appliedObservations, 0); + obs = yield* observationsFor(sql, ticketId as string); + assert.equal(obs[0]!.attemptCount, 5); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowGitHubPoller.ts b/apps/server/src/workflow/Layers/WorkflowGitHubPoller.ts new file mode 100644 index 00000000000..dc598cb2a39 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowGitHubPoller.ts @@ -0,0 +1,633 @@ +import type { BoardId, TicketId } from "@t3tools/contracts"; +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 Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { GitHubPort, type GitHubPrDetail } from "../Services/GitHubPort.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { + WorkflowGitHubPoller, + type WorkflowGitHubPollerShape, + type WorkflowGitHubPollerSweepResult, +} from "../Services/WorkflowGitHubPoller.ts"; +import { sanitizeExternalEventPayload } from "../externalEvent.ts"; +import { redactSensitiveText, truncateKeepingTail } from "../redactSensitiveText.ts"; +import { MAX_TICKET_MESSAGE_BODY_LENGTH } from "../ticketMessageBody.ts"; + +const DEFAULT_SWEEP_INTERVAL_MS = 45_000; +const DEFAULT_MAX_TICKETS_PER_SWEEP = 20; + +export interface WorkflowGitHubPollerLiveOptions { + readonly sweepIntervalMs?: number; + readonly maxTicketsPerSweep?: number; +} + +const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + +// JSON encode/decode via Schema (the codebase convention for persisted JSON — +// see WorkflowProjectionPipeline). Payloads are already sanitized + bounded. +const encodePayloadJson = Schema.encodeUnknownEffect(Schema.UnknownFromJsonString); +const decodePayloadJson = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); + +// A watched ticket: an open PR whose projection row is still non-terminal. +interface WatchedTicketRow { + readonly ticketId: TicketId; + readonly boardId: BoardId; + readonly repoRoot: string | null; + readonly prNumber: number; + readonly repo: string; + readonly lastHeadSha: string | null; + readonly lastCiState: string | null; + readonly lastReviewDecision: string | null; + readonly lastCommentCursor: string | null; +} + +// One durable outbox record produced by observing a single PR transition. +interface PendingObservation { + readonly observationId: string; + readonly ticketId: TicketId; + readonly dedupKey: string; + readonly eventName: string; + readonly payloadJson: string; + readonly messageBody: string | null; +} + +// The new `last_*` snapshot to persist for a ticket after observing it. +interface ObservedState { + readonly headSha: string | null; + readonly ciState: string | null; + readonly reviewDecision: string; + readonly commentCursor: string | null; + readonly prState: "open" | "merged" | "closed"; +} + +// A phase-2 work item: a pending observation joined to its board. +interface PendingPhase2Row { + readonly observationId: string; + readonly ticketId: TicketId; + readonly boardId: BoardId; + readonly eventName: string; + readonly payloadJson: string; + readonly messageBody: string | null; + readonly attemptCount: number; +} + +// A pending observation whose ingest keeps failing with a non-terminal, +// non-transient error (e.g. a predicate-eval error) would otherwise be retried +// every sweep forever. After this many failed attempts we give up and mark it +// 'failed' so it stops being drained. +const MAX_INGEST_ATTEMPTS = 5; + +const redactAndCap = (text: string): string => + truncateKeepingTail(redactSensitiveText(text), MAX_TICKET_MESSAGE_BODY_LENGTH); + +const makeWorkflowGitHubPoller = (options?: WorkflowGitHubPollerLiveOptions) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const saveLocks = yield* WorkflowBoardSaveLocks; + const engine = yield* WorkflowEngine; + const gitHub = yield* GitHubPort; + + const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS); + const maxTicketsPerSweep = Math.max( + 1, + Math.floor(options?.maxTicketsPerSweep ?? DEFAULT_MAX_TICKETS_PER_SWEEP), + ); + + // Round-robin cursor over watched tickets (same mechanic as the retention + // sweeper's lane cursor): we remember the ticket to *start* from next sweep + // so a cap'd sweep eventually covers every watched ticket. + let nextSweepCursorTicketId: string | null = null; + + // observation_id is an internal opaque PK — a v4 UUID is fine and avoids + // pulling the Crypto service into this layer (which the engine test harness + // does not provide). + const newObservationId = Effect.sync( + // @effect-diagnostics-next-line cryptoRandomUUIDInEffect:off + () => globalThis.crypto.randomUUID() as string, + ); + + const watchedTickets = () => + sql` + SELECT + pr.ticket_id AS "ticketId", + ticket.board_id AS "boardId", + ( + SELECT projects.workspace_root + FROM projection_board AS board + INNER JOIN projection_projects AS projects + ON projects.project_id = board.project_id + WHERE board.board_id = ticket.board_id + ) AS "repoRoot", + pr.pr_number AS "prNumber", + pr.repo, + pr.last_head_sha AS "lastHeadSha", + pr.last_ci_state AS "lastCiState", + pr.last_review_decision AS "lastReviewDecision", + pr.last_comment_cursor AS "lastCommentCursor" + FROM workflow_pr_state AS pr + INNER JOIN projection_ticket AS ticket + ON ticket.ticket_id = pr.ticket_id + WHERE pr.pr_state = 'open' + AND ticket.terminal_at IS NULL + ORDER BY pr.ticket_id ASC + `; + + const rotateTickets = (rows: ReadonlyArray) => { + if (nextSweepCursorTicketId === null) { + return rows; + } + const startIndex = rows.findIndex( + (row) => (row.ticketId as string) === nextSweepCursorTicketId, + ); + if (startIndex <= 0) { + return rows; + } + return [...rows.slice(startIndex), ...rows.slice(0, startIndex)]; + }; + + // Phase 1: build the durable observation records + the new last_* snapshot + // for one ticket by diffing live PR detail against the stored row. Pure + // computation + (for ci.failed / changes_requested) read-only gh fetches; + // NO database writes and NO engine/committer calls. + const observeTicket = (ticket: WatchedTicketRow) => + Effect.gen(function* () { + const cwd = ticket.repoRoot ?? "."; + const detail: GitHubPrDetail = yield* gitHub.prDetail({ + cwd, + prNumber: ticket.prNumber, + }); + + const observations: PendingObservation[] = []; + + const push = (record: { + readonly dedupKey: string; + readonly eventName: string; + readonly payload: unknown; + readonly messageBody: string | null; + }) => + Effect.gen(function* () { + const payloadJson = yield* encodePayloadJson( + sanitizeExternalEventPayload(record.payload), + ).pipe(Effect.orDie); + observations.push({ + observationId: yield* newObservationId, + ticketId: ticket.ticketId, + dedupKey: record.dedupKey, + eventName: record.eventName, + payloadJson, + messageBody: record.messageBody, + }); + }); + + // A new head sha resets the CI comparison: each push earns its own CI + // verdict and the dedup_key embeds the sha so per-push events stay + // distinct. When the sha changed, treat lastCiState as unknown. + const shaChanged = + detail.headSha !== null && detail.headSha !== ticket.lastHeadSha; + const ciBaseline = shaChanged ? null : ticket.lastCiState; + + // dedup_key is a TABLE-WIDE UNIQUE constraint, so every key is scoped by + // ticketId — otherwise two tickets sharing a head sha (monorepo) or both + // reaching merged would collide and the second observation would be + // silently dropped by INSERT OR IGNORE. + const tid = ticket.ticketId as string; + + // --- CI transitions (keyed by head sha) --- + if (detail.headSha !== null && detail.ciState !== ciBaseline) { + if (detail.ciState === "success") { + yield* push({ + dedupKey: `ci:${tid}:${detail.headSha}:success`, + eventName: "ci.passed", + payload: { sha: detail.headSha }, + messageBody: null, + }); + } else if (detail.ciState === "failure") { + const rawLogs = yield* gitHub + .failingCheckLogs({ cwd, prNumber: ticket.prNumber }) + .pipe(Effect.orElseSucceed(() => null)); + const summary = rawLogs === null ? null : redactAndCap(rawLogs); + yield* push({ + dedupKey: `ci:${tid}:${detail.headSha}:failure`, + eventName: "ci.failed", + payload: { + sha: detail.headSha, + ...(summary === null ? {} : { summary }), + }, + messageBody: summary, + }); + } + } + + // --- Review decision transitions --- + let nextCommentCursor = ticket.lastCommentCursor; + if (detail.reviewDecision !== ticket.lastReviewDecision) { + if (detail.reviewDecision === "changes_requested") { + // Post each NEW feedback item (newer than the stored cursor) as its + // own observation with a body; emit ONE routing event. + const feedback = yield* gitHub + .listReviewFeedback({ + cwd, + prNumber: ticket.prNumber, + repo: ticket.repo, + }) + .pipe(Effect.orElseSucceed(() => [])); + const cursor = ticket.lastCommentCursor; + const fresh = feedback.filter( + (item) => cursor === null || item.submittedAt > cursor, + ); + for (const item of fresh) { + const body = redactAndCap( + `**@${item.author}** on PR #${ticket.prNumber}:\n${item.body}`, + ); + yield* push({ + dedupKey: `comment:${tid}:${item.id}`, + eventName: "pr.changes_requested", + payload: {}, + messageBody: body, + }); + } + // The routing event itself (no body) — deduped by head sha so a new + // review round on a new push re-fires. + yield* push({ + dedupKey: `review:${tid}:${detail.headSha ?? "nohead"}:changes_requested`, + eventName: "pr.changes_requested", + payload: {}, + messageBody: null, + }); + // Advance the cursor to the newest feedback item observed. + for (const item of fresh) { + if (nextCommentCursor === null || item.submittedAt > nextCommentCursor) { + nextCommentCursor = item.submittedAt; + } + } + } else if (detail.reviewDecision === "approved") { + yield* push({ + dedupKey: `review:${tid}:${detail.headSha ?? "nohead"}:approved`, + eventName: "pr.approved", + payload: {}, + messageBody: null, + }); + } + } + + // --- Lifecycle transitions --- + if (detail.state === "merged") { + yield* push({ + dedupKey: `lifecycle:${tid}:merged`, + eventName: "pr.merged", + payload: {}, + messageBody: null, + }); + } else if (detail.state === "closed") { + yield* push({ + dedupKey: `lifecycle:${tid}:closed`, + eventName: "pr.closed", + payload: {}, + messageBody: null, + }); + } + + const observed: ObservedState = { + headSha: detail.headSha, + // When the sha changed and no new CI verdict is in yet, record the + // live verdict (pending) so a later transition still diffs cleanly. + ciState: detail.ciState, + reviewDecision: detail.reviewDecision, + commentCursor: nextCommentCursor, + prState: detail.state, + }; + + return { observations, observed }; + }); + + // Phase 1 write: under the save lock + a transaction, recheck the PR is + // still watched, INSERT OR IGNORE the observations, advance last_*. + // PLAIN SQL ONLY — never engine.* / committer.* here (they self-acquire the + // same non-reentrant save lock → deadlock). + const persistObservations = ( + ticket: WatchedTicketRow, + observations: ReadonlyArray, + observed: ObservedState, + ) => + saveLocks.withSaveLock( + ticket.boardId, + sql.withTransaction( + Effect.gen(function* () { + const rows = yield* sql<{ readonly prState: string }>` + SELECT pr_state AS "prState" + FROM workflow_pr_state + WHERE ticket_id = ${ticket.ticketId} + `; + const current = rows[0]; + // Gone (ticket deleted between observe and now) or already terminal + // → skip every write. + if (current === undefined || current.prState !== "open") { + return 0; + } + const ticketExists = yield* sql<{ readonly one: number }>` + SELECT 1 AS "one" + FROM projection_ticket + WHERE ticket_id = ${ticket.ticketId} + `; + if (ticketExists[0] === undefined) { + return 0; + } + + const createdAt = yield* nowIso; + let recorded = 0; + for (const observation of observations) { + // Count only the rows that are genuinely new: a UNIQUE dedup_key + // collision means this transition was already recorded (a + // re-observation no-op). Checking before the INSERT OR IGNORE + // gives an accurate `recorded` count without relying on a + // driver-specific affected-rows shape. + const existing = yield* sql<{ readonly one: number }>` + SELECT 1 AS "one" + FROM workflow_pr_observation + WHERE dedup_key = ${observation.dedupKey} + `; + if (existing[0] !== undefined) { + continue; + } + yield* sql` + INSERT OR IGNORE INTO workflow_pr_observation ( + observation_id, + ticket_id, + dedup_key, + event_name, + payload_json, + message_body, + status, + created_at + ) VALUES ( + ${observation.observationId}, + ${observation.ticketId}, + ${observation.dedupKey}, + ${observation.eventName}, + ${observation.payloadJson}, + ${observation.messageBody}, + 'pending', + ${createdAt} + ) + `; + recorded += 1; + } + + yield* sql` + UPDATE workflow_pr_state + SET last_head_sha = ${observed.headSha}, + last_ci_state = ${observed.ciState}, + last_review_decision = ${observed.reviewDecision}, + last_comment_cursor = ${observed.commentCursor}, + pr_state = ${observed.prState}, + updated_at = ${createdAt} + WHERE ticket_id = ${ticket.ticketId} + `; + + return recorded; + }), + ), + ); + + // Phase 2: drain pending observations across ALL still-reachable boards + // (joined to projection_ticket for boardId), oldest first. NO save lock is + // held here — engine.* self-acquire it. An observation whose PR has since + // merged is still drained because we select by status, not pr_state. + const drainPendingObservations = () => + Effect.gen(function* () { + const pending = yield* sql` + SELECT + obs.observation_id AS "observationId", + obs.ticket_id AS "ticketId", + ticket.board_id AS "boardId", + obs.event_name AS "eventName", + obs.payload_json AS "payloadJson", + obs.message_body AS "messageBody", + obs.attempt_count AS "attemptCount" + FROM workflow_pr_observation AS obs + INNER JOIN projection_ticket AS ticket + ON ticket.ticket_id = obs.ticket_id + WHERE obs.status = 'pending' + ORDER BY obs.created_at ASC, obs.observation_id ASC + `; + + let applied = 0; + for (const row of pending) { + const outcome = yield* applyPendingObservation(row).pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.github-poller.apply-failed", { + observationId: row.observationId, + ticketId: row.ticketId, + eventName: row.eventName, + cause, + }).pipe(Effect.as("pending" as const)), + ), + ); + if (outcome === "applied") { + applied += 1; + } + } + return applied; + }); + + // Apply one pending observation: post its message (if any), then ingest the + // external event. Both a successful outcome AND a "ticket not found on this + // board" error are terminal (the ticket/board is gone or moved — re-ingest + // would never help) → mark 'applied'. Any OTHER ingest error increments + // attempt_count and leaves the row 'pending'; once attempt_count would + // reach MAX_INGEST_ATTEMPTS the row is given up on (status 'failed') so a + // poison pill stops being retried every sweep. Returns the row's resulting + // state this pass: "applied" (ingested), "failed" (given up), or "pending" + // (will retry next sweep). + // A non-terminal phase-2 failure (a message post OR an ingest that is not a + // terminal "ticket not found"): count the attempt and leave the row + // 'pending', or give up at the ceiling by marking it 'failed' so a poison + // pill — whether the poison is the post or the ingest — stops being retried + // every sweep. Returns "failed" (given up) or "pending" (will retry). + const recordPhase2Failure = (row: PendingPhase2Row, stage: "post" | "ingest") => + Effect.gen(function* () { + const nextAttempt = row.attemptCount + 1; + if (nextAttempt >= MAX_INGEST_ATTEMPTS) { + yield* sql` + UPDATE workflow_pr_observation + SET status = 'failed', + attempt_count = ${nextAttempt} + WHERE observation_id = ${row.observationId} + `; + yield* Effect.logError("workflow.github-poller.observation-given-up", { + observationId: row.observationId, + ticketId: row.ticketId, + eventName: row.eventName, + stage, + attemptCount: nextAttempt, + }); + return "failed" as const; + } + yield* sql` + UPDATE workflow_pr_observation + SET attempt_count = ${nextAttempt} + WHERE observation_id = ${row.observationId} + `; + return "pending" as const; + }); + + const applyPendingObservation = (row: PendingPhase2Row) => + Effect.gen(function* () { + if (row.messageBody !== null) { + // A persistently-failing post must not retry forever — treat ANY post + // error as a non-terminal phase-2 failure that counts toward the + // ceiling (rather than throwing out to the sweep-level catch, which + // left the row 'pending' without incrementing attempt_count). + const posted = yield* engine + .postTicketMessage({ + ticketId: row.ticketId, + text: row.messageBody, + }) + .pipe( + Effect.as(true as const), + Effect.catch(() => Effect.succeed(false as const)), + ); + if (!posted) { + return yield* recordPhase2Failure(row, "post"); + } + // Posted-marker: clearing message_body makes a re-drive (crash before + // the 'applied' mark, or a later ingest give-up pass) skip the post. + // The tiny window between post and marker is an accepted + // at-least-once double-post. + yield* sql` + UPDATE workflow_pr_observation + SET message_body = NULL + WHERE observation_id = ${row.observationId} + `; + } + + const payload = yield* decodePayloadJson(row.payloadJson).pipe( + Effect.orElseSucceed(() => null as unknown), + ); + const ingestOutcome = yield* engine + .ingestExternalEvent({ + boardId: row.boardId, + name: row.eventName, + ticketId: row.ticketId, + payload, + }) + .pipe( + Effect.as("applied" as const), + Effect.catch((error) => + error.message === "ticket not found on this board" + ? Effect.succeed("applied" as const) + : Effect.succeed("error" as const), + ), + ); + + if (ingestOutcome === "applied") { + yield* sql` + UPDATE workflow_pr_observation + SET status = 'applied' + WHERE observation_id = ${row.observationId} + `; + return "applied"; + } + + return yield* recordPhase2Failure(row, "ingest"); + }); + + const sweep: WorkflowGitHubPollerShape["sweep"] = () => + Effect.gen(function* () { + const allWatched = yield* watchedTickets().pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.github-poller.watch-query-failed", { + cause, + }).pipe(Effect.as([] as ReadonlyArray)), + ), + ); + const ordered = rotateTickets(allWatched); + const toProcess = ordered.slice(0, maxTicketsPerSweep); + + // Advance the round-robin cursor to the first ticket we did NOT process + // this sweep, so the next sweep starts there. + if (ordered.length > toProcess.length) { + nextSweepCursorTicketId = ordered[toProcess.length]!.ticketId as string; + } else { + nextSweepCursorTicketId = null; + } + + let observedTickets = 0; + let recordedObservations = 0; + let failedTickets = 0; + + for (const ticket of toProcess) { + observedTickets += 1; + const outcome = yield* observeTicket(ticket).pipe( + Effect.flatMap(({ observations, observed }) => + persistObservations(ticket, observations, observed), + ), + Effect.catchCause((cause) => + Effect.logWarning("workflow.github-poller.observe-failed", { + ticketId: ticket.ticketId, + prNumber: ticket.prNumber, + cause, + }).pipe(Effect.as(null)), + ), + ); + if (outcome === null) { + failedTickets += 1; + } else { + recordedObservations += outcome; + } + } + + // Phase 2 runs regardless: it also drains leftover pending rows from a + // prior crashed process (the watched set is unrelated to which rows are + // pending). + const appliedObservations = yield* drainPendingObservations().pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.github-poller.drain-failed", { + cause, + }).pipe(Effect.as(0)), + ), + ); + + if (recordedObservations > 0 || appliedObservations > 0 || failedTickets > 0) { + yield* Effect.logInfo("workflow.github-poller.sweep-complete", { + observedTickets, + recordedObservations, + appliedObservations, + failedTickets, + }); + } + + return { + observedTickets, + recordedObservations, + appliedObservations, + failedTickets, + } satisfies WorkflowGitHubPollerSweepResult; + }); + + const start: WorkflowGitHubPollerShape["start"] = () => + Effect.gen(function* () { + yield* Effect.forkScoped( + sweep().pipe( + Effect.catchDefect((defect: unknown) => + Effect.logWarning("workflow.github-poller.sweep-defect", { defect }), + ), + Effect.repeat(Schedule.spaced(Duration.millis(sweepIntervalMs))), + ), + ); + yield* Effect.logInfo("workflow.github-poller.started", { sweepIntervalMs }); + }); + + return { sweep, start } satisfies WorkflowGitHubPollerShape; + }); + +export const makeWorkflowGitHubPollerLive = (options?: WorkflowGitHubPollerLiveOptions) => + Layer.effect(WorkflowGitHubPoller, makeWorkflowGitHubPoller(options)); + +export const WorkflowGitHubPollerLive = makeWorkflowGitHubPollerLive(); 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..7deb5f09803 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowIds.ts @@ -0,0 +1,61 @@ +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)), + mappingId: () => next("mapping"), + } 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)), + mappingId: () => next("mapping"), + } 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..9089493da7b --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.test.ts @@ -0,0 +1,1131 @@ +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 TicketPrOpened into workflow_pr_state (initial insert)", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + + yield* pipeline.projectEvent({ + type: "TicketCreated", + eventId: "pr-opened-setup-a" as never, + ticketId: "ticket-pr-opened" as never, + streamVersion: 0, + occurredAt: "2026-06-12T00:00:00.000Z" as never, + payload: { + boardId: "board-pr-opened" as never, + title: "PR ticket" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + type: "TicketPrOpened", + eventId: "pr-opened-b" as never, + ticketId: "ticket-pr-opened" as never, + streamVersion: 1, + occurredAt: "2026-06-12T00:00:01.000Z" as never, + payload: { + stepRunId: "sr-pr-opened" as never, + prNumber: 42, + url: "https://github.com/owner/repo/pull/42", + branch: "ft/my-feature", + remoteName: "origin", + repo: "owner/repo", + }, + } as never); + + const rows = yield* sql<{ + readonly ticketId: string; + readonly prNumber: number; + readonly prUrl: string; + readonly branch: string; + readonly remoteName: string; + readonly repo: string; + readonly prState: string; + readonly updatedAt: string; + readonly lastHeadSha: string | null; + readonly lastCiState: string | null; + }>` + SELECT + ticket_id AS "ticketId", + pr_number AS "prNumber", + pr_url AS "prUrl", + branch, + remote_name AS "remoteName", + repo, + pr_state AS "prState", + updated_at AS "updatedAt", + last_head_sha AS "lastHeadSha", + last_ci_state AS "lastCiState" + FROM workflow_pr_state + WHERE ticket_id = 'ticket-pr-opened' + `; + + assert.equal(rows.length, 1); + assert.equal(rows[0]?.ticketId, "ticket-pr-opened"); + assert.equal(rows[0]?.prNumber, 42); + assert.equal(rows[0]?.prUrl, "https://github.com/owner/repo/pull/42"); + assert.equal(rows[0]?.branch, "ft/my-feature"); + assert.equal(rows[0]?.remoteName, "origin"); + assert.equal(rows[0]?.repo, "owner/repo"); + assert.equal(rows[0]?.prState, "open"); + assert.equal(rows[0]?.updatedAt, "2026-06-12T00:00:01.000Z"); + assert.equal(rows[0]?.lastHeadSha, null); + assert.equal(rows[0]?.lastCiState, null); + }), + ); + + it.effect("projecting TicketPrOpened twice is idempotent (upsert)", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + + yield* pipeline.projectEvent({ + type: "TicketCreated", + eventId: "pr-replay-setup" as never, + ticketId: "ticket-pr-replay" as never, + streamVersion: 0, + occurredAt: "2026-06-12T00:00:00.000Z" as never, + payload: { + boardId: "board-pr-replay" as never, + title: "PR replay ticket" as never, + laneKey: "implement" as never, + }, + }); + const prEvent = { + type: "TicketPrOpened" as const, + eventId: "pr-replay-b" as never, + ticketId: "ticket-pr-replay" as never, + streamVersion: 1, + occurredAt: "2026-06-12T00:00:01.000Z" as never, + payload: { + stepRunId: "sr-pr-replay" as never, + prNumber: 7, + url: "https://github.com/owner/repo/pull/7", + branch: "ft/replay", + remoteName: "upstream", + repo: "owner/repo", + }, + }; + yield* pipeline.projectEvent(prEvent as never); + yield* pipeline.projectEvent(prEvent as never); + + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_pr_state WHERE ticket_id = 'ticket-pr-replay' + `; + assert.equal(rows[0]?.count, 1); + }), + ); + + 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); + }), + ); + + it.effect( + "StepAwaitingUser with providerResponseKind=request sets attention_kind=waiting_for_approval", + () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-attn-approval" as never, + occurredAt: "2026-06-13T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "attn-approval-a" as never, + streamVersion: 0, + payload: { + boardId: "b-attn" as never, + title: "Approval ticket" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "attn-approval-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-attn-approval" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-attn-approval" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "attn-approval-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-attn-approval" as never, + stepRunId: "sr-attn-approval" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepAwaitingUser", + eventId: "attn-approval-d" as never, + streamVersion: 3, + payload: { + stepRunId: "sr-attn-approval" as never, + waitingReason: "approve shell command?", + providerResponseKind: "request", + }, + }); + + const rows = yield* sql<{ + readonly status: string; + readonly attentionKind: string | null; + readonly attentionReason: string | null; + }>` + SELECT + status, + attention_kind AS "attentionKind", + attention_reason AS "attentionReason" + FROM projection_ticket + WHERE ticket_id = 't-attn-approval' + `; + assert.equal(rows[0]?.status, "waiting_on_user"); + assert.equal(rows[0]?.attentionKind, "waiting_for_approval"); + assert.equal(rows[0]?.attentionReason, "approve shell command?"); + }), + ); + + it.effect( + "StepAwaitingUser with providerResponseKind=user-input sets attention_kind=waiting_for_input", + () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-attn-input" as never, + occurredAt: "2026-06-13T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "attn-input-a" as never, + streamVersion: 0, + payload: { + boardId: "b-attn" as never, + title: "Input ticket" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "attn-input-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-attn-input" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-attn-input" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "attn-input-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-attn-input" as never, + stepRunId: "sr-attn-input" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepAwaitingUser", + eventId: "attn-input-d" as never, + streamVersion: 3, + payload: { + stepRunId: "sr-attn-input" as never, + waitingReason: "which endpoint?", + providerResponseKind: "user-input", + }, + }); + + const rows = yield* sql<{ + readonly attentionKind: string | null; + readonly attentionReason: string | null; + }>` + SELECT + attention_kind AS "attentionKind", + attention_reason AS "attentionReason" + FROM projection_ticket + WHERE ticket_id = 't-attn-input' + `; + assert.equal(rows[0]?.attentionKind, "waiting_for_input"); + assert.equal(rows[0]?.attentionReason, "which endpoint?"); + }), + ); + + it.effect( + "StepAwaitingUser without providerResponseKind sets attention_kind=waiting_for_input", + () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-attn-null-kind" as never, + occurredAt: "2026-06-13T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "attn-null-kind-a" as never, + streamVersion: 0, + payload: { + boardId: "b-attn" as never, + title: "No-kind ticket" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "attn-null-kind-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-attn-null-kind" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-attn-null-kind" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "attn-null-kind-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-attn-null-kind" as never, + stepRunId: "sr-attn-null-kind" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepAwaitingUser", + eventId: "attn-null-kind-d" as never, + streamVersion: 3, + payload: { + stepRunId: "sr-attn-null-kind" as never, + waitingReason: "what to do?", + }, + }); + + const rows = yield* sql<{ + readonly attentionKind: string | null; + readonly attentionReason: string | null; + }>` + SELECT + attention_kind AS "attentionKind", + attention_reason AS "attentionReason" + FROM projection_ticket + WHERE ticket_id = 't-attn-null-kind' + `; + assert.equal(rows[0]?.attentionKind, "waiting_for_input"); + assert.equal(rows[0]?.attentionReason, "what to do?"); + }), + ); + + it.effect("TicketBlocked sets attention_kind=blocked and attention_reason", () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-attn-blocked" as never, + occurredAt: "2026-06-13T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "attn-blocked-a" as never, + streamVersion: 0, + payload: { + boardId: "b-attn" as never, + title: "Blocked ticket" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketBlocked", + eventId: "attn-blocked-b" as never, + streamVersion: 1, + payload: { reason: "missing credentials" }, + }); + + const rows = yield* sql<{ + readonly status: string; + readonly attentionKind: string | null; + readonly attentionReason: string | null; + }>` + SELECT + status, + attention_kind AS "attentionKind", + attention_reason AS "attentionReason" + FROM projection_ticket + WHERE ticket_id = 't-attn-blocked' + `; + assert.equal(rows[0]?.status, "blocked"); + assert.equal(rows[0]?.attentionKind, "blocked"); + assert.equal(rows[0]?.attentionReason, "missing credentials"); + }), + ); + + it.effect( + "StepUserResolved after StepAwaitingUser clears attention_kind and attention_reason", + () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-attn-clear-resolved" as never, + occurredAt: "2026-06-13T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "attn-clear-resolved-a" as never, + streamVersion: 0, + payload: { + boardId: "b-attn" as never, + title: "Clear resolved ticket" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "attn-clear-resolved-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-attn-clear-resolved" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-attn-clear-resolved" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "attn-clear-resolved-c" as never, + streamVersion: 2, + payload: { + pipelineRunId: "pr-attn-clear-resolved" as never, + stepRunId: "sr-attn-clear-resolved" as never, + stepKey: "code" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepAwaitingUser", + eventId: "attn-clear-resolved-d" as never, + streamVersion: 3, + payload: { + stepRunId: "sr-attn-clear-resolved" as never, + waitingReason: "approve command?", + providerResponseKind: "request", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepUserResolved", + eventId: "attn-clear-resolved-e" as never, + streamVersion: 4, + payload: { stepRunId: "sr-attn-clear-resolved" as never }, + }); + + const rows = yield* sql<{ + readonly status: string; + readonly attentionKind: string | null; + readonly attentionReason: string | null; + }>` + SELECT + status, + attention_kind AS "attentionKind", + attention_reason AS "attentionReason" + FROM projection_ticket + WHERE ticket_id = 't-attn-clear-resolved' + `; + assert.equal(rows[0]?.status, "running"); + assert.isNull(rows[0]?.attentionKind); + assert.isNull(rows[0]?.attentionReason); + }), + ); + + it.effect( + "TicketMovedToLane after TicketBlocked clears attention_kind and attention_reason", + () => + Effect.gen(function* () { + const pipeline = yield* WorkflowProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const base = { + ticketId: "t-attn-clear-moved" as never, + occurredAt: "2026-06-13T00:00:00.000Z" as never, + }; + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "attn-clear-moved-a" as never, + streamVersion: 0, + payload: { + boardId: "b-attn" as never, + title: "Clear moved ticket" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketBlocked", + eventId: "attn-clear-moved-b" as never, + streamVersion: 1, + payload: { reason: "blocked for now" }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "TicketMovedToLane", + eventId: "attn-clear-moved-c" as never, + streamVersion: 2, + occurredAt: "2026-06-13T00:00:01.000Z" as never, + payload: { + toLane: "review" as never, + laneEntryToken: "tok-attn-clear-moved" as never, + reason: "manual", + }, + }); + + const rows = yield* sql<{ + readonly status: string; + readonly attentionKind: string | null; + readonly attentionReason: string | null; + }>` + SELECT + status, + attention_kind AS "attentionKind", + attention_reason AS "attentionReason" + FROM projection_ticket + WHERE ticket_id = 't-attn-clear-moved' + `; + assert.equal(rows[0]?.status, "idle"); + assert.isNull(rows[0]?.attentionKind); + assert.isNull(rows[0]?.attentionReason); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts new file mode 100644 index 00000000000..0b35eedde57 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowProjectionPipeline.ts @@ -0,0 +1,512 @@ +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', + attention_kind = NULL, + attention_reason = NULL, + 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', + attention_kind = NULL, + attention_reason = NULL, + 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', + attention_kind = NULL, + attention_reason = NULL, + 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', + attention_kind = 'blocked', + attention_reason = ${event.payload.reason}, + 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', + attention_kind = NULL, + attention_reason = NULL, + 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', + attention_kind = ${event.payload.providerResponseKind === "request" ? "waiting_for_approval" : "waiting_for_input"}, + attention_reason = ${event.payload.waitingReason}, + 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', + attention_kind = NULL, + attention_reason = NULL, + 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; + } + case "TicketPrOpened": { + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, + pr_number, + pr_url, + branch, + remote_name, + repo, + pr_state, + updated_at + ) + VALUES ( + ${event.ticketId}, + ${event.payload.prNumber}, + ${event.payload.url}, + ${event.payload.branch}, + ${event.payload.remoteName}, + ${event.payload.repo}, + 'open', + ${event.occurredAt} + ) + ON CONFLICT(ticket_id) DO UPDATE SET + pr_number = excluded.pr_number, + pr_url = excluded.pr_url, + branch = excluded.branch, + remote_name = excluded.remote_name, + repo = excluded.repo, + pr_state = 'open', + updated_at = excluded.updated_at + `; + 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..64c0f4f13c3 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowReadModel.test.ts @@ -0,0 +1,2085 @@ +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 { 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("parses a work_source route decision into history", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const insertRouteDecision = (payload: unknown) => sql` + INSERT INTO workflow_events ( + event_id, ticket_id, stream_version, event_type, occurred_at, payload_json + ) + VALUES ( + 'event-work-source-1', + 'ticket-work-source', + 0, + 'TicketRouteDecided', + '2026-06-13T00:00:01.000Z', + ${JSON.stringify(payload)} + ) + `; + yield* insertRouteDecision({ + fromLane: "implement", + toLane: "done", + source: "work_source", + }); + + const decisions = yield* read.listTicketRouteDecisions("ticket-work-source" as never); + + assert.deepEqual( + decisions.map((row) => [row.source, row.fromLane, row.toLane]), + [["work_source", "implement", "done"]], + ); + }), + ); + + 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], + ], + ); + }), + ); + + it.effect("listTickets and getTicketDetail include pr field when workflow_pr_state row exists", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-12T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES + ('ticket-pr-view', 'board-pr-view', 'PR ticket', 'implement', 'idle', ${now}, ${now}), + ('ticket-no-pr', 'board-pr-view', 'No PR ticket', 'implement', 'idle', ${now}, ${now}) + `; + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, pr_state, + last_ci_state, updated_at + ) + VALUES ( + 'ticket-pr-view', 99, 'https://github.com/owner/repo/pull/99', + 'ft/feature', 'origin', 'owner/repo', 'open', 'success', ${now} + ) + `; + + const tickets = yield* read.listTickets("board-pr-view" as never); + const prTicket = tickets.find((t) => t.ticketId === "ticket-pr-view"); + const noPrTicket = tickets.find((t) => t.ticketId === "ticket-no-pr"); + + assert.isDefined(prTicket?.pr); + assert.equal(prTicket?.pr?.number, 99); + assert.equal(prTicket?.pr?.url, "https://github.com/owner/repo/pull/99"); + assert.equal(prTicket?.pr?.state, "open"); + assert.equal(prTicket?.pr?.ciState, "success"); + assert.isUndefined(noPrTicket?.pr); + + const detail = yield* read.getTicketDetail("ticket-pr-view" as never); + assert.isDefined(detail?.ticket.pr); + assert.equal(detail?.ticket.pr?.number, 99); + assert.equal(detail?.ticket.pr?.url, "https://github.com/owner/repo/pull/99"); + assert.equal(detail?.ticket.pr?.state, "open"); + assert.equal(detail?.ticket.pr?.ciState, "success"); + + const detailNoPr = yield* read.getTicketDetail("ticket-no-pr" as never); + assert.isUndefined(detailNoPr?.ticket.pr); + }), + ); + + it.effect("pr.ciState is omitted when last_ci_state is NULL", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-12T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-pr-no-ci', 'board-pr-no-ci', 'PR no CI', 'implement', 'idle', ${now}, ${now}) + `; + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, pr_state, updated_at + ) + VALUES ( + 'ticket-pr-no-ci', 3, 'https://github.com/owner/repo/pull/3', + 'ft/no-ci', 'origin', 'owner/repo', 'merged', ${now} + ) + `; + + const tickets = yield* read.listTickets("board-pr-no-ci" as never); + const ticket = tickets[0]; + assert.isDefined(ticket?.pr); + assert.equal(ticket?.pr?.state, "merged"); + assert.isUndefined(ticket?.pr?.ciState); + }), + ); + + it.effect("getTicketPrState returns full row or null", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-12T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-pr-state-full', 'board-pr-state-full', 'Full PR state', 'implement', 'idle', ${now}, ${now}) + `; + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, pr_state, + last_head_sha, last_ci_state, last_review_decision, last_comment_cursor, updated_at + ) + VALUES ( + 'ticket-pr-state-full', 11, 'https://github.com/owner/repo/pull/11', + 'ft/full', 'origin', 'owner/repo', 'open', + 'abc123', 'pending', 'APPROVED', 'cursor-xyz', ${now} + ) + `; + + const prState = yield* read.getTicketPrState("ticket-pr-state-full" as never); + assert.isNotNull(prState); + assert.equal(prState?.prNumber, 11); + assert.equal(prState?.prUrl, "https://github.com/owner/repo/pull/11"); + assert.equal(prState?.branch, "ft/full"); + assert.equal(prState?.remoteName, "origin"); + assert.equal(prState?.repo, "owner/repo"); + assert.equal(prState?.prState, "open"); + assert.equal(prState?.lastHeadSha, "abc123"); + assert.equal(prState?.lastCiState, "pending"); + assert.equal(prState?.lastReviewDecision, "APPROVED"); + assert.equal(prState?.lastCommentCursor, "cursor-xyz"); + + const missing = yield* read.getTicketPrState("ticket-no-such" as never); + assert.isNull(missing); + }), + ); + + it.effect("drops the pr view when pr_state is unrecognized", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-12T00:00:00.000Z"; + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-pr-bogus', 'board-pr-bogus', 'Bogus PR state', 'implement', 'idle', ${now}, ${now}) + `; + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, pr_state, updated_at + ) + VALUES ( + 'ticket-pr-bogus', 5, 'https://github.com/owner/repo/pull/5', + 'ft/bogus', 'origin', 'owner/repo', 'reopened', ${now} + ) + `; + + // An unrecognized pr_state is an invariant violation: the view degrades + // to "no pr" and the read model logs one warning per query (log output + // is not captured by this harness, so only the view shape is asserted). + const tickets = yield* read.listTickets("board-pr-bogus" as never); + assert.equal(tickets.length, 1); + assert.isUndefined(tickets[0]?.pr); + + const detail = yield* read.getTicketDetail("ticket-pr-bogus" as never); + assert.isUndefined(detail?.ticket.pr); + }), + ); + + it.effect( + "ticket detail carries attention fields and current-lane actions for a waiting ticket", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const registry = yield* BoardRegistry; + const base = { + ticketId: "t-attention-detail" as never, + occurredAt: "2026-06-08T00:00:00.000Z" as never, + }; + + yield* registry.register("b-attention-detail" as never, { + name: "Attention board", + lanes: [ + { + key: "review", + name: "Review", + entry: "manual", + actions: [ + { label: "Approve", to: "done", hint: "Ship it" }, + { label: "Send back", to: "implement" }, + ], + }, + { key: "implement", name: "Implement", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "attention-detail-a" as never, + streamVersion: 0, + payload: { + boardId: "b-attention-detail" as never, + title: "Needs you" as never, + laneKey: "review" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepStarted", + eventId: "attention-detail-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-attention-detail" as never, + stepRunId: "sr-attention-detail" as never, + stepKey: "review" as never, + stepType: "agent", + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "StepAwaitingUser", + eventId: "attention-detail-c" as never, + streamVersion: 2, + payload: { + stepRunId: "sr-attention-detail" as never, + waitingReason: "Approve this command?", + providerResponseKind: "request", + }, + }); + + const detail = yield* read.getTicketDetail("t-attention-detail" as never); + assert.equal(detail?.ticket.attentionKind, "waiting_for_approval"); + assert.equal(detail?.ticket.attentionReason, "Approve this command?"); + assert.deepEqual(detail?.ticket.currentLane, { + key: "review", + name: "Review", + actions: [ + { label: "Approve", to: "done", hint: "Ship it" }, + { label: "Send back", to: "implement" }, + ], + }); + }), + ); + + it.effect( + "ticket detail reports no attention and an action-less lane for a running ticket", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const registry = yield* BoardRegistry; + const base = { + ticketId: "t-running-detail" as never, + occurredAt: "2026-06-08T00:00:00.000Z" as never, + }; + + yield* registry.register("b-running-detail" as never, { + name: "Running board", + lanes: [{ key: "implement", name: "Implement", entry: "manual" }], + }); + + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "running-detail-a" as never, + streamVersion: 0, + payload: { + boardId: "b-running-detail" as never, + title: "In progress" as never, + laneKey: "implement" as never, + }, + }); + yield* pipeline.projectEvent({ + ...base, + type: "PipelineStarted", + eventId: "running-detail-b" as never, + streamVersion: 1, + payload: { + pipelineRunId: "pr-running-detail" as never, + laneKey: "implement" as never, + laneEntryToken: "tok-running" as never, + }, + }); + + const detail = yield* read.getTicketDetail("t-running-detail" as never); + assert.equal(detail?.ticket.status, "running"); + assert.equal(detail?.ticket.attentionKind, null); + assert.equal(detail?.ticket.attentionReason, null); + // Lane resolved but has no actions configured. + assert.deepEqual(detail?.ticket.currentLane, { + key: "implement", + name: "Implement", + actions: [], + }); + }), + ); + + it.effect( + "ticket detail falls back to a key-only lane when the board definition is unregistered", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const pipeline = yield* WorkflowProjectionPipeline; + const base = { + ticketId: "t-fallback-detail" as never, + occurredAt: "2026-06-08T00:00:00.000Z" as never, + }; + + // No registry.register for this board — definition is unresolvable. + yield* pipeline.projectEvent({ + ...base, + type: "TicketCreated", + eventId: "fallback-detail-a" as never, + streamVersion: 0, + payload: { + boardId: "b-fallback-detail" as never, + title: "Orphan" as never, + laneKey: "mystery_lane" as never, + }, + }); + + const detail = yield* read.getTicketDetail("t-fallback-detail" as never); + assert.deepEqual(detail?.ticket.currentLane, { + key: "mystery_lane", + name: "mystery_lane", + actions: [], + }); + }), + ); + + it.effect( + "listNeedsAttentionTickets returns only waiting/blocked tickets with board name, oldest first", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + + yield* read.registerBoard({ + boardId: "b-needs-attention" as never, + projectId: "p-needs-attention" as never, + name: "Attention Board" as never, + workflowFilePath: ".t3/boards/attention.json", + workflowVersionHash: "h", + maxConcurrentTickets: 3, + }); + + const insertTicket = (input: { + readonly ticketId: string; + readonly status: string; + readonly attentionKind: string | null; + readonly attentionReason: string | null; + readonly updatedAt: string; + }) => sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, + attention_kind, attention_reason, created_at, updated_at + ) + VALUES ( + ${input.ticketId}, 'b-needs-attention', ${input.ticketId}, 'review', ${input.status}, + ${input.attentionKind}, ${input.attentionReason}, + '2026-06-08T00:00:00.000Z', ${input.updatedAt} + ) + `; + + // Newer waiting ticket, older blocked ticket, and an excluded running one. + yield* insertTicket({ + ticketId: "ticket-waiting", + status: "waiting_on_user", + attentionKind: "waiting_for_input", + attentionReason: "Which API?", + updatedAt: "2026-06-08T02:00:00.000Z", + }); + yield* insertTicket({ + ticketId: "ticket-blocked", + status: "blocked", + attentionKind: "blocked", + attentionReason: "Missing creds", + updatedAt: "2026-06-08T01:00:00.000Z", + }); + yield* insertTicket({ + ticketId: "ticket-running", + status: "running", + attentionKind: null, + attentionReason: null, + updatedAt: "2026-06-08T03:00:00.000Z", + }); + + const rows = yield* read.listNeedsAttentionTickets(); + assert.deepEqual( + rows.map((row) => row.ticketId), + ["ticket-blocked", "ticket-waiting"], + ); + assert.equal(rows[0]?.boardName, "Attention Board"); + assert.equal(rows[0]?.status, "blocked"); + assert.equal(rows[0]?.attentionKind, "blocked"); + assert.equal(rows[0]?.attentionReason, "Missing creds"); + assert.equal(rows[0]?.currentLaneKey, "review"); + assert.equal(rows[1]?.attentionKind, "waiting_for_input"); + }), + ); + + it.effect("deleteTicketState removes the ticket's notification outbox rows", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + 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 ('ticket-outbox', 'b-outbox', 'Outbox', 'review', 'waiting_on_user', ${now}, ${now}) + `; + yield* sql` + INSERT INTO workflow_notification_outbox ( + outbox_id, ticket_id, board_id, sequence, status, created_at + ) + VALUES ('outbox-1', 'ticket-outbox', 'b-outbox', 1, 'waiting_on_user', ${now}) + `; + + yield* read.deleteTicketState("ticket-outbox" as never); + + const remaining = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_notification_outbox WHERE ticket_id = 'ticket-outbox' + `; + assert.equal(remaining[0]?.count, 0); + }), + ); + + it.effect("deleteBoardTicketState removes the board's notification outbox rows", () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + 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 ('ticket-board-outbox', 'b-board-outbox', 'Outbox', 'review', 'blocked', ${now}, ${now}) + `; + yield* sql` + INSERT INTO workflow_notification_outbox ( + outbox_id, ticket_id, board_id, sequence, status, created_at + ) + VALUES ('board-outbox-1', 'ticket-board-outbox', 'b-board-outbox', 2, 'blocked', ${now}) + `; + + yield* read.deleteBoardTicketState("b-board-outbox" as never); + + const remaining = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_notification_outbox WHERE board_id = 'b-board-outbox' + `; + assert.equal(remaining[0]?.count, 0); + }), + ); + + it.effect( + "deleteBoardTicketState removes work_source_mapping and work_source_state rows for the board", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-13T00:00:00.000Z"; + + // Register the board via the proper API + yield* read.registerBoard({ + boardId: "b-ws-cascade" as never, + projectId: "proj-ws" as never, + name: "WS Board", + workflowFilePath: ".t3/boards/ws.json", + workflowVersionHash: "hash-ws", + maxConcurrentTickets: 5, + }); + + // Insert ticket row + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-ws-cascade', 'b-ws-cascade', 'Synced', 'inbox', 'running', ${now}, ${now}) + `; + + // Insert work_source_mapping row + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + content_hash, lifecycle, sync_status, created_at, last_synced_at + ) + VALUES ( + 'map-ws-cascade', 'b-ws-cascade', 'src-1', 'github', '42', + 'ticket-ws-cascade', 'hash123', 'open', 'active', ${now}, ${now} + ) + `; + + // Insert work_source_state row (board-scoped) + yield* sql` + INSERT INTO work_source_state ( + board_id, source_id, consecutive_failures + ) + VALUES ('b-ws-cascade', 'src-1', 0) + `; + + yield* read.deleteBoardTicketState("b-ws-cascade" as never); + + const mappingCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM work_source_mapping WHERE ticket_id = 'ticket-ws-cascade' + `; + assert.equal(mappingCount[0]?.count, 0, "work_source_mapping should be deleted"); + + const stateCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM work_source_state WHERE board_id = 'b-ws-cascade' + `; + assert.equal(stateCount[0]?.count, 0, "work_source_state should be deleted"); + }), + ); + + it.effect( + "deleteTicketState removes work_source_mapping for the ticket but leaves board-scoped work_source_state intact", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-13T00:00:00.000Z"; + + // Register the board via the proper API + yield* read.registerBoard({ + boardId: "b-ws-ticket" as never, + projectId: "proj-ws-t" as never, + name: "WS Ticket Board", + workflowFilePath: ".t3/boards/wst.json", + workflowVersionHash: "hash-wst", + maxConcurrentTickets: 5, + }); + + // Insert ticket row + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-ws-single', 'b-ws-ticket', 'Synced Single', 'inbox', 'running', ${now}, ${now}) + `; + + // Insert work_source_mapping row for the ticket + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + content_hash, lifecycle, sync_status, created_at, last_synced_at + ) + VALUES ( + 'map-ws-single', 'b-ws-ticket', 'src-2', 'github', '99', + 'ticket-ws-single', 'hash456', 'open', 'active', ${now}, ${now} + ) + `; + + // Insert board-scoped work_source_state row (should NOT be deleted) + yield* sql` + INSERT INTO work_source_state ( + board_id, source_id, consecutive_failures + ) + VALUES ('b-ws-ticket', 'src-2', 0) + `; + + yield* read.deleteTicketState("ticket-ws-single" as never); + + const mappingCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM work_source_mapping WHERE ticket_id = 'ticket-ws-single' + `; + assert.equal(mappingCount[0]?.count, 0, "work_source_mapping should be deleted for the ticket"); + + const stateCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM work_source_state WHERE board_id = 'b-ws-ticket' + `; + assert.equal(stateCount[0]?.count, 1, "work_source_state (board-scoped) should remain untouched"); + }), + ); + + it.effect( + "getTicketDetail returns syncedSource when work_source_mapping row has valid source_metadata_json", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-13T00:00:00.000Z"; + + yield* read.registerBoard({ + boardId: "b-synced-detail" as never, + projectId: "proj-synced" as never, + name: "Synced Board", + workflowFilePath: ".t3/boards/synced.json", + workflowVersionHash: "hash-synced", + maxConcurrentTickets: 5, + }); + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-synced', 'b-synced-detail', 'Synced Ticket', 'inbox', 'running', ${now}, ${now}) + `; + + const metadataJson = + '{"provider":"github","url":"https://github.com/owner/repo/issues/42","assignees":["alice","bob"],"labels":["bug","high-priority"],"lifecycle":"open"}'; + + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + content_hash, lifecycle, sync_status, source_metadata_json, created_at, last_synced_at + ) + VALUES ( + 'map-synced', 'b-synced-detail', 'src-synced', 'github', '42', + 'ticket-synced', 'hashXYZ', 'open', 'active', ${metadataJson}, ${now}, ${now} + ) + `; + + const detail = yield* read.getTicketDetail("ticket-synced" as never); + assert.isDefined(detail, "detail should not be null"); + assert.isDefined(detail?.syncedSource, "syncedSource should be present"); + assert.equal(detail?.syncedSource?.provider, "github"); + assert.equal( + detail?.syncedSource?.url, + "https://github.com/owner/repo/issues/42", + ); + assert.deepEqual(detail?.syncedSource?.assignees, ["alice", "bob"]); + assert.deepEqual(detail?.syncedSource?.labels, ["bug", "high-priority"]); + }), + ); + + it.effect( + "getTicketDetail returns syncedSource for orphaned mapping (sync_status=orphaned)", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-13T00:00:00.000Z"; + + yield* read.registerBoard({ + boardId: "b-orphaned-detail" as never, + projectId: "proj-orphaned" as never, + name: "Orphaned Board", + workflowFilePath: ".t3/boards/orphaned.json", + workflowVersionHash: "hash-orphaned", + maxConcurrentTickets: 5, + }); + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-orphaned', 'b-orphaned-detail', 'Orphaned Ticket', 'inbox', 'running', ${now}, ${now}) + `; + + const metadataJson = + '{"provider":"asana","url":"https://app.asana.com/0/proj/task123","labels":["v2"]}'; + + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + content_hash, lifecycle, sync_status, source_metadata_json, created_at, last_synced_at + ) + VALUES ( + 'map-orphaned', 'b-orphaned-detail', 'src-asana', 'asana', 'task123', + 'ticket-orphaned', 'hashABC', 'closed', 'orphaned', ${metadataJson}, ${now}, ${now} + ) + `; + + const detail = yield* read.getTicketDetail("ticket-orphaned" as never); + assert.isDefined(detail?.syncedSource, "syncedSource should be present even for orphaned mapping"); + assert.equal(detail?.syncedSource?.provider, "asana"); + assert.equal(detail?.syncedSource?.url, "https://app.asana.com/0/proj/task123"); + assert.deepEqual(detail?.syncedSource?.labels, ["v2"]); + assert.isUndefined(detail?.syncedSource?.assignees); + }), + ); + + it.effect( + "getTicketDetail returns syncedSource: undefined for a non-synced ticket", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-13T00:00:00.000Z"; + + yield* read.registerBoard({ + boardId: "b-non-synced" as never, + projectId: "proj-non-synced" as never, + name: "Non-Synced Board", + workflowFilePath: ".t3/boards/non-synced.json", + workflowVersionHash: "hash-non-synced", + maxConcurrentTickets: 5, + }); + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-non-synced', 'b-non-synced', 'Non-Synced Ticket', 'inbox', 'running', ${now}, ${now}) + `; + + const detail = yield* read.getTicketDetail("ticket-non-synced" as never); + assert.isDefined(detail, "detail should not be null"); + assert.isUndefined(detail?.syncedSource, "syncedSource should be undefined for non-synced ticket"); + }), + ); + + it.effect( + "getTicketDetail returns syncedSource: undefined when source_metadata_json is malformed", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-13T00:00:00.000Z"; + + yield* read.registerBoard({ + boardId: "b-malformed" as never, + projectId: "proj-malformed" as never, + name: "Malformed Board", + workflowFilePath: ".t3/boards/malformed.json", + workflowVersionHash: "hash-malformed", + maxConcurrentTickets: 5, + }); + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-malformed', 'b-malformed', 'Malformed Ticket', 'inbox', 'running', ${now}, ${now}) + `; + + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + content_hash, lifecycle, sync_status, source_metadata_json, created_at, last_synced_at + ) + VALUES ( + 'map-malformed', 'b-malformed', 'src-malformed', 'github', '99', + 'ticket-malformed', 'hashBAD', 'open', 'active', 'not valid json!!!', ${now}, ${now} + ) + `; + + const detail = yield* read.getTicketDetail("ticket-malformed" as never); + assert.isDefined(detail, "detail should not be null (no crash)"); + assert.isUndefined( + detail?.syncedSource, + "syncedSource should be undefined for malformed source_metadata_json", + ); + }), + ); + + it.effect( + "getTicketDetail returns syncedSource: undefined when source_metadata_json is null", + () => + Effect.gen(function* () { + const read = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const now = "2026-06-13T00:00:00.000Z"; + + yield* read.registerBoard({ + boardId: "b-null-meta" as never, + projectId: "proj-null-meta" as never, + name: "Null Meta Board", + workflowFilePath: ".t3/boards/null-meta.json", + workflowVersionHash: "hash-null-meta", + maxConcurrentTickets: 5, + }); + + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, board_id, title, current_lane_key, status, created_at, updated_at + ) + VALUES ('ticket-null-meta', 'b-null-meta', 'Null Meta Ticket', 'inbox', 'running', ${now}, ${now}) + `; + + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + content_hash, lifecycle, sync_status, source_metadata_json, created_at, last_synced_at + ) + VALUES ( + 'map-null-meta', 'b-null-meta', 'src-null', 'github', '77', + 'ticket-null-meta', 'hashNULL', 'open', 'active', NULL, ${now}, ${now} + ) + `; + + const detail = yield* read.getTicketDetail("ticket-null-meta" as never); + assert.isDefined(detail, "detail should not be null"); + assert.isUndefined( + detail?.syncedSource, + "syncedSource should be undefined when source_metadata_json is NULL", + ); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowReadModel.ts b/apps/server/src/workflow/Layers/WorkflowReadModel.ts new file mode 100644 index 00000000000..63555f9637d --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowReadModel.ts @@ -0,0 +1,1132 @@ +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 { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { + WorkflowReadModel, + type BoardListRow, + type BoardRow, + type PipelineStepRunRow, + type StepRunRow, + type RouteDecisionStepSnapshot, + type TicketMessageRow, + type TicketRouteDecisionRow, + type TicketPrStateRow, + type TicketPrView, + type TicketRow, + type WorkflowCurrentLaneRow, + type WorkflowLaneActionRow, + type WorkflowNeedsAttentionTicketRow, + 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", + "work_source", +] 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"]), + }; +}; + +const PR_STATES = ["open", "merged", "closed"] as const; +const CI_STATES = ["pending", "success", "failure"] as const; + +const toPrView = ( + prNumber: number | null, + prUrl: string | null, + prState: string | null, + lastCiState: string | null, +): TicketPrView | undefined => { + if (prNumber === null || prUrl === null || prState === null) { + return undefined; + } + const state = PR_STATES.find((s) => s === prState); + if (state === undefined) { + return undefined; + } + const ciState = CI_STATES.find((s) => s === lastCiState); + const view: TicketPrView = { number: prNumber, url: prUrl, state }; + if (ciState !== undefined) { + return { ...view, ciState }; + } + return view; +}; + +interface TicketDependencySqlRow extends TicketRow { + readonly dependsOnJson?: string | null; + // PR columns from the workflow_pr_state LEFT JOIN — null when no row exists. + readonly prNumber?: number | null; + readonly prUrl?: string | null; + readonly prState?: string | null; + readonly prCiState?: string | null; + // Work-source columns from the work_source_mapping LEFT JOIN — null when no mapping row exists. + readonly sourceMetadataJson?: string | null; +} + +// pr_state is NOT NULL DEFAULT 'open' and only our code writes it, so an +// unrecognized value is an invariant violation — surface it once per query +// instead of silently dropping the pr view without a trace. +const warnUnrecognizedPrStates = (rows: ReadonlyArray) => { + const ticketIds = rows + .filter( + (row) => + typeof row.prState === "string" && !PR_STATES.some((state) => state === row.prState), + ) + .map((row) => row.ticketId); + return ticketIds.length === 0 + ? Effect.void + : Effect.logWarning("workflow ticket pr_state unrecognized", { ticketIds }); +}; + +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, prNumber, prUrl, prState, prCiState, sourceMetadataJson: _sm, ...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. + } + } + const pr = toPrView(prNumber ?? null, prUrl ?? null, prState ?? null, prCiState ?? null); + return { + ...ticket, + dependsOn, + unresolvedDependencyCount: ticket.unresolvedDependencyCount ?? 0, + ...(pr !== undefined ? { pr } : {}), + }; +} + +/** + * Parse `source_metadata_json` from a `work_source_mapping` row into the + * `syncedSource` shape expected by `TicketDetail` / `WorkflowTicketDetailView`. + * + * Returns `undefined` when: + * - the column is null/undefined (no mapping row) + * - JSON is malformed + * - the parsed object lacks the required `provider` or `url` fields + */ +function parseSyncedSource( + raw: string | null | undefined, +): + | { + provider: "github" | "asana"; + url: string; + assignees?: ReadonlyArray; + labels?: ReadonlyArray; + } + | undefined { + if (raw == null) return undefined; + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return undefined; + const obj = parsed as Record; + const provider = obj["provider"]; + const url = obj["url"]; + if ((provider !== "github" && provider !== "asana") || typeof url !== "string" || url === "") + return undefined; + const result: { + provider: "github" | "asana"; + url: string; + assignees?: ReadonlyArray; + labels?: ReadonlyArray; + } = { provider, url }; + if (Array.isArray(obj["assignees"])) { + result.assignees = obj["assignees"].filter( + (v): v is string => typeof v === "string", + ); + } + if (Array.isArray(obj["labels"])) { + result.labels = obj["labels"].filter((v): v is string => typeof v === "string"); + } + return result; + } catch { + return undefined; + } +} + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const boardRegistry = yield* BoardRegistry; + + // Resolve the ticket's current lane (name + human actions) from the board + // definition. A board with no registered definition (e.g. a stale or + // unregistered board) degrades to a key-only lane with no actions rather + // than failing the detail read. + const resolveCurrentLane = ( + boardId: string, + currentLaneKey: string, + ): Effect.Effect => + Effect.gen(function* () { + const definition = yield* boardRegistry.getDefinition(boardId as never); + const lane = definition?.lanes.find((candidate) => candidate.key === currentLaneKey); + if (lane === undefined) { + yield* Effect.logDebug("workflow current lane definition unresolved", { + boardId, + currentLaneKey, + }); + return { key: currentLaneKey, name: currentLaneKey, actions: [] }; + } + const actions: ReadonlyArray = (lane.actions ?? []).map((action) => ({ + label: action.label, + to: action.to as string, + ...(action.hint === undefined ? {} : { hint: action.hint }), + })); + return { key: lane.key as string, name: lane.name, actions }; + }); + + 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 workflow_pr_observation + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM workflow_pr_state + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM workflow_notification_outbox + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM work_source_mapping + WHERE ticket_id IN ( + SELECT ticket_id + FROM projection_ticket + WHERE board_id = ${boardId} + ) + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM work_source_state + 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 workflow_pr_observation + WHERE ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM workflow_pr_state + WHERE ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM workflow_notification_outbox + WHERE ticket_id = ${ticketId} + `), + ), + Effect.andThen( + wrap(sql` + DELETE FROM work_source_mapping + WHERE 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 + projection_ticket.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", + projection_ticket.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", + pr.pr_number AS "prNumber", + pr.pr_url AS "prUrl", + pr.pr_state AS "prState", + pr.last_ci_state AS "prCiState", + status + FROM projection_ticket + LEFT JOIN workflow_pr_state AS pr + ON pr.ticket_id = projection_ticket.ticket_id + WHERE board_id = ${boardId} + ORDER BY created_at ASC + `).pipe( + Effect.tap(warnUnrecognizedPrStates), + 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 + projection_ticket.ticket_id AS "ticketId", + projection_ticket.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", + projection_ticket.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", + pr.pr_number AS "prNumber", + pr.pr_url AS "prUrl", + pr.pr_state AS "prState", + pr.last_ci_state AS "prCiState", + status, + attention_kind AS "attentionKind", + attention_reason AS "attentionReason", + wsm.source_metadata_json AS "sourceMetadataJson" + FROM projection_ticket + LEFT JOIN workflow_pr_state AS pr + ON pr.ticket_id = projection_ticket.ticket_id + LEFT JOIN work_source_mapping AS wsm + ON wsm.ticket_id = projection_ticket.ticket_id + WHERE projection_ticket.ticket_id = ${ticketId} + `); + const rawTicket = ticketRows[0]; + if (!rawTicket) { + return null; + } + yield* warnUnrecognizedPrStates(ticketRows); + const currentLane = yield* resolveCurrentLane( + rawTicket.boardId, + rawTicket.currentLaneKey, + ); + const ticket: TicketRow = { ...withDependencyFields(rawTicket), currentLane }; + + const syncedSource = parseSyncedSource(rawTicket.sourceMetadataJson); + + 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, ...(syncedSource !== undefined ? { syncedSource } : {}) }; + }); + + 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)), + })), + }; + }); + + // No environment filter is needed: each t3 server process owns exactly one + // SQLite database file, and that file is already environment-scoped at the + // process / WebSocket-connection level. There is no multi-environment-per-DB + // path (projection_board carries project_id, not an environment_id, and the + // server never shares one DB across environments). Confirmed in T8 Part C. + const listNeedsAttentionTickets: WorkflowReadModelShape["listNeedsAttentionTickets"] = () => + wrap(sql` + SELECT + pt.ticket_id AS "ticketId", + pt.board_id AS "boardId", + pb.name AS "boardName", + pt.title, + pt.status, + pt.current_lane_key AS "currentLaneKey", + pt.attention_kind AS "attentionKind", + pt.attention_reason AS "attentionReason", + pt.updated_at AS "updatedAt" + FROM projection_ticket AS pt + INNER JOIN projection_board AS pb + ON pb.board_id = pt.board_id + WHERE pt.status IN ('waiting_on_user', 'blocked') + ORDER BY pt.updated_at ASC + `); + + 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); + }); + + const getTicketPrState: WorkflowReadModelShape["getTicketPrState"] = (ticketId) => + wrap(sql` + SELECT + pr_number AS "prNumber", + pr_url AS "prUrl", + branch, + remote_name AS "remoteName", + repo, + pr_state AS "prState", + last_head_sha AS "lastHeadSha", + last_ci_state AS "lastCiState", + last_review_decision AS "lastReviewDecision", + last_comment_cursor AS "lastCommentCursor" + FROM workflow_pr_state + WHERE ticket_id = ${ticketId} + `).pipe(Effect.map((rows) => rows[0] ?? null)); + + return { + registerBoard, + getBoard, + deleteBoard, + deleteBoardTicketState, + deleteTicketState, + listBoardsForProject, + listTickets, + countAdmittedInLane, + oldestQueuedForLane, + getTicketDetail, + countLanePipelineRuns, + listTicketMessages, + listTicketDiscussion, + listTicketRouteDecisions, + listReleasableDependents, + listDependentTicketIds, + getBoardDigest, + listNeedsAttentionTickets, + listStepRunsForPipeline, + getTicketPrState, + } 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..fbb61ec7e1f --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRecovery.test.ts @@ -0,0 +1,3084 @@ +// @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 { GitHubPort, type GitHubPortShape } from "../Services/GitHubPort.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; + +// Mutable GitHub port double for PR recovery tests. Reset per test. +const gitHubPortScript: { + findPrForBranch: { number: number; url: string } | null; + prDetailState: "open" | "merged" | "closed"; + findPrForBranchCalls: number; +} = { + findPrForBranch: null, + prDetailState: "open", + findPrForBranchCalls: 0, +}; + +const RecoveryGitHubPortLayer = Layer.succeed(GitHubPort, { + resolveRemote: () => Effect.succeed({ remoteName: "origin", repo: "acme/widgets" }), + findPrForBranch: () => + Effect.sync(() => { + gitHubPortScript.findPrForBranchCalls += 1; + return gitHubPortScript.findPrForBranch; + }), + prDetail: (input: { prNumber: number }) => + Effect.succeed({ + number: input.prNumber, + url: `https://github.com/acme/widgets/pull/${input.prNumber}`, + state: gitHubPortScript.prDetailState, + headSha: null, + reviewDecision: "none" as const, + ciState: "success" as const, + }), +} as unknown as GitHubPortShape); + +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"), + createTicketAndEnterUnlocked: () => Effect.die("unused createTicketAndEnterUnlocked"), + closeTicketFromSourceUnlocked: () => Effect.die("unused closeTicketFromSourceUnlocked"), + cancellableProviderTurnsForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + supersedeProviderWorkForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + editTicketFieldsUnlocked: () => Effect.die("unused editTicketFieldsUnlocked"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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), + mappingId: () => Effect.succeed("mapping-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(RecoveryGitHubPortLayer), + 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), + appendManyUnlocked: (events) => + Effect.forEach(events, appendAndProject, { concurrency: 1 }).pipe( + Effect.mapError(toDelayedCommitterError), + ), + publishTicketView: () => Effect.void, + } 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"), + createTicketAndEnterUnlocked: () => Effect.die("unused createTicketAndEnterUnlocked"), + closeTicketFromSourceUnlocked: () => Effect.die("unused closeTicketFromSourceUnlocked"), + cancellableProviderTurnsForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + supersedeProviderWorkForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + editTicketFieldsUnlocked: () => Effect.die("unused editTicketFieldsUnlocked"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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), + mappingId: () => Effect.succeed("mapping-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"), + createTicketAndEnterUnlocked: () => Effect.die("unused createTicketAndEnterUnlocked"), + closeTicketFromSourceUnlocked: () => Effect.die("unused closeTicketFromSourceUnlocked"), + cancellableProviderTurnsForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + supersedeProviderWorkForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + editTicketFieldsUnlocked: () => Effect.die("unused editTicketFieldsUnlocked"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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), + mappingId: () => Effect.succeed("mapping-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)); + }), +); + +it.effect("fails an interrupted panel step even when only one member row is still started", () => + Effect.gen(function* () { + const recovered = yield* Ref.make< + ReadonlyArray<{ readonly stepRunId: string; readonly result: unknown }> + >([]); + const providerStarts = yield* Ref.make>([]); + const panelRecoveryLayer = WorkflowRecoveryLive.pipe( + Layer.provideMerge(ProviderDispatchOutboxLive), + Layer.provideMerge( + Layer.succeed(ProviderTurnPort, { + // A dead panel member must never be re-dispatched by recovery. + ensureTurnStarted: (request) => + Ref.update(providerStarts, (starts) => [...starts, request.dispatchId as string]).pipe( + Effect.as({ turnId: "turn-panel-live" as never }), + ), + }), + ), + Layer.provideMerge( + Layer.succeed(TurnStateReader, { + // Member 2 of panel A crashed mid-turn (projected 'running'); + // member 2 of panel B reached a terminal turn before the crash. + // Neither may decide its panel single-handedly. + read: (threadId) => + Effect.succeed( + (threadId as string) === "thread-panel-b-member-2" + ? ({ _tag: "completed" } as const) + : ({ _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.effect( + WorkflowEngine, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return { + createTicket: () => Effect.die("unused createTicket"), + editTicket: () => Effect.void, + moveTicket: () => Effect.die("unused moveTicket"), + createTicketAndEnterUnlocked: () => Effect.die("unused createTicketAndEnterUnlocked"), + closeTicketFromSourceUnlocked: () => Effect.die("unused closeTicketFromSourceUnlocked"), + cancellableProviderTurnsForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + supersedeProviderWorkForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + editTicketFieldsUnlocked: () => Effect.die("unused editTicketFieldsUnlocked"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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, + // Record the call and settle the projection like the real + // engine would, so later recovery stages see the step done. + completeRecoveredStep: (stepRunId, result) => + Ref.update(recovered, (calls) => [ + ...calls, + { stepRunId: stepRunId as string, result }, + ]).pipe( + Effect.andThen( + sql` + UPDATE projection_step_run + SET status = 'failed' + WHERE step_run_id = ${stepRunId as string} + `.pipe(Effect.orDie), + ), + Effect.asVoid, + ), + }; + }), + ), + ), + 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-panel-recovery-${recoveryEventId}` as never; + }), + token: () => Effect.succeed("token-unused" as never), + mappingId: () => Effect.succeed("mapping-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-panel-recovery" as never, { + name: "Panel Recovery", + lanes: [ + { + key: "review", + name: "Review", + entry: "manual", + pipeline: [ + { + key: "panel-review", + type: "agent", + agent: { instance: "codex", model: "gpt-5.5" }, + instruction: "review the work", + captureOutput: true, + panel: 3, + }, + ], + }, + ], + }); + yield* sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + 'board-panel-recovery', + 'project-panel-recovery', + 'Panel Recovery', + '.t3/boards/panel-recovery.json', + 'hash-panel-recovery', + 1 + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES + ( + 'ticket-panel-recovery', + 'board-panel-recovery', + 'Panel recovery', + 'review', + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:02.000Z' + ), + ( + 'ticket-panel-recovery-b', + 'board-panel-recovery', + 'Panel recovery B', + 'review', + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:02.000Z' + ) + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + status, + started_at + ) + VALUES + ( + 'step-panel-recovery', + 'pipeline-panel-recovery', + 'ticket-panel-recovery', + 'panel-review', + 'agent', + 'running', + '2026-06-07T00:00:00.000Z' + ), + ( + 'step-panel-recovery-b', + 'pipeline-panel-recovery-b', + 'ticket-panel-recovery-b', + 'panel-review', + 'agent', + 'running', + '2026-06-07T00:00:00.000Z' + ) + `; + // A 3-member sequential panel crashed mid-member-2: member 1 already + // confirmed, member 3 was never dispatched. Only member 2 is 'started'. + 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-panel-member-1', + 'ticket-panel-recovery', + 'step-panel-recovery', + 'thread-panel-member-1', + 'codex', + 'gpt-5.5', + 'review the work', + '/tmp/panel-recovery', + 'confirmed', + 'turn-panel-member-1', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:01.000Z' + ), + ( + 'dispatch-panel-member-2', + 'ticket-panel-recovery', + 'step-panel-recovery', + 'thread-panel-member-2', + 'codex', + 'gpt-5.5', + 'review the work', + '/tmp/panel-recovery', + 'started', + 'turn-panel-member-2', + '2026-06-07T00:00:01.000Z', + '2026-06-07T00:00:01.000Z', + NULL + ), + ( + 'dispatch-panel-b-member-1', + 'ticket-panel-recovery-b', + 'step-panel-recovery-b', + 'thread-panel-b-member-1', + 'codex', + 'gpt-5.5', + 'review the work', + '/tmp/panel-recovery-b', + 'confirmed', + 'turn-panel-b-member-1', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:01.000Z' + ), + ( + 'dispatch-panel-b-member-2', + 'ticket-panel-recovery-b', + 'step-panel-recovery-b', + 'thread-panel-b-member-2', + 'codex', + 'gpt-5.5', + 'review the work', + '/tmp/panel-recovery-b', + 'started', + 'turn-panel-b-member-2', + '2026-06-07T00:00:01.000Z', + '2026-06-07T00:00:01.000Z', + NULL + ) + `; + + yield* recovery.recover(); + + // No panel member may be re-dispatched: recovery must settle the + // panels without starting fresh provider turns for dead members. + assert.deepEqual(yield* Ref.get(providerStarts), []); + const recoveredCalls = [...(yield* Ref.get(recovered))].sort((a, b) => + a.stepRunId.localeCompare(b.stepRunId), + ); + assert.deepEqual(recoveredCalls, [ + { + stepRunId: "step-panel-recovery", + result: { + _tag: "failed", + error: "review panel interrupted by restart", + retryable: true, + }, + }, + { + stepRunId: "step-panel-recovery-b", + result: { + _tag: "failed", + error: "review panel interrupted by restart", + retryable: true, + }, + }, + ]); + const outboxRows = yield* sql<{ readonly status: string }>` + SELECT status + FROM workflow_dispatch_outbox + WHERE step_run_id IN ('step-panel-recovery', 'step-panel-recovery-b') + ORDER BY dispatch_id ASC + `; + assert.deepEqual( + outboxRows.map((row) => row.status), + ["confirmed", "confirmed", "confirmed", "confirmed"], + ); + }).pipe(Effect.provide(panelRecoveryLayer)); + }), +); + +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"), + createTicketAndEnterUnlocked: () => Effect.die("unused createTicketAndEnterUnlocked"), + closeTicketFromSourceUnlocked: () => Effect.die("unused closeTicketFromSourceUnlocked"), + cancellableProviderTurnsForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + supersedeProviderWorkForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + editTicketFieldsUnlocked: () => Effect.die("unused editTicketFieldsUnlocked"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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), + mappingId: () => Effect.succeed("mapping-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"), + createTicketAndEnterUnlocked: () => Effect.die("unused createTicketAndEnterUnlocked"), + closeTicketFromSourceUnlocked: () => Effect.die("unused closeTicketFromSourceUnlocked"), + cancellableProviderTurnsForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + supersedeProviderWorkForTicket: () => Effect.die("unused closeTicketFromSourceUnlocked"), + editTicketFieldsUnlocked: () => Effect.die("unused editTicketFieldsUnlocked"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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), + mappingId: () => Effect.succeed("mapping-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"); + }), + ); + + it.effect("recovers an already-terminal merge step with its stored outcome", () => + Effect.gen(function* () { + completedRecoveredSteps.length = 0; + const sql = yield* SqlClient.SqlClient; + const recovery = yield* WorkflowRecovery; + + // Crash window: the StepCompleted event was appended but the crash hit + // before the projection update, so the step run still says 'running'. + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + status, + started_at + ) + VALUES ( + 'step-run-merge-terminal', + 'pipeline-merge-terminal', + 'ticket-merge-terminal', + 'land', + 'merge', + 'running', + '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-merge-terminal-completed', + 'ticket-merge-terminal', + 0, + 'StepCompleted', + '2026-06-07T00:00:01.000Z', + '{"stepRunId":"step-run-merge-terminal","output":{"merged":true}}' + ) + `; + + yield* recovery.recover(); + + assert.deepEqual(completedRecoveredSteps, [ + { + stepRunId: "step-run-merge-terminal", + result: { _tag: "completed", output: { merged: true } }, + }, + ]); + }), + ); + + // --- pullRequest step recovery --------------------------------------------- + + const prBoardDefinition = { + name: "pr recovery", + lanes: [ + { + key: "ship", + name: "Ship", + entry: "auto", + pipeline: [ + { key: "open-pr", type: "pullRequest", action: "open" }, + { key: "land-pr", type: "pullRequest", action: "land" }, + ], + }, + ], + }; + + const seedPrStep = (input: { + readonly boardId: string; + readonly ticketId: string; + readonly stepRunId: string; + readonly stepKey: string; + }) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const registry = yield* BoardRegistry; + yield* registry.register(input.boardId as never, prBoardDefinition); + yield* sql` + INSERT INTO projection_board ( + board_id, project_id, name, workflow_file_path, + workflow_version_hash, max_concurrent_tickets + ) + VALUES ( + ${input.boardId}, ${`${input.boardId}-project`}, 'PR recovery', + '.t3/boards/pr.json', ${`hash-${input.boardId}`}, 1 + ) + `; + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, scripts_json, created_at, updated_at + ) + VALUES ( + ${`${input.boardId}-project`}, 'PR repo', '/tmp/pr-repo', '{}', + '2026-06-07T00:00:00.000Z', '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}, 'PR ticket', 'ship', 'running', + '2026-06-07T00:00:00.000Z', '2026-06-07T00:00:01.000Z' + ) + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, pipeline_run_id, ticket_id, step_key, step_type, status, started_at + ) + VALUES ( + ${input.stepRunId}, ${`${input.stepRunId}-pipeline`}, ${input.ticketId}, + ${input.stepKey}, 'pullRequest', 'running', '2026-06-07T00:00:00.000Z' + ) + `; + }); + + const seedPrStateRow = (ticketId: string, prNumber: number) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, pr_state, updated_at + ) + VALUES ( + ${ticketId}, ${prNumber}, ${`https://github.com/acme/widgets/pull/${prNumber}`}, + ${`workflow/${ticketId}`}, 'origin', 'acme/widgets', 'open', '2026-06-07T00:00:02.000Z' + ) + `; + }); + + it.effect("recovers an open PR step from recorded PR state without adopting", () => + Effect.gen(function* () { + completedRecoveredSteps.length = 0; + gitHubPortScript.findPrForBranch = null; + gitHubPortScript.findPrForBranchCalls = 0; + const recovery = yield* WorkflowRecovery; + + yield* seedPrStep({ + boardId: "board-pr-open-recorded", + ticketId: "ticket-pr-open-recorded", + stepRunId: "step-run-pr-open-recorded", + stepKey: "open-pr", + }); + yield* seedPrStateRow("ticket-pr-open-recorded", 42); + + yield* recovery.recover(); + + const calls = completedRecoveredSteps.filter( + (c) => c.stepRunId === "step-run-pr-open-recorded", + ); + assert.deepEqual(calls, [ + { + stepRunId: "step-run-pr-open-recorded", + result: { + _tag: "completed", + output: { prNumber: 42, url: "https://github.com/acme/widgets/pull/42" }, + }, + }, + ]); + // PR already recorded → no branch lookup, no extra TicketPrOpened. + assert.equal(gitHubPortScript.findPrForBranchCalls, 0); + const events = yield* Stream.runCollect( + (yield* WorkflowEventStore).readByTicket("ticket-pr-open-recorded" as never), + ); + assert.equal( + Array.from(events).filter((e) => e.type === "TicketPrOpened").length, + 0, + ); + }), + ); + + it.effect("adopts a created-but-unrecorded PR and commits TicketPrOpened", () => + Effect.gen(function* () { + completedRecoveredSteps.length = 0; + gitHubPortScript.findPrForBranch = { + number: 77, + url: "https://github.com/acme/widgets/pull/77", + }; + gitHubPortScript.findPrForBranchCalls = 0; + const recovery = yield* WorkflowRecovery; + + yield* seedPrStep({ + boardId: "board-pr-open-adopt", + ticketId: "ticket-pr-open-adopt", + stepRunId: "step-run-pr-open-adopt", + stepKey: "open-pr", + }); + + yield* recovery.recover(); + + const calls = completedRecoveredSteps.filter( + (c) => c.stepRunId === "step-run-pr-open-adopt", + ); + assert.deepEqual(calls, [ + { + stepRunId: "step-run-pr-open-adopt", + result: { + _tag: "completed", + output: { prNumber: 77, url: "https://github.com/acme/widgets/pull/77" }, + }, + }, + ]); + assert.isAtLeast(gitHubPortScript.findPrForBranchCalls, 1); + const events = yield* Stream.runCollect( + (yield* WorkflowEventStore).readByTicket("ticket-pr-open-adopt" as never), + ); + const opened = Array.from(events).filter((e) => e.type === "TicketPrOpened"); + assert.equal(opened.length, 1); + assert.equal( + (opened[0] as { payload: { prNumber: number } }).payload.prNumber, + 77, + ); + }), + ); + + it.effect("fails an open PR step when no PR exists on the remote", () => + Effect.gen(function* () { + completedRecoveredSteps.length = 0; + gitHubPortScript.findPrForBranch = null; + const recovery = yield* WorkflowRecovery; + + yield* seedPrStep({ + boardId: "board-pr-open-none", + ticketId: "ticket-pr-open-none", + stepRunId: "step-run-pr-open-none", + stepKey: "open-pr", + }); + + yield* recovery.recover(); + + const calls = completedRecoveredSteps.filter( + (c) => c.stepRunId === "step-run-pr-open-none", + ); + assert.deepEqual(calls, [ + { + stepRunId: "step-run-pr-open-none", + result: { _tag: "failed", error: "PR open interrupted by restart" }, + }, + ]); + }), + ); + + it.effect("completes a land PR step when prDetail reports merged", () => + Effect.gen(function* () { + completedRecoveredSteps.length = 0; + gitHubPortScript.prDetailState = "merged"; + const recovery = yield* WorkflowRecovery; + + yield* seedPrStep({ + boardId: "board-pr-land-merged", + ticketId: "ticket-pr-land-merged", + stepRunId: "step-run-pr-land-merged", + stepKey: "land-pr", + }); + yield* seedPrStateRow("ticket-pr-land-merged", 55); + + yield* recovery.recover(); + + const calls = completedRecoveredSteps.filter( + (c) => c.stepRunId === "step-run-pr-land-merged", + ); + assert.deepEqual(calls, [ + { stepRunId: "step-run-pr-land-merged", result: { _tag: "completed" } }, + ]); + }), + ); + + it.effect("fails a land PR step when prDetail reports not merged", () => + Effect.gen(function* () { + completedRecoveredSteps.length = 0; + gitHubPortScript.prDetailState = "open"; + const recovery = yield* WorkflowRecovery; + + yield* seedPrStep({ + boardId: "board-pr-land-open", + ticketId: "ticket-pr-land-open", + stepRunId: "step-run-pr-land-open", + stepKey: "land-pr", + }); + yield* seedPrStateRow("ticket-pr-land-open", 56); + + yield* recovery.recover(); + + const calls = completedRecoveredSteps.filter( + (c) => c.stepRunId === "step-run-pr-land-open", + ); + assert.deepEqual(calls, [ + { + stepRunId: "step-run-pr-land-open", + result: { _tag: "failed", error: "land interrupted by restart" }, + }, + ]); + }), + ); + + it.effect("fails a land PR step when no PR state is recorded", () => + Effect.gen(function* () { + completedRecoveredSteps.length = 0; + const recovery = yield* WorkflowRecovery; + + yield* seedPrStep({ + boardId: "board-pr-land-norow", + ticketId: "ticket-pr-land-norow", + stepRunId: "step-run-pr-land-norow", + stepKey: "land-pr", + }); + + yield* recovery.recover(); + + const calls = completedRecoveredSteps.filter( + (c) => c.stepRunId === "step-run-pr-land-norow", + ); + assert.deepEqual(calls, [ + { + stepRunId: "step-run-pr-land-norow", + result: { _tag: "failed", error: "land interrupted by restart" }, + }, + ]); + }), + ); + + it.effect("fails a running step whose outbox rows were confirmed before the terminal event", () => + Effect.gen(function* () { + completedRecoveredSteps.length = 0; + const sql = yield* SqlClient.SqlClient; + const recovery = yield* WorkflowRecovery; + + // Crash window: awaitTerminal confirmed the dispatch row (e.g. on its + // 30-minute timeout) but the process died before the engine committed + // the step's terminal event. No dispatch stage looks at confirmed rows + // and the projection still says 'running' with no terminal event. + yield* sql` + INSERT INTO projection_board ( + board_id, + project_id, + name, + workflow_file_path, + workflow_version_hash, + max_concurrent_tickets + ) + VALUES ( + 'board-confirmed-crash', + 'project-confirmed-crash', + 'Confirmed Crash', + '.t3/boards/confirmed-crash.json', + 'hash-confirmed-crash', + 1 + ) + `; + yield* sql` + INSERT INTO projection_ticket ( + ticket_id, + board_id, + title, + current_lane_key, + status, + created_at, + updated_at + ) + VALUES ( + 'ticket-confirmed-crash', + 'board-confirmed-crash', + 'Confirmed crash', + 'impl', + 'running', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:01.000Z' + ) + `; + yield* sql` + INSERT INTO projection_step_run ( + step_run_id, + pipeline_run_id, + ticket_id, + step_key, + step_type, + status, + started_at + ) + VALUES ( + 'step-confirmed-crash', + 'pipeline-confirmed-crash', + 'ticket-confirmed-crash', + 'implement', + 'agent', + 'running', + '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, + turn_id, + created_at, + started_at, + confirmed_at + ) + VALUES ( + 'dispatch-confirmed-crash', + 'ticket-confirmed-crash', + 'step-confirmed-crash', + 'thread-confirmed-crash', + 'codex', + 'gpt-5.5', + 'implement the step', + '/tmp/confirmed-crash', + 'confirmed', + 'turn-confirmed-crash', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:00.000Z', + '2026-06-07T00:00:01.000Z' + ) + `; + + yield* recovery.recover(); + + const calls = completedRecoveredSteps.filter( + (call) => call.stepRunId === "step-confirmed-crash", + ); + assert.deepEqual(calls, [ + { + stepRunId: "step-confirmed-crash", + result: { _tag: "failed", error: "step interrupted by server restart" }, + }, + ]); + }), + ); +}); diff --git a/apps/server/src/workflow/Layers/WorkflowRecovery.ts b/apps/server/src/workflow/Layers/WorkflowRecovery.ts new file mode 100644 index 00000000000..ba0eaa6c011 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRecovery.ts @@ -0,0 +1,1112 @@ +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 { GitHubPort } from "../Services/GitHubPort.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"; +const STEP_RESTART_ERROR = "step interrupted by server restart"; +const PR_OPEN_RESTART_ERROR = "PR open interrupted by restart"; +const PR_LAND_RESTART_ERROR = "land interrupted by 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); + +type TerminalStepEvent = Extract< + PersistedWorkflowEvent, + { readonly type: "StepCompleted" | "StepFailed" | "StepBlocked" } +>; + +const isTerminalStepEvent = (event: PersistedWorkflowEvent): event is TerminalStepEvent => + event.type === "StepCompleted" || event.type === "StepFailed" || event.type === "StepBlocked"; + +// Shared mapping from a step's stored terminal outcome to the recovered +// result: a step that already reached a terminal state must be recovered +// with that state, never a synthesized restart failure. +const toRecoveredStepResult = (terminal: { + readonly status: "completed" | "failed" | "blocked"; + readonly error: string | null; + readonly retryable: boolean; + readonly output: unknown; +}): RecoveredStepResult => + terminal.status === "completed" + ? { _tag: "completed", ...(terminal.output === undefined ? {} : { output: terminal.output }) } + : terminal.status === "blocked" + ? { _tag: "blocked", reason: terminal.error ?? "step blocked" } + : { + _tag: "failed", + error: terminal.error ?? "step failed", + ...(terminal.retryable ? {} : { retryable: false }), + }; + +const recoveredResultFromTerminalEvent = (event: TerminalStepEvent): RecoveredStepResult => + event.type === "StepCompleted" + ? toRecoveredStepResult({ + status: "completed", + error: null, + retryable: true, + output: event.payload.output, + }) + : event.type === "StepBlocked" + ? toRecoveredStepResult({ + status: "blocked", + error: event.payload.reason, + retryable: true, + output: undefined, + }) + : toRecoveredStepResult({ + status: "failed", + error: event.payload.error, + retryable: event.payload.retryable !== false, + output: undefined, + }); + +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, + ); + // PR recovery inspects external state through gh. Trimmed test layers without + // a GitHubPort fall back to "not found" → failed, never crash recovery. + const gitHub = Context.getOption( + (yield* Effect.context()) as Context.Context, + GitHubPort, + ); + 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 latestTerminalStepEvent = ( + events: ReadonlyArray, + stepRunId: StepRunId, + ): TerminalStepEvent | null => + events.reduce( + (latest, event) => + isTerminalStepEvent(event) && event.payload.stepRunId === stepRunId ? event : latest, + null, + ); + + 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; + } + // Interrupted panels are settled by settleInterruptedPanelDispatches + // before this stage runs; any panel row still unconfirmed here must + // not be recovered single-dispatch (one member's terminal turn would + // decide the whole panel) nor reset for re-dispatch. + if (yield* isPanelStep(row.stepRunId as string)) { + 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); + } + // Same crash window as merge recovery: a stored terminal event means + // the step already finished — recover its outcome, don't fail it. + const terminal = latestTerminalStepEvent(events, row.stepRunId); + if (terminal !== null) { + yield* engine.completeRecoveredStep( + row.stepRunId, + recoveredResultFromTerminalEvent(terminal), + ); + continue; + } + 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); + // A crash between the terminal event append and its projection leaves + // the step 'running' even though it already finished — recover the + // stored outcome instead of synthesizing a failure. + const terminal = latestTerminalStepEvent(events, row.stepRunId); + if (terminal !== null) { + yield* engine.completeRecoveredStep( + row.stepRunId, + recoveredResultFromTerminalEvent(terminal), + ); + continue; + } + const result = yield* inspectInterruptedMerge(row.repoRoot, row.ticketId); + yield* engine.completeRecoveredStep(row.stepRunId, result); + } + }); + + // Resolve a pullRequest step's `action` ("open" | "land") from its board + // definition. Returns null when the step def is no longer resolvable (board + // edited/unloaded) — the caller then fails the step honestly. + const resolvePullRequestAction = (boardId: BoardId, stepKey: string) => + Effect.gen(function* () { + const definition = yield* boardRegistry.getDefinition(boardId); + const step = definition?.lanes + .flatMap((lane) => lane.pipeline ?? []) + .find((candidate) => candidate.key === stepKey); + return step !== undefined && step.type === "pullRequest" ? step.action : null; + }); + + // Recovery is by inspection (no retry budget), mirroring merge recovery. A PR + // step left 'running' after a crash is settled by checking external state: + // - open : getTicketPrState is the authority. A recorded row means + // TicketPrOpened already committed → completed. No row means the open + // never committed; if a PR was nonetheless created on the remote + // (crash-after-create-before-commit) findPrForBranch adopts it, committing + // the missing TicketPrOpened. No PR found → failed. + // - land : prDetail on the recorded PR. state "merged" → completed; anything + // else (or no recorded PR) → failed (a land cannot have succeeded without a + // recorded PR to merge). + const recoverRunningPullRequestSteps = Effect.gen(function* () { + const rows = yield* wrapSql(sql<{ + readonly ticketId: TicketId; + readonly stepRunId: StepRunId; + readonly stepKey: string; + readonly boardId: BoardId; + readonly repoRoot: string | null; + }>` + SELECT + step.ticket_id AS "ticketId", + step.step_run_id AS "stepRunId", + step.step_key AS "stepKey", + ticket.board_id AS "boardId", + ( + SELECT projects.workspace_root + FROM projection_board AS board + INNER JOIN projection_projects AS projects + ON projects.project_id = board.project_id + WHERE board.board_id = ticket.board_id + ) AS "repoRoot" + FROM projection_step_run AS step + INNER JOIN projection_ticket AS ticket + ON ticket.ticket_id = step.ticket_id + WHERE step.step_type = 'pullRequest' + AND step.status IN ('running', 'dispatch_requested') + `); + + for (const row of rows) { + const events = yield* ticketEvents(row.ticketId); + // Same crash window as merge recovery: a stored terminal event means the + // step already finished — recover its outcome. + const terminal = latestTerminalStepEvent(events, row.stepRunId); + if (terminal !== null) { + yield* engine.completeRecoveredStep( + row.stepRunId, + recoveredResultFromTerminalEvent(terminal), + ); + continue; + } + + const action = yield* resolvePullRequestAction(row.boardId, row.stepKey); + const prState = yield* readModel.getTicketPrState(row.ticketId); + + if (action === "land") { + // A land cannot have landed without a recorded PR to merge. + if (prState === null || Option.isNone(gitHub) || row.repoRoot === null) { + yield* engine.completeRecoveredStep(row.stepRunId, { + _tag: "failed", + error: PR_LAND_RESTART_ERROR, + }); + continue; + } + const result = yield* gitHub.value + .prDetail({ cwd: row.repoRoot, prNumber: prState.prNumber }) + .pipe(Effect.orElseSucceed(() => null)); + yield* engine.completeRecoveredStep( + row.stepRunId, + result !== null && result.state === "merged" + ? { _tag: "completed" } + : { _tag: "failed", error: PR_LAND_RESTART_ERROR }, + ); + continue; + } + + // action === "open" (or an unresolvable step def — treat as open). + if (prState !== null) { + // TicketPrOpened already committed: the PR exists and was recorded. + yield* engine.completeRecoveredStep(row.stepRunId, { + _tag: "completed", + output: { prNumber: prState.prNumber, url: prState.prUrl }, + }); + continue; + } + + // No recorded PR. A PR may still have been created on the remote before + // the crash; adopt it by branch, committing the missing TicketPrOpened. + // Without a gh port or a repo root there is no way to look one up. + if (Option.isNone(gitHub) || row.repoRoot === null) { + yield* engine.completeRecoveredStep(row.stepRunId, { + _tag: "failed", + error: PR_OPEN_RESTART_ERROR, + }); + continue; + } + const github = gitHub.value; + const repoRoot = row.repoRoot; + const branch = `workflow/${row.ticketId}`; + const found = yield* github + .findPrForBranch({ cwd: repoRoot, branch }) + .pipe(Effect.orElseSucceed(() => null)); + if (found === null) { + yield* engine.completeRecoveredStep(row.stepRunId, { + _tag: "failed", + error: PR_OPEN_RESTART_ERROR, + }); + continue; + } + + // The remote PR is real but unrecorded: commit TicketPrOpened so the + // PR-state projection is consistent, then complete the step. resolveRemote + // backfills the remote/repo metadata the open action would have recorded. + const remote = yield* github + .resolveRemote(repoRoot) + .pipe(Effect.orElseSucceed(() => null)); + yield* committer.commit({ + type: "TicketPrOpened", + eventId: yield* ids.eventId(), + ticketId: row.ticketId, + occurredAt: yield* nowIso, + payload: { + stepRunId: row.stepRunId, + prNumber: found.number, + url: found.url, + branch, + remoteName: remote?.remoteName ?? "origin", + repo: remote?.repo ?? "", + }, + } as WorkflowEventInput); + yield* engine.completeRecoveredStep(row.stepRunId, { + _tag: "completed", + output: { prNumber: found.number, url: found.url }, + }); + } + }); + + // 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) { + yield* engine.completeRecoveredStep( + row.stepRunId, + toRecoveredStepResult({ + status: row.status, + error: row.error, + retryable: row.retryable !== 0, + output: parseOutput(row.outputJson), + }), + ); + } + }); + + // Panel detection must not depend on outbox row status: a sequential panel + // crashed mid-member leaves earlier members 'confirmed' (and later members + // not yet dispatched), so the started-row group can shrink to a single row + // even though the step is a panel. Resolve the step definition instead. + const isPanelStep = (stepRunId: string) => + Effect.gen(function* () { + const stepRows = yield* wrapSql(sql<{ + readonly stepKey: string; + readonly boardId: BoardId; + }>` + SELECT + step.step_key AS "stepKey", + 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} + `); + const stepRow = stepRows[0]; + if (stepRow !== undefined) { + const definition = yield* boardRegistry.getDefinition(stepRow.boardId); + const step = definition?.lanes + .flatMap((lane) => lane.pipeline ?? []) + .find((candidate) => candidate.key === stepRow.stepKey); + if (step !== undefined) { + // Mirrors the executor's panel gate (RealStepExecutor): a panel + // only fans out when captureOutput is set. + return step.type === "agent" && (step.panel ?? 0) >= 2 && step.captureOutput === true; + } + } + // The step definition is not resolvable (board edited or unloaded); + // fall back to counting every outbox row for the step regardless of + // status — a panel fans out several dispatches under one stepRunId. + const counts = yield* wrapSql(sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM workflow_dispatch_outbox + WHERE step_run_id = ${stepRunId} + `); + return (counts[0]?.count ?? 0) > 1; + }); + + // An interrupted panel cannot be resumed member-by-member: settle every + // member row and fail the step honestly (retryable) instead. + const settleInterruptedPanel = (stepRunId: string) => + 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 })); + + // Panel rows must be settled before any single-dispatch stage touches the + // outbox: recoverTerminalDispatches would complete the whole panel from one + // member's terminal turn, and its row reset would let recoverPending start + // a fresh provider turn for a dead panel member that nothing ever stops. + const settleInterruptedPanelDispatches = Effect.gen(function* () { + yield* deleteOrphanDispatches; + const rows = yield* wrapSql(sql<{ readonly stepRunId: StepRunId }>` + SELECT DISTINCT step_run_id AS "stepRunId" + FROM workflow_dispatch_outbox + WHERE status != 'confirmed' + `); + for (const row of rows) { + if (yield* isPanelStep(row.stepRunId as string)) { + yield* settleInterruptedPanel(row.stepRunId as string); + } + } + }); + + // Crash window: awaitTerminal (or the panel settlement) confirmed a step's + // outbox rows but the process died before the engine committed the step's + // terminal event. Every dispatch stage keys off non-confirmed rows and + // resumeStrandedPipelines keys off projection-terminal steps, so nothing + // else would ever settle the step — the ticket would stick 'running' + // forever. Steps awaiting user input are excluded twice over: their + // projection status is 'awaiting_user' and their dispatch row stays + // 'started' until the wait resolves. + // + // No provider-session cleanup happens here (or anywhere in recovery): + // recovery runs once at server startup, when every adapter session + // registry is empty — interruptTurn/stopSession would only fail with + // session-not-found. An agent child process orphaned by a hard-killed + // server is unreachable through the provider API entirely; reining those + // in would take OS-level lifecycle tracking, not a recovery-time call. + const recoverConfirmedRunningSteps = Effect.gen(function* () { + const rows = yield* wrapSql(sql<{ + readonly stepRunId: StepRunId; + readonly ticketId: TicketId; + }>` + SELECT + step.step_run_id AS "stepRunId", + step.ticket_id AS "ticketId" + FROM projection_step_run AS step + WHERE step.status = 'running' + AND EXISTS ( + SELECT 1 + FROM workflow_dispatch_outbox AS outbox + WHERE outbox.step_run_id = step.step_run_id + ) + AND NOT EXISTS ( + SELECT 1 + FROM workflow_dispatch_outbox AS outbox + WHERE outbox.step_run_id = step.step_run_id + AND outbox.status != 'confirmed' + ) + `); + for (const row of rows) { + const events = yield* ticketEvents(row.ticketId); + const terminal = latestTerminalStepEvent(events, row.stepRunId); + yield* engine.completeRecoveredStep( + row.stepRunId, + terminal !== null + ? recoveredResultFromTerminalEvent(terminal) + : { _tag: "failed", error: STEP_RESTART_ERROR }, + ); + } + }); + + 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 && !(yield* isPanelStep(stepRunId))) { + rows.push(group[0]); + continue; + } + yield* settleInterruptedPanel(stepRunId); + } + + 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* settleInterruptedPanelDispatches; + yield* recoverTerminalDispatches; + yield* recoverRunningScriptRuns; + yield* recoverRunningMergeSteps; + yield* recoverRunningPullRequestSteps; + // Must run before recoverPending: tombstoneStaleDispatches also + // confirms rows, and those superseded steps are not this sweep's + // target (completeRecoveredStep's token guard handles them anyway). + yield* recoverConfirmedRunningSteps; + 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..ba6fddccdfa --- /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(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + 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..481c188cc19 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.test.ts @@ -0,0 +1,4973 @@ +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 { WorkSourceConnectionStoreShape } from "../Services/WorkSourceConnectionStore.ts"; +import { WorkSourceAuthError } from "../Services/WorkSourceProvider.ts"; +import type { + WorkflowBoardVersionRecordInput, + WorkflowBoardVersionSource, + WorkflowBoardVersionStoreShape, +} from "../Services/WorkflowBoardVersionStore.ts"; +import { WorkflowBoardVersionStore } from "../Services/WorkflowBoardVersionStore.ts"; +import type { WorkflowReadModelShape } from "../Services/WorkflowReadModel.ts"; +import { + encodeWorkflowDefinitionJson, + lintWorkflowDefinition, + type LintError, +} from "../workflowFile.ts"; + +const noopProjectScriptTrust = { + isTrusted: () => Effect.succeed(false), + setTrusted: () => Effect.void, +} satisfies ProjectScriptTrustShape; + +const noopConnectionStore = { + getToken: (connectionRef: string, _expectedProvider) => + Effect.fail(new WorkSourceAuthError({ connectionRef })), + create: () => Effect.die("noopConnectionStore.create not implemented"), + list: () => Effect.succeed([]), + remove: () => Effect.void, +} satisfies WorkSourceConnectionStoreShape; + +const noopVersionStore = { + record: () => Effect.void, + list: () => Effect.succeed([]), + get: () => Effect.succeed(null), + deleteForBoard: () => Effect.void, +} satisfies WorkflowBoardVersionStoreShape; + +const noopReadModel = { + 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([]), + listTicketDiscussion: () => Effect.succeed([]), + listTicketRouteDecisions: () => Effect.succeed([]), + listReleasableDependents: () => Effect.succeed([]), + listDependentTicketIds: () => Effect.succeed([]), + getBoardDigest: () => + Effect.succeed({ + windowHours: 24, + createdCount: 0, + shippedCount: 0, + totalTokens: 0, + totalDurationMs: 0, + needsAttention: [], + }), + listNeedsAttentionTickets: () => Effect.succeed([]), + countLanePipelineRuns: () => Effect.succeed(1), + listStepRunsForPipeline: () => Effect.succeed([]), + getTicketPrState: () => Effect.succeed(null), +} satisfies WorkflowReadModelShape; + +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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + 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", + }, + ]), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: (boardId) => Effect.succeed(rows.get(boardId as string) ?? null), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: (inputBoardId) => + Effect.succeed( + inputBoardId === boardId + ? { + boardId, + projectId, + name: "Delete Me", + workflowFilePath: ".t3/boards/delete-me.json", + workflowVersionHash: "hash-delete-me", + maxConcurrentTickets: 3, + } + : null, + ), + deleteBoard: (inputBoardId) => + Effect.sync(() => { + operations.push("delete-projection"); + readModelDeleted.push(inputBoardId); + }), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + 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"); + }), + deleteBoard: (inputBoardId) => + Effect.sync(() => { + assert.equal(inputBoardId, boardId); + operations.push("delete-board"); + }), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + 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; + }), + 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, + ), + ), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId: "project-rpc", + name: "Unsafe Delete", + workflowFilePath: ".t3/boards/../escape.json", + workflowVersionHash: "hash-unsafe-delete", + maxConcurrentTickets: 3, + }), + deleteBoard: () => + Effect.sync(() => { + sideEffects.push("delete-projection"); + }), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + 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), + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: noopReadModel, + 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 }); + }), + }, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: noopReadModel, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: () => Effect.succeed(boardRow), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: () => Effect.succeed(boardRow), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: () => Effect.succeed(boardRow), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: () => + Effect.sync(() => { + sideEffects.push("get-board"); + return 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.sync(() => { + sideEffects.push("lint"); + return []; + }), + loadAndRegister: () => + Effect.sync(() => { + sideEffects.push("load"); + return boardId; + }), + }, + projectScriptTrust: noopProjectScriptTrust, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId, + name: "Delivery", + workflowFilePath, + workflowVersionHash: sha256Hex(fileContents), + maxConcurrentTickets: 3, + }), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: noopReadModel, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: () => Effect.succeed(boardProjectionPresent ? boardRow : null), + deleteBoard: () => + Effect.sync(() => { + boardProjectionPresent = false; + }), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: (inputBoardId) => + Effect.succeed( + inputBoardId === boardId + ? { + boardId, + projectId, + name: "Imported Delivery", + workflowFilePath, + workflowVersionHash: importedHash, + maxConcurrentTickets: 3, + } + : null, + ), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId, + name: importedDefinition.name, + workflowFilePath, + workflowVersionHash: importedHash, + maxConcurrentTickets: 3, + }), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: (inputBoardId) => Effect.succeed(inputBoardId === boardId ? boardRow : null), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId, + name: importedDefinition.name, + workflowFilePath, + workflowVersionHash: importedHash, + maxConcurrentTickets: 3, + }), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: (inputBoardId) => Effect.succeed(inputBoardId === boardId ? boardRow : null), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId: "project-editor-rpc", + name: "Invalid", + workflowFilePath: ".t3/boards/invalid.json", + workflowVersionHash: currentHash, + maxConcurrentTickets: 3, + }), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId: "project-editor-rpc", + name: "Stale", + workflowFilePath: ".t3/boards/stale.json", + workflowVersionHash: currentHash, + maxConcurrentTickets: 3, + }), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId, + name: "External Edit", + workflowFilePath, + workflowVersionHash: originalHash, + maxConcurrentTickets: 3, + }), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: () => Effect.succeed(boardRow), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: (inputBoardId) => Effect.succeed(inputBoardId === boardId ? boardRow : null), + deleteBoard: (inputBoardId) => + Effect.sync(() => { + if (inputBoardId === boardId) { + boardRow = null; + } + }), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId: "project-editor-rpc", + name: "Unsafe Instruction", + workflowFilePath: ".t3/boards/unsafe-instruction.json", + workflowVersionHash: currentHash, + maxConcurrentTickets: 3, + }), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: () => + Effect.succeed({ + boardId, + projectId: "project-editor-rpc", + name: "Unsafe", + workflowFilePath: ".t3/boards/../unsafe.json", + workflowVersionHash: "hash-before", + maxConcurrentTickets: 3, + }), + }, + 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, + connectionStore: noopConnectionStore, + 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, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getBoard: () => Effect.succeed(boardRow), + }, + 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, + connectionStore: noopConnectionStore, + 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); + }), +); + +it.effect( + "workflowRpcHandlers listNeedsAttentionTickets returns real query rows (not the placeholder [])", + () => + Effect.gen(function* () { + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + listNeedsAttentionTickets: () => + Effect.succeed([ + { + ticketId: "ticket-attention-1", + boardId: "board-attention-1", + boardName: "Delivery Board", + title: "Deploy hotfix", + status: "waiting_on_user", + currentLaneKey: "review", + attentionKind: "waiting_for_input" as const, + attentionReason: "Please confirm the deploy target", + updatedAt: "2026-06-13T10:00:00.000Z", + }, + // A second ticket with status "running" — should NOT appear because the + // read model filters; we verify the handler passes through exactly what + // the read model returns (the model already filters), so we give it only + // the attention row. + ]), + }, + 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, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const rows = yield* invokeWorkflowHandler< + ReadonlyArray<{ + readonly ticketId: string; + readonly boardName: string; + readonly attentionKind: string | null; + readonly attentionReason: string | null; + }> + >(handlers, WORKFLOW_WS_METHODS.listNeedsAttentionTickets, {}); + + assert.equal(rows.length, 1, "should return the one attention row, not an empty placeholder"); + assert.equal(rows[0]?.ticketId, "ticket-attention-1"); + assert.equal(rows[0]?.boardName, "Delivery Board"); + assert.equal(rows[0]?.attentionKind, "waiting_for_input"); + assert.equal(rows[0]?.attentionReason, "Please confirm the deploy target"); + }), +); + +it.effect( + "workflowRpcHandlers getTicketDetail surfaces attentionKind, attentionReason, and currentLane.actions", + () => + Effect.gen(function* () { + const handlers = workflowRpcHandlers({ + engine: { + createTicket: () => Effect.die("unused"), + editTicket: () => Effect.void, + moveTicket: () => Effect.void, + createTicketAndEnterUnlocked: () => Effect.die("unused"), + closeTicketFromSourceUnlocked: () => Effect.die("unused"), + cancellableProviderTurnsForTicket: () => Effect.die("unused"), + supersedeProviderWorkForTicket: () => Effect.die("unused"), + editTicketFieldsUnlocked: () => Effect.die("unused"), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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: { + ...noopReadModel, + getTicketDetail: () => + Effect.succeed({ + ticket: { + ticketId: "ticket-detail-attention", + boardId: "board-detail-1", + title: "Review PR", + description: null, + currentLaneKey: "review", + currentLaneEntryToken: null, + queuedAt: null, + totalTokens: null, + totalDurationMs: null, + status: "waiting_on_user", + attentionKind: "waiting_for_input", + attentionReason: "Awaiting human review", + currentLane: { + key: "review", + name: "Review", + actions: [{ label: "Approve", to: "done", hint: "Looks good" }], + }, + }, + steps: [], + messages: [], + } as never), + 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, + connectionStore: noopConnectionStore, + versionStore: noopVersionStore, + observeRpcEffect: (_method, effect) => effect, + observeRpcStreamEffect: (_method, effect) => Stream.unwrap(effect), + }); + + const detail = yield* handlers[WORKFLOW_WS_METHODS.getTicketDetail]({ + ticketId: TicketId.make("ticket-detail-attention"), + }); + + assert.equal( + detail.ticket.attentionKind, + "waiting_for_input", + "attentionKind must pass through from read-model row", + ); + assert.equal( + detail.ticket.attentionReason, + "Awaiting human review", + "attentionReason must pass through from read-model row", + ); + assert.isDefined(detail.ticket.currentLane, "currentLane must be present in detail view"); + assert.equal(detail.ticket.currentLane?.key, "review"); + assert.equal(detail.ticket.currentLane?.name, "Review"); + assert.equal(detail.ticket.currentLane?.actions.length, 1); + assert.equal(detail.ticket.currentLane?.actions[0]?.label, "Approve"); + assert.equal(detail.ticket.currentLane?.actions[0]?.to, "done"); + assert.equal(detail.ticket.currentLane?.actions[0]?.hint, "Looks good"); + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts new file mode 100644 index 00000000000..09fa7b8bbc0 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRpcHandlers.ts @@ -0,0 +1,1384 @@ +import type { + AgentSelection, + BoardListEntry, + BoardSnapshot, + BoardTicketView, + WorkflowIntakeResult, + EnvironmentAuthorizationError, + ProjectId, + StepRunId, + StepRunStatus, + TicketAttachment, + TicketId, + TicketStatus, + WorkflowBoardVersionSummary, + WorkflowCreateBoardInput as WorkflowCreateBoardInputType, + WorkflowGetBoardDefinitionResult, + WorkflowGetBoardVersionResult, + WorkflowLintError, + WorkflowNeedsAttentionTicketView, + WorkflowRenameBoardInput as WorkflowRenameBoardInputType, + WorkflowSaveBoardDefinitionInput, + WorkflowSaveBoardDefinitionResult, + WorkflowStepRunView, + WorkflowTicketDetailView, + WorkflowDefinition as WorkflowDefinitionType, + WorkflowDefinitionEncoded, + WorkflowDryRunScenario, +} from "@t3tools/contracts"; +import type { WorkSourceConnectionView } from "@t3tools/contracts/workSource"; +import type { WorkSourceProviderName } from "@t3tools/contracts/workSource"; +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 type { WorkSourceConnectionStoreShape } from "../Services/WorkSourceConnectionStore.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 connectionStore: WorkSourceConnectionStoreShape; + 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 } + : {}), + ...(ticket.pr === undefined ? {} : { pr: ticket.pr }), + // Attention fields — present when the ticket is in a needs-attention state. + ...(ticket.attentionKind == null ? {} : { attentionKind: ticket.attentionKind as never }), + ...(ticket.attentionReason == null ? {} : { attentionReason: ticket.attentionReason }), + // Current lane detail — present on detail reads (resolved from board definition). + ...(ticket.currentLane === undefined + ? {} + : { + currentLane: { + key: ticket.currentLane.key as LaneKey, + name: ticket.currentLane.name, + actions: ticket.currentLane.actions.map((a) => ({ + label: a.label, + to: a.to as LaneKey, + ...(a.hint === undefined ? {} : { hint: a.hint }), + })), + }, + }), +}); + +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, + })), + ...(detail.syncedSource !== undefined ? { syncedSource: detail.syncedSource } : {}), + } 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.listNeedsAttentionTickets]: (_input: Record) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.listNeedsAttentionTickets, + Effect.gen(function* () { + const rows = yield* deps.readModel + .listNeedsAttentionTickets() + .pipe(Effect.mapError(toWorkflowRpcError("Failed to list needs-attention tickets"))); + return rows.map( + (row): WorkflowNeedsAttentionTicketView => ({ + ticketId: row.ticketId as never, + boardId: row.boardId as never, + boardName: row.boardName, + title: row.title, + status: row.status as never, + currentLaneKey: row.currentLaneKey as never, + attentionKind: row.attentionKind as never, + attentionReason: row.attentionReason, + updatedAt: row.updatedAt, + }), + ); + }), + { "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" }, + ), + + [WORKFLOW_WS_METHODS.listWorkSourceConnections]: (_input: Record) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.listWorkSourceConnections, + Effect.gen(function* () { + return yield* deps.connectionStore + .list() + .pipe( + Effect.mapError(toWorkflowRpcError("Failed to list work-source connections")), + ); + }), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.createWorkSourceConnection]: (input: { + readonly provider: WorkSourceProviderName; + readonly displayName: string; + readonly token: string; + }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.createWorkSourceConnection, + Effect.gen(function* () { + const view = yield* deps.connectionStore + .create(input) + .pipe( + Effect.mapError(toWorkflowRpcError("Failed to create work-source connection")), + ); + return view satisfies WorkSourceConnectionView; + }), + { "rpc.aggregate": "workflow" }, + ), + + [WORKFLOW_WS_METHODS.deleteWorkSourceConnection]: (input: { readonly connectionRef: string }) => + deps.observeRpcEffect( + WORKFLOW_WS_METHODS.deleteWorkSourceConnection, + Effect.gen(function* () { + yield* deps.connectionStore + .remove(input.connectionRef) + .pipe( + Effect.mapError(toWorkflowRpcError("Failed to delete work-source connection")), + ); + }), + { "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..fabf15277e8 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRuntime.integration.test.ts @@ -0,0 +1,1102 @@ +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 { TicketPullRequestService } from "../Services/TicketPullRequestService.ts"; +import { GitHubCli } from "../../sourceControl/GitHubCli.ts"; +import { SourceControlProviderRegistry } from "../../sourceControl/SourceControlProviderRegistry.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, + getSnapshot: () => Effect.succeed(null), + 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({ threadId: "workflow-setup:stub", 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(TicketPullRequestService, { + open: () => Effect.succeed({ _tag: "completed" }), + land: () => Effect.succeed({ _tag: "completed" }), + }), + ), + // The real GitHubPortLive is wired into the executor; these tests never run + // PR steps, so stub its source-control deps to keep the layer self-contained. + Layer.provideMerge(Layer.succeed(GitHubCli, {} as never)), + Layer.provideMerge(Layer.succeed(SourceControlProviderRegistry, {} as never)), + 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..670fa2540c0 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowRuntime.realpath.test.ts @@ -0,0 +1,1468 @@ +// @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 { GitHubCli } from "../../sourceControl/GitHubCli.ts"; +import { SourceControlProviderRegistry } from "../../sourceControl/SourceControlProviderRegistry.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 { TicketPullRequestService } from "../Services/TicketPullRequestService.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 PullRequestStubLive = Layer.mergeAll( + Layer.succeed(TicketPullRequestService, { + open: () => Effect.succeed({ _tag: "completed" }), + land: () => Effect.succeed({ _tag: "completed" }), + }), + Layer.succeed(GitHubCli, {} as never), + Layer.succeed(SourceControlProviderRegistry, {} as never), +); +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, + getSnapshot: () => Effect.succeed(null), + 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({ threadId: "workflow-setup:stub", terminalId: null }), + awaitExit: () => Effect.succeed({ exitCode: 0 }), + }), + ), + Layer.provideMerge(DeterministicWorkflowIds), + // The real GitHubPortLive is wired into the executor; these tests never run + // PR steps, so stub the PR service and its source-control deps to keep the + // layer self-contained. + Layer.provideMerge(PullRequestStubLive), + 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/WorkflowSourceCommitter.test.ts b/apps/server/src/workflow/Layers/WorkflowSourceCommitter.test.ts new file mode 100644 index 00000000000..a313949c112 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowSourceCommitter.test.ts @@ -0,0 +1,759 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import type { BoardTicketView } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +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 { + ProviderService, + type ProviderServiceShape, +} from "../../provider/Services/ProviderService.ts"; +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { ScriptCancelRegistry } from "../Services/ScriptCancelRegistry.ts"; +import { StepExecutor, type StepExecutorShape } from "../Services/StepExecutor.ts"; +import { WorkflowBoardEvents } from "../Services/WorkflowBoardEvents.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventStore } from "../Services/WorkflowEventStore.ts"; +import { WorkflowReadModel } from "../Services/WorkflowReadModel.ts"; +import { + WorkflowSourceCommitter, + type ReconcileLanes, + type SourceDelta, + type SourceItemFields, +} from "../Services/WorkflowSourceCommitter.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"; +import { WorkflowSourceCommitterLive } from "./WorkflowSourceCommitter.ts"; + +// A step that blocks forever so a ticket admitted into an auto lane keeps a +// running pipeline (lets us prove the post-tx pipeline-start path runs). +const blockingExecutor = Layer.succeed(StepExecutor, { + execute: () => Effect.never, +} satisfies StepExecutorShape); + +const layer = it.layer( + WorkflowSourceCommitterLive.pipe( + Layer.provideMerge(WorkflowEngineLayer), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(blockingExecutor), + 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), + ), +); + +// inbox is WIP-1 (a second create queues), work is auto (admitted tickets start +// a blocking pipeline), done is the terminal lane closes route into. +const definition = { + name: "work source committer", + lanes: [ + { key: "inbox", name: "Inbox", entry: "manual", wipLimit: 1 }, + { + key: "work", + name: "Work", + entry: "auto", + pipeline: [ + { + key: "code", + type: "agent", + agent: { instance: "claude_main", model: "sonnet" }, + instruction: "do it", + }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], +}; + +const lanes: ReconcileLanes = { + destinationLane: "inbox" as never, + closedLane: "done" as never, +}; + +const item = (over: Partial = {}): SourceItemFields => ({ + sourceId: "src-1", + provider: "github_issues", + externalId: "issue-1", + title: "Upstream issue", + description: "body", + contentHash: "hash-v1", + providerVersion: "v1", + metadata: { provider: "github_issues", url: "https://example/1", labels: ["bug"] }, + ...over, +}); + +interface MappingRow { + readonly ticketId: string; + readonly contentHash: string; + readonly providerVersion: string | null; + readonly lifecycle: string; + readonly syncStatus: string; +} + +const readMapping = (boardId: string, ext: SourceItemFields) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql` + SELECT ticket_id AS "ticketId", content_hash AS "contentHash", + provider_version AS "providerVersion", lifecycle AS "lifecycle", + sync_status AS "syncStatus" + FROM work_source_mapping + WHERE board_id = ${boardId} AND source_id = ${ext.sourceId} + AND provider = ${ext.provider} AND external_id = ${ext.externalId} + LIMIT 1 + `; + return rows[0] ?? null; + }); + +const countMappings = (boardId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM work_source_mapping WHERE board_id = ${boardId} + `; + return rows[0]?.count ?? 0; + }); + +layer("WorkflowSourceCommitter.reconcileChunk", (it) => { + it.effect("create: a new delta creates a ticket + mapping row in the same tx", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-create" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const read = yield* WorkflowReadModel; + + const ext = item(); + yield* committer.reconcileChunk("b-create" as never, lanes, [{ _tag: "new", item: ext }]); + + const mapping = yield* readMapping("b-create", ext); + assert.isNotNull(mapping); + assert.equal(mapping?.contentHash, "hash-v1"); + assert.equal(mapping?.lifecycle, "open"); + assert.equal(mapping?.syncStatus, "active"); + + const detail = yield* read.getTicketDetail(mapping?.ticketId as never); + assert.equal(detail?.ticket.currentLaneKey, "inbox"); + assert.equal(detail?.ticket.title, "Upstream issue"); + assert.equal(detail?.ticket.description, "body"); + }), + ); + + it.effect("idempotent create: the same new delta twice yields exactly one ticket + mapping", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-idem" as never, definition); + const committer = yield* WorkflowSourceCommitter; + + const ext = item(); + yield* committer.reconcileChunk("b-idem" as never, lanes, [{ _tag: "new", item: ext }]); + // Re-run with the SAME new delta (simulating a stale out-of-lock diff). + yield* committer.reconcileChunk("b-idem" as never, lanes, [{ _tag: "new", item: ext }]); + + assert.equal(yield* countMappings("b-idem"), 1); + + const sql = yield* SqlClient.SqlClient; + const tickets = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_ticket WHERE board_id = 'b-idem' + `; + assert.equal(tickets[0]?.count ?? 0, 1); + }), + ); + + it.effect("change: differing content_hash edits the ticket + bumps the mapping; same hash is a no-op", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-change" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const read = yield* WorkflowReadModel; + const store = yield* WorkflowEventStore; + + const ext = item(); + yield* committer.reconcileChunk("b-change" as never, lanes, [{ _tag: "new", item: ext }]); + const created = yield* readMapping("b-change", ext); + const ticketId = created?.ticketId as string; + + // Same hash → no write. + yield* committer.reconcileChunk("b-change" as never, lanes, [ + { _tag: "changed", item: ext, ticketId }, + ]); + let events = yield* Stream.runCollect(store.readByTicket(ticketId as never)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isUndefined(events.find((event) => event.type === "TicketEdited")); + + // Differing hash → edit + mapping bump. + const changed = item({ + title: "Renamed issue", + description: "new body", + contentHash: "hash-v2", + providerVersion: "v2", + }); + yield* committer.reconcileChunk("b-change" as never, lanes, [ + { _tag: "changed", item: changed, ticketId }, + ]); + + const detail = yield* read.getTicketDetail(ticketId as never); + assert.equal(detail?.ticket.title, "Renamed issue"); + assert.equal(detail?.ticket.description, "new body"); + + const mapping = yield* readMapping("b-change", ext); + assert.equal(mapping?.contentHash, "hash-v2"); + assert.equal(mapping?.providerVersion, "v2"); + + events = yield* Stream.runCollect(store.readByTicket(ticketId as never)).pipe( + Effect.map((chunk) => Array.from(chunk)), + ); + assert.isDefined(events.find((event) => event.type === "TicketEdited")); + }), + ); + + it.effect("close: a closed delta routes to closedLane with source work_source + mapping lifecycle closed", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-close" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const read = yield* WorkflowReadModel; + + const ext = item(); + yield* committer.reconcileChunk("b-close" as never, lanes, [{ _tag: "new", item: ext }]); + const created = yield* readMapping("b-close", ext); + const ticketId = created?.ticketId as string; + + yield* committer.reconcileChunk("b-close" as never, lanes, [ + { _tag: "closed", item: ext, ticketId }, + ]); + + const detail = yield* read.getTicketDetail(ticketId as never); + assert.equal(detail?.ticket.currentLaneKey, "done"); + + const mapping = yield* readMapping("b-close", ext); + assert.equal(mapping?.lifecycle, "closed"); + + const decisions = yield* read.listTicketRouteDecisions(ticketId as never); + assert.isDefined(decisions.find((row) => row.source === "work_source")); + }), + ); + + it.effect("orphan: a missing delta marks sync_status orphaned; confirmedDeleted also closes", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-orphan" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const read = yield* WorkflowReadModel; + + // Orphan-only path. + const a = item({ externalId: "issue-a" }); + yield* committer.reconcileChunk("b-orphan" as never, lanes, [{ _tag: "new", item: a }]); + const mappedA = yield* readMapping("b-orphan", a); + yield* committer.reconcileChunk("b-orphan" as never, lanes, [ + { _tag: "missing", item: a, ticketId: mappedA?.ticketId as string }, + ]); + const orphanA = yield* readMapping("b-orphan", a); + assert.equal(orphanA?.syncStatus, "orphaned"); + assert.equal(orphanA?.lifecycle, "open"); + const detailA = yield* read.getTicketDetail(mappedA?.ticketId as never); + assert.equal(detailA?.ticket.currentLaneKey, "inbox"); + + // confirmedDeleted path → also terminal route + lifecycle closed. + const b = item({ externalId: "issue-b" }); + yield* committer.reconcileChunk("b-orphan" as never, lanes, [{ _tag: "new", item: b }]); + const mappedB = yield* readMapping("b-orphan", b); + yield* committer.reconcileChunk("b-orphan" as never, lanes, [ + { _tag: "missing", item: b, ticketId: mappedB?.ticketId as string, confirmedDeleted: true }, + ]); + const orphanB = yield* readMapping("b-orphan", b); + assert.equal(orphanB?.syncStatus, "orphaned"); + assert.equal(orphanB?.lifecycle, "closed"); + const detailB = yield* read.getTicketDetail(mappedB?.ticketId as never); + assert.equal(detailB?.ticket.currentLaneKey, "done"); + }), + ); + + it.effect("WIP serialization: a second create into a WIP-1 lane already at capacity queues", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-wip" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const read = yield* WorkflowReadModel; + + const occupant = item({ externalId: "wip-1", title: "Occupant" }); + const queued = item({ externalId: "wip-2", title: "Queued" }); + // Both into the WIP-1 inbox lane via the committer (admission lock path). + yield* committer.reconcileChunk("b-wip" as never, lanes, [ + { _tag: "new", item: occupant }, + { _tag: "new", item: queued }, + ]); + + const admitted = yield* read.countAdmittedInLane("b-wip" as never, "inbox" as never); + assert.equal(admitted, 1); + + const queuedMapping = yield* readMapping("b-wip", queued); + const queuedDetail = yield* read.getTicketDetail(queuedMapping?.ticketId as never); + assert.equal(queuedDetail?.ticket.currentLaneKey, "inbox"); + assert.equal(queuedDetail?.ticket.queuedAt !== null, true); + }), + ); + + it.effect("post-tx pipeline start: a create into an auto lane starts the pipeline after the chunk", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-pipeline" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const sql = yield* SqlClient.SqlClient; + + // Destination = the auto `work` lane: createTicketAndEnterUnlocked drops the + // pipeline start inside the chunk tx; recoverBoardWip (post-tx) starts it. + const autoLanes: ReconcileLanes = { destinationLane: "work" as never, closedLane: "done" as never }; + const ext = item({ externalId: "auto-1" }); + yield* committer.reconcileChunk("b-pipeline" as never, autoLanes, [ + { _tag: "new", item: ext }, + ]); + + const mapping = yield* readMapping("b-pipeline", ext); + const runs = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_pipeline_run + WHERE ticket_id = ${mapping?.ticketId as string} + `; + assert.isAbove(runs[0]?.count ?? 0, 0); + }), + ); + + // Fix 4: a chunk whose destinationLane/closedLane does not exist on the + // CURRENT board definition fails with a typed WorkflowEventStoreError and + // creates/moves nothing. + it.effect("validate lanes: a missing destination lane fails the chunk; nothing is created", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-badlane" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const sql = yield* SqlClient.SqlClient; + + const badLanes: ReconcileLanes = { + destinationLane: "ghost" as never, + closedLane: "done" as never, + }; + const ext = item({ externalId: "bad-1" }); + const exit = yield* committer + .reconcileChunk("b-badlane" as never, badLanes, [{ _tag: "new", item: ext }]) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(exit)); + + assert.equal(yield* countMappings("b-badlane"), 0); + const tickets = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_ticket WHERE board_id = 'b-badlane' + `; + assert.equal(tickets[0]?.count ?? 0, 0); + }), + ); + + it.effect("validate lanes: a missing closed lane fails the chunk; nothing is created", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-badclosed" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const sql = yield* SqlClient.SqlClient; + + const badLanes: ReconcileLanes = { + destinationLane: "inbox" as never, + closedLane: "ghost" as never, + }; + const ext = item({ externalId: "bad-2" }); + const exit = yield* committer + .reconcileChunk("b-badclosed" as never, badLanes, [{ _tag: "new", item: ext }]) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(exit)); + + assert.equal(yield* countMappings("b-badclosed"), 0); + const tickets = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_ticket WHERE board_id = 'b-badclosed' + `; + assert.equal(tickets[0]?.count ?? 0, 0); + }), + ); +}); + +// --------------------------------------------------------------------------- +// Fixes 2 (post-tx provider cancel), 3 (no orphan on UNIQUE), 6 (post-tx +// publish). Each uses a fresh layer so DeterministicWorkflowIds counters start +// clean (Fix 3 predicts the first ticket id) and a recording ProviderService / +// WorkflowBoardEvents captures the post-tx side effects. +// --------------------------------------------------------------------------- + +interface ProviderCall { + readonly kind: "interrupt" | "stop"; + readonly threadId: string; +} + +// Decorates the real engine so recoverBoardWip FAILS post-commit — proves the +// committer's publish + provider-cancel still run and reconcileChunk does not +// fail. Requires the real engine under the same tag (provided via Layer.provide) +// and re-publishes it with only recoverBoardWip overridden to fail. +const failingRecoverEngineLayer = Layer.effect( + WorkflowEngine, + Effect.gen(function* () { + const base = yield* WorkflowEngine; + return { + ...base, + recoverBoardWip: () => + new WorkflowEventStoreError({ message: "boom: recoverBoardWip failed" }), + } satisfies typeof base; + }), +).pipe(Layer.provide(WorkflowEngineLayer)); + +const makeCommitterLayer = ( + providerCalls: Ref.Ref>, + published: Ref.Ref>, + engineLayer: typeof WorkflowEngineLayer = WorkflowEngineLayer, +) => + WorkflowSourceCommitterLive.pipe( + Layer.provideMerge(engineLayer), + Layer.provideMerge(WorkflowEventCommitterLive), + Layer.provideMerge( + Layer.succeed(ScriptCancelRegistry, { + register: () => Effect.void, + unregister: () => Effect.void, + cancel: () => Effect.void, + }), + ), + Layer.provideMerge(blockingExecutor), + Layer.provideMerge( + Layer.succeed(ProviderService, { + startSession: () => Effect.die("unused"), + sendTurn: () => Effect.die("unused"), + interruptTurn: (input) => + Ref.update(providerCalls, (calls) => [ + ...calls, + { kind: "interrupt" as const, threadId: input.threadId as string }, + ]), + respondToRequest: () => Effect.die("unused"), + respondToUserInput: () => Effect.die("unused"), + stopSession: (input) => + Ref.update(providerCalls, (calls) => [ + ...calls, + { kind: "stop" as const, threadId: input.threadId as string }, + ]), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.die("unused"), + getInstanceInfo: () => Effect.die("unused"), + rollbackConversation: () => Effect.die("unused"), + streamEvents: Stream.empty, + } satisfies ProviderServiceShape), + ), + Layer.provideMerge( + Layer.succeed(WorkflowBoardEvents, { + publish: (ticket: BoardTicketView) => + Ref.update(published, (ids) => [...ids, ticket.ticketId as string]), + stream: () => Stream.empty, + }), + ), + 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), + ); + +it.effect("Fix 2: a closed delta with running provider work cancels the session POST-commit", () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make>([]); + const published = yield* Ref.make>([]); + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-cancel" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const sql = yield* SqlClient.SqlClient; + + const ext = item({ externalId: "cancel-1" }); + yield* committer.reconcileChunk("b-cancel" as never, lanes, [{ _tag: "new", item: ext }]); + const created = yield* readMapping("b-cancel", ext); + const ticketId = created?.ticketId as string; + + // Seed an in-flight provider dispatch row for the ticket. The in-tx close + // tombstones it (DB), and the committer cancels the provider session + // POST-tx using the snapshot captured before the tombstone. + 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-cancel-1', ${ticketId}, 'step-cancel-1', 'thread-cancel-1', + 'turn-cancel-1', 'codex', 'gpt-5.5', 'cancel me', '/tmp/wt', 'started', + '2026-06-13T00:00:00.000Z', '2026-06-13T00:00:00.000Z' + ) + `; + + yield* committer.reconcileChunk("b-cancel" as never, lanes, [ + { _tag: "closed", item: ext, ticketId }, + ]); + + // The provider session was interrupted + stopped (post-commit). + const calls = yield* Ref.get(providerCalls); + assert.deepEqual(calls, [ + { kind: "interrupt", threadId: "thread-cancel-1" }, + { kind: "stop", threadId: "thread-cancel-1" }, + ]); + + // The dispatch row was tombstoned in-tx (no live pending/started rows). + const live = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_dispatch_outbox + WHERE ticket_id = ${ticketId} AND status IN ('pending', 'started') + `; + assert.equal(live[0]?.count ?? 0, 0); + + const detail = yield* (yield* WorkflowReadModel).getTicketDetail(ticketId as never); + assert.equal(detail?.ticket.currentLaneKey, "done"); + }).pipe(Effect.provide(makeCommitterLayer(providerCalls, published))); + }), +); + +it.effect("Fix 2: a later failing delta rolls back the close WITHOUT cancelling the provider mid-tx", () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make>([]); + const published = yield* Ref.make>([]); + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-rollback" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const sql = yield* SqlClient.SqlClient; + const read = yield* WorkflowReadModel; + + const closing = item({ externalId: "rollback-close" }); + yield* committer.reconcileChunk("b-rollback" as never, lanes, [ + { _tag: "new", item: closing }, + ]); + const created = yield* readMapping("b-rollback", closing); + const ticketId = created?.ticketId as string; + + 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-rollback-1', ${ticketId}, 'step-rollback-1', 'thread-rollback-1', + 'turn-rollback-1', 'codex', 'gpt-5.5', 'cancel me', '/tmp/wt', 'started', + '2026-06-13T00:00:00.000Z', '2026-06-13T00:00:00.000Z' + ) + `; + + // A LATER `new` delta whose ticket_id collides: pre-insert a mapping row + // whose ticket_id equals the id the next created ticket WILL receive, so + // its mapping INSERT hits the UNIQUE(ticket_id) index and fails the tx. + // The re-read (by external key) misses, so the create proceeds to the + // failing insert. This forces a chunk rollback AFTER the close applied + // in-tx. + const failing = item({ externalId: "rollback-new" }); + const nextTicketId = yield* sql<{ readonly value: string }>` + SELECT 'ticket-' || ( + COALESCE(MAX(CAST(SUBSTR(ticket_id, 8) AS INTEGER)), 0) + 1 + ) AS value + FROM projection_ticket WHERE ticket_id LIKE 'ticket-%' + `.pipe(Effect.map((rows) => rows[0]?.value as string)); + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + content_hash, lifecycle, sync_status, source_metadata_json, + created_at, last_synced_at + ) VALUES ( + 'mapping-collide', 'b-rollback', 'other-src', 'other-prov', 'other-ext', + ${nextTicketId}, 'h', 'open', 'active', '{}', + '2026-06-13T00:00:00.000Z', '2026-06-13T00:00:00.000Z' + ) + `; + + const exit = yield* committer + .reconcileChunk("b-rollback" as never, lanes, [ + { _tag: "closed", item: closing, ticketId }, + { _tag: "new", item: failing }, + ]) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(exit)); + + // The close's DB change rolled back: the ticket is NOT in `done`. + const detail = yield* read.getTicketDetail(ticketId as never); + assert.equal(detail?.ticket.currentLaneKey, "inbox"); + // The dispatch tombstone rolled back too. + const live = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_dispatch_outbox + WHERE ticket_id = ${ticketId} AND status IN ('pending', 'started') + `; + assert.equal(live[0]?.count ?? 0, 1); + // Critically: the provider session was NOT cancelled, because the + // cancellation only runs post-tx and the tx rolled back. + assert.deepEqual(yield* Ref.get(providerCalls), []); + }).pipe(Effect.provide(makeCommitterLayer(providerCalls, published))); + }), +); + +it.effect("Fix 3: a UNIQUE violation on the mapping insert rolls back the chunk; no orphan ticket", () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make>([]); + const published = yield* Ref.make>([]); + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-orphan-guard" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const sql = yield* SqlClient.SqlClient; + + // Pre-seed a mapping row whose ticket_id is the one the FIRST created + // ticket will get (ticket-1 on this fresh, deterministic layer), under a + // DIFFERENT external key so the in-tx re-read for our delta misses. The + // create then collides on UNIQUE(ticket_id) — the violation must NOT be + // swallowed, rolling back the just-created ticket. + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + content_hash, lifecycle, sync_status, source_metadata_json, + created_at, last_synced_at + ) VALUES ( + 'mapping-pre', 'b-orphan-guard', 'pre-src', 'pre-prov', 'pre-ext', + 'ticket-1', 'h', 'open', 'active', '{}', + '2026-06-13T00:00:00.000Z', '2026-06-13T00:00:00.000Z' + ) + `; + + const ext = item({ externalId: "orphan-guard-1" }); + const exit = yield* committer + .reconcileChunk("b-orphan-guard" as never, lanes, [{ _tag: "new", item: ext }]) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(exit)); + + // No orphan ticket survives: the only mapping is the pre-seeded one, and + // no ticket exists for our delta's external key. + assert.equal(yield* countMappings("b-orphan-guard"), 1); + const tickets = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM projection_ticket WHERE board_id = 'b-orphan-guard' + `; + assert.equal(tickets[0]?.count ?? 0, 0); + // Our delta's mapping was never created. + assert.isNull(yield* readMapping("b-orphan-guard", ext)); + }).pipe(Effect.provide(makeCommitterLayer(providerCalls, published))); + }), +); + +it.effect("Fix 6: created / changed / closed tickets are published to WorkflowBoardEvents post-tx", () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make>([]); + const published = yield* Ref.make>([]); + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-publish" as never, definition); + const committer = yield* WorkflowSourceCommitter; + + // Create. + const ext = item({ externalId: "publish-1" }); + yield* committer.reconcileChunk("b-publish" as never, lanes, [{ _tag: "new", item: ext }]); + const created = yield* readMapping("b-publish", ext); + const ticketId = created?.ticketId as string; + assert.include(yield* Ref.get(published), ticketId); + + // Reset and exercise a change publish. + yield* Ref.set(published, []); + const changed = item({ externalId: "publish-1", contentHash: "hash-v2", title: "Renamed" }); + yield* committer.reconcileChunk("b-publish" as never, lanes, [ + { _tag: "changed", item: changed, ticketId }, + ]); + assert.include(yield* Ref.get(published), ticketId); + + // Reset and exercise a close publish. + yield* Ref.set(published, []); + yield* committer.reconcileChunk("b-publish" as never, lanes, [ + { _tag: "closed", item: ext, ticketId }, + ]); + assert.include(yield* Ref.get(published), ticketId); + }).pipe(Effect.provide(makeCommitterLayer(providerCalls, published))); + }), +); + +it.effect( + "post-commit ordering: a failing recoverBoardWip does NOT suppress publish + provider-cancel for a committed close", + () => + Effect.gen(function* () { + const providerCalls = yield* Ref.make>([]); + const published = yield* Ref.make>([]); + yield* Effect.gen(function* () { + const registry = yield* BoardRegistry; + yield* registry.register("b-recover-fail" as never, definition); + const committer = yield* WorkflowSourceCommitter; + const sql = yield* SqlClient.SqlClient; + const read = yield* WorkflowReadModel; + + const ext = item({ externalId: "recover-fail-1" }); + yield* committer.reconcileChunk("b-recover-fail" as never, lanes, [ + { _tag: "new", item: ext }, + ]); + const created = yield* readMapping("b-recover-fail", ext); + const ticketId = created?.ticketId as string; + + // In-flight provider work on the ticket. + 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-recover-fail-1', ${ticketId}, 'step-recover-fail-1', 'thread-recover-fail-1', + 'turn-recover-fail-1', 'codex', 'gpt-5.5', 'cancel me', '/tmp/wt', 'started', + '2026-06-13T00:00:00.000Z', '2026-06-13T00:00:00.000Z' + ) + `; + + // The close commits; recoverBoardWip then FAILS post-commit. The + // committer must still publish + cancel the provider session, and + // reconcileChunk must NOT fail (recoverBoardWip is backstopped). + yield* Ref.set(published, []); + const exit = yield* committer + .reconcileChunk("b-recover-fail" as never, lanes, [ + { _tag: "closed", item: ext, ticketId }, + ]) + .pipe(Effect.exit); + assert.isTrue(Exit.isSuccess(exit)); + + // Provider cancellation STILL ran (independent of recoverBoardWip). + assert.deepEqual(yield* Ref.get(providerCalls), [ + { kind: "interrupt", threadId: "thread-recover-fail-1" }, + { kind: "stop", threadId: "thread-recover-fail-1" }, + ]); + + // The closed ticket's view was STILL published. + assert.include(yield* Ref.get(published), ticketId); + + // The close itself durably landed (it committed before recovery failed). + const detail = yield* read.getTicketDetail(ticketId as never); + assert.equal(detail?.ticket.currentLaneKey, "done"); + }).pipe( + Effect.provide( + makeCommitterLayer(providerCalls, published, failingRecoverEngineLayer), + ), + ); + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowSourceCommitter.ts b/apps/server/src/workflow/Layers/WorkflowSourceCommitter.ts new file mode 100644 index 00000000000..a635cebc954 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowSourceCommitter.ts @@ -0,0 +1,400 @@ +import type { BoardId, LaneKey, ThreadId, TicketId, TurnId } 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 { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEventStoreError } from "../Services/Errors.ts"; +import { WorkflowBoardSaveLocks } from "../Services/WorkflowBoardSaveLocks.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { WorkflowEventCommitter } from "../Services/WorkflowEventCommitter.ts"; +import { WorkflowIds } from "../Services/WorkflowIds.ts"; +import { + WorkflowSourceCommitter, + type ReconcileLanes, + type SourceDelta, + type SourceItemFields, + type SourceItemMetadata, + type WorkflowSourceCommitterShape, +} from "../Services/WorkflowSourceCommitter.ts"; + +const toCommitterError = (message: string) => (cause: unknown) => + new WorkflowEventStoreError({ message, cause }); + +const wrap = (message: string, effect: Effect.Effect) => + effect.pipe(Effect.mapError(toCommitterError(message))); + +interface MappingRow { + readonly mappingId: string; + readonly ticketId: string; + readonly contentHash: string; + readonly lifecycle: string; + readonly syncStatus: string; +} + +// What a single delta application touched. Drives the POST-TX steps: +// - `publishTicketId`: a created/edited/closed/orphaned ticket whose live view +// must be pushed to WorkflowBoardEvents after the lock/tx releases (the +// unlocked cores append+project but never publish). +// - `republishDependents`: a terminal/closed move republishes dependents too. +// - `cancelTurns`: provider turns SNAPSHOTTED in-tx (before the close tombstoned +// the outbox rows) to interrupt+cancel after the tx commits — no provider IO +// runs inside the transaction. +interface DeltaEffect { + readonly publishTicketId: TicketId | null; + readonly republishDependents: boolean; + readonly cancelTicketId: TicketId | null; + readonly cancelTurns: ReadonlyArray<{ readonly threadId: ThreadId; readonly turnId: TurnId | null }>; +} + +const noEffect: DeltaEffect = { + publishTicketId: null, + republishDependents: false, + cancelTicketId: null, + cancelTurns: [], +}; + +const serializeMetadata = (metadata: SourceItemMetadata): string => + JSON.stringify({ + provider: metadata.provider, + url: metadata.url ?? null, + assignees: metadata.assignees ?? [], + labels: metadata.labels ?? [], + lifecycle: metadata.lifecycle ?? null, + }); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const engine = yield* WorkflowEngine; + const committer = yield* WorkflowEventCommitter; + const registry = yield* BoardRegistry; + const saveLocks = yield* WorkflowBoardSaveLocks; + const ids = yield* WorkflowIds; + + // Re-read the mapping row by the UNIQUE key INSIDE the transaction. The diff + // that produced the delta ran outside the lock, so a concurrent batch may have + // mutated the table since; this revalidation is the authority. + const readMapping = (boardId: BoardId, item: SourceItemFields) => + wrap( + "WorkflowSourceCommitter.readMapping", + sql` + SELECT + mapping_id AS "mappingId", + ticket_id AS "ticketId", + content_hash AS "contentHash", + lifecycle AS "lifecycle", + sync_status AS "syncStatus" + FROM work_source_mapping + WHERE board_id = ${String(boardId)} + AND source_id = ${item.sourceId} + AND provider = ${item.provider} + AND external_id = ${item.externalId} + LIMIT 1 + `, + ).pipe(Effect.map((rows) => rows[0] ?? null)); + + const applyNew = ( + boardId: BoardId, + lanes: ReconcileLanes, + item: SourceItemFields, + ): Effect.Effect => + Effect.gen(function* () { + // In-tx recheck: if a mapping already exists (a stale "new" diff, or a + // racing batch won) do nothing — exactly one ticket/mapping per item. + const existing = yield* readMapping(boardId, item); + if (existing !== null) { + return noEffect; + } + const created = yield* engine.createTicketAndEnterUnlocked({ + boardId, + title: item.title, + ...(item.description === undefined ? {} : { description: item.description }), + destinationLane: lanes.destinationLane, + }); + const mappingId = yield* ids.mappingId(); + const now = DateTime.formatIso(yield* DateTime.now); + // INSERT in the SAME transaction as the ticket create. A UNIQUE violation + // here means a genuine conflict the in-tx re-read above did NOT catch (the + // mapping was committed by a racing writer between the re-read and this + // insert). We do NOT swallow it: letting it propagate ROLLS BACK the whole + // chunk tx (the just-created ticket rolls back with it), so we never commit + // an orphan ticket with no mapping. The next sync cycle's in-tx re-read + // finds the now-committed mapping and skips. + yield* wrap( + "WorkflowSourceCommitter.insertMapping", + sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + provider_version, content_hash, lifecycle, sync_status, + source_metadata_json, created_at, last_synced_at + ) VALUES ( + ${mappingId}, ${String(boardId)}, ${item.sourceId}, ${item.provider}, + ${item.externalId}, ${String(created.ticketId)}, + ${item.providerVersion ?? null}, ${item.contentHash}, 'open', 'active', + ${serializeMetadata(item.metadata)}, ${now}, ${now} + ) + `, + ); + return { ...noEffect, publishTicketId: created.ticketId }; + }); + + const applyChanged = ( + boardId: BoardId, + item: SourceItemFields, + ): Effect.Effect => + Effect.gen(function* () { + const row = yield* readMapping(boardId, item); + if (row === null) { + return noEffect; + } + // Content-hash gate: same hash → no write (idempotent re-run). + if (item.contentHash === row.contentHash) { + return noEffect; + } + yield* engine.editTicketFieldsUnlocked(row.ticketId as TicketId, { + title: item.title, + ...(item.description === undefined ? {} : { description: item.description }), + }); + const now = DateTime.formatIso(yield* DateTime.now); + yield* wrap( + "WorkflowSourceCommitter.updateChanged", + sql` + UPDATE work_source_mapping + SET provider_version = ${item.providerVersion ?? null}, + content_hash = ${item.contentHash}, + source_metadata_json = ${serializeMetadata(item.metadata)}, + last_synced_at = ${now} + WHERE mapping_id = ${row.mappingId} + `, + ); + return { ...noEffect, publishTicketId: row.ticketId as TicketId }; + }); + + // Close a ticket from the source: snapshot its cancellable provider turns + // BEFORE the in-tx tombstone hides them, then route it to the closed lane + // (DB-only supersession: tombstone, no provider IO). The returned DeltaEffect + // carries the snapshot so the committer cancels the provider work POST-TX. + const closeTicket = ( + ticketId: TicketId, + closedLane: LaneKey, + ): Effect.Effect => + Effect.gen(function* () { + const cancelTurns = yield* engine.cancellableProviderTurnsForTicket(ticketId); + yield* engine.closeTicketFromSourceUnlocked(ticketId, closedLane); + return { + publishTicketId: ticketId, + republishDependents: true, + cancelTicketId: ticketId, + cancelTurns, + }; + }); + + const applyClosed = ( + boardId: BoardId, + lanes: ReconcileLanes, + item: SourceItemFields, + ): Effect.Effect => + Effect.gen(function* () { + const row = yield* readMapping(boardId, item); + if (row === null || row.lifecycle === "closed") { + return noEffect; + } + const effectResult = yield* closeTicket(row.ticketId as TicketId, lanes.closedLane); + const now = DateTime.formatIso(yield* DateTime.now); + yield* wrap( + "WorkflowSourceCommitter.updateClosed", + sql` + UPDATE work_source_mapping + SET lifecycle = 'closed', content_hash = ${item.contentHash}, last_synced_at = ${now} + WHERE mapping_id = ${row.mappingId} + `, + ); + return effectResult; + }); + + const applyMissing = ( + boardId: BoardId, + lanes: ReconcileLanes, + item: SourceItemFields, + confirmedDeleted: boolean, + ): Effect.Effect => + Effect.gen(function* () { + const row = yield* readMapping(boardId, item); + if (row === null) { + return noEffect; + } + const now = DateTime.formatIso(yield* DateTime.now); + // Mark-only: flag the mapping orphaned. The getItem confirmation is a + // provider call done OUTSIDE this tx (in the syncer); when it confirms + // deletion the syncer sets confirmedDeleted and we also terminal-route. + if (confirmedDeleted) { + let effectResult: DeltaEffect = { ...noEffect, publishTicketId: row.ticketId as TicketId }; + if (row.lifecycle !== "closed") { + effectResult = yield* closeTicket(row.ticketId as TicketId, lanes.closedLane); + } + yield* wrap( + "WorkflowSourceCommitter.markOrphanClosed", + sql` + UPDATE work_source_mapping + SET sync_status = 'orphaned', lifecycle = 'closed', last_synced_at = ${now} + WHERE mapping_id = ${row.mappingId} + `, + ); + return effectResult; + } + if (row.syncStatus === "orphaned") { + return noEffect; + } + yield* wrap( + "WorkflowSourceCommitter.markOrphan", + sql` + UPDATE work_source_mapping + SET sync_status = 'orphaned', last_synced_at = ${now} + WHERE mapping_id = ${row.mappingId} + `, + ); + return { ...noEffect, publishTicketId: row.ticketId as TicketId }; + }); + + const applyDelta = ( + boardId: BoardId, + lanes: ReconcileLanes, + delta: SourceDelta, + ): Effect.Effect => { + switch (delta._tag) { + case "new": + return applyNew(boardId, lanes, delta.item); + case "changed": + return applyChanged(boardId, delta.item); + case "closed": + return applyClosed(boardId, lanes, delta.item); + case "missing": + return applyMissing(boardId, lanes, delta.item, delta.confirmedDeleted === true); + } + }; + + // Validate the destination/closed lanes against the CURRENT board definition + // (the in-memory registry is the source of truth) BEFORE applying any delta. + // A board edited between sync cycles may have removed a lane the diff named; + // enterLaneCore would otherwise emit a move/create for a lane that no longer + // exists and corrupt lane state. Fail the whole chunk (typed error → the + // syncer backs the source off) without creating or moving any ticket. + const validateLanes = (boardId: BoardId, lanes: ReconcileLanes) => + Effect.gen(function* () { + const definition = yield* registry.getDefinition(boardId); + if (definition === null) { + return yield* new WorkflowEventStoreError({ + message: `WorkflowSourceCommitter: board ${String(boardId)} is no longer registered`, + }); + } + const laneKeys = new Set(definition.lanes.map((lane) => lane.key as string)); + for (const laneKey of [lanes.destinationLane, lanes.closedLane]) { + if (!laneKeys.has(laneKey as string)) { + return yield* new WorkflowEventStoreError({ + message: `WorkflowSourceCommitter: lane ${String(laneKey)} does not exist on board ${String(boardId)}`, + }); + } + } + }); + + const reconcileChunk: WorkflowSourceCommitterShape["reconcileChunk"] = ( + boardId, + lanes, + deltas, + ) => + Effect.gen(function* () { + if (deltas.length === 0) { + return; + } + // Constraint A — lock order: admission (OUTER) -> save (INNER) -> + // transaction (innermost). The unlocked engine cores assume the admission + // lock is held so sync admits serialize against concurrent user moves and + // cannot violate a WIP limit; this matches the public enterLane order. + const effects = yield* engine.withBoardAdmissionLock( + boardId, + saveLocks.withSaveLock( + boardId, + sql + .withTransaction( + Effect.gen(function* () { + // Lane validation runs INSIDE the locked tx and BEFORE any delta + // so a missing destination/closed lane fails the chunk atomically + // (nothing created/moved). + yield* validateLanes(boardId, lanes); + const collected: Array = []; + for (const delta of deltas) { + collected.push(yield* applyDelta(boardId, lanes, delta)); + } + return collected; + }), + ) + .pipe(Effect.mapError(toCommitterError("WorkflowSourceCommitter.transaction"))), + ), + ); + + // ---- POST-COMMIT phase ---------------------------------------------- + // Everything below runs ONLY because the tx committed and the locks + // released — a rolled-back chunk never reaches here (correct: a rollback + // skips all of it). The close's OWN durable side effects (publish + + // provider-cancel) run FIRST and UNCONDITIONALLY; board WIP recovery runs + // LAST and defensively, because it is an unrelated, backstopped sweep + // whose failure must never suppress side effects tied to THIS committed + // close. + + // 1) Publish the collected ticket views. The unlocked cores append+project + // but never publish to WorkflowBoardEvents, so push a live view for every + // created/edited/closed/orphaned ticket (and dependents on a terminal/ + // closed move) now that the lock/tx has released. Mirrors commitMany's + // post-lock publish. + const published = new Set(); + for (const effectResult of effects) { + if (effectResult.publishTicketId === null) { + continue; + } + const key = `${effectResult.publishTicketId as string}:${effectResult.republishDependents}`; + if (published.has(key)) { + continue; + } + published.add(key); + yield* committer + .publishTicketView(effectResult.publishTicketId, { + republishDependents: effectResult.republishDependents, + }) + .pipe(Effect.catch(() => Effect.void)); + } + + // 2) Provider cancellation for source-closed tickets: interrupt the + // running pipeline fiber + cancel the provider turns snapshotted in-tx. + // Idempotent. Tied to THIS committed close, so it must always run. + for (const effectResult of effects) { + if (effectResult.cancelTicketId !== null) { + yield* engine + .supersedeProviderWorkForTicket(effectResult.cancelTicketId, effectResult.cancelTurns) + .pipe(Effect.catch(() => Effect.void)); + } + } + + // 3) Board WIP recovery LAST. The unlocked cores DROP auto-lane pipeline + // starts (SQLite cannot BEGIN-within-BEGIN); recoverBoardWip sweeps the + // board (taking its own admission lock) and starts admitted-but-not-yet- + // started pipelines. It does DB reads + pipeline starts and CAN fail; a + // failure here must NOT propagate to fail reconcileChunk nor suppress the + // publish/provider-cancel above. It is backstopped — WT11's syncer calls + // recoverBoardWip per board per cycle regardless of deltas — so a transient + // failure self-heals. Wrap defensively: log a warning and swallow. + yield* engine.recoverBoardWip(boardId).pipe( + Effect.catch((cause) => + Effect.logWarning("WorkflowSourceCommitter.recoverBoardWip failed post-commit", { + boardId, + cause, + }), + ), + ); + }); + + return { reconcileChunk } satisfies WorkflowSourceCommitterShape; +}); + +export const WorkflowSourceCommitterLive = Layer.effect(WorkflowSourceCommitter, make); diff --git a/apps/server/src/workflow/Layers/WorkflowSourceSyncer.test.ts b/apps/server/src/workflow/Layers/WorkflowSourceSyncer.test.ts new file mode 100644 index 00000000000..afefb6f2a70 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowSourceSyncer.test.ts @@ -0,0 +1,603 @@ +// @effect-diagnostics globalTimers:off +import { assert, it } from "@effect/vitest"; +import type { BoardId, LaneKey, WorkflowDefinition } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +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 { BoardRegistry, type BoardRegistryShape } from "../Services/BoardRegistry.ts"; +import { WorkflowEngine, type WorkflowEngineShape } from "../Services/WorkflowEngine.ts"; +import { + WorkSourceProviderRegistry, + WorkSourceRateLimitError, + WorkSourceTransientError, + type ExternalWorkItem, + type WorkSourcePage, + type WorkSourceProvider, + type WorkSourceProviderError, +} from "../Services/WorkSourceProvider.ts"; +import { + WorkflowSourceCommitter, + type ReconcileLanes, + type SourceDelta, +} from "../Services/WorkflowSourceCommitter.ts"; +import { + MAX_DELTAS_PER_RECONCILE_CHUNK, + WorkflowSourceSyncerLive, +} from "./WorkflowSourceSyncer.ts"; +import { WorkflowSourceSyncer } from "../Services/WorkflowSourceSyncer.ts"; + +// --------------------------------------------------------------------------- +// Test doubles +// --------------------------------------------------------------------------- + +// A scriptable provider: each source maps to a sequence of pages keyed by +// pageToken (undefined = first page). getItem returns a configured map of +// externalId -> item|null (null = provider confirms deletion). +interface ProviderScript { + readonly pages: ReadonlyArray; + readonly getItems: ReadonlyMap; + readonly failWith?: WorkSourceRateLimitError | WorkSourceTransientError; + // When set, getItem fails with this error instead of resolving an item — used + // to prove a getItem ERROR does NOT confirm deletion (it feeds backoff). + readonly getItemFailWith?: WorkSourceRateLimitError | WorkSourceTransientError; +} + +const item = (externalId: string, overrides?: Partial): ExternalWorkItem => ({ + provider: "github", + externalId, + url: `https://example.test/${externalId}`, + lifecycle: "open", + version: { updatedAt: "2026-06-13T00:00:00Z" }, + fields: { title: `Item ${externalId}` }, + ...overrides, +}); + +// Build a multi-page script. `pageTokens` are the nextPageToken values; the +// final page omits nextPageToken (exhaustion) unless `lastHasToken` is set. +const makePages = ( + itemsPerPage: ReadonlyArray>, + options?: { readonly lastHasToken?: boolean }, +): ReadonlyArray => + itemsPerPage.map((items, idx) => { + const isLast = idx === itemsPerPage.length - 1; + const hasToken = !isLast || options?.lastHasToken === true; + return hasToken ? { items, nextPageToken: `tok-${idx + 1}` } : { items }; + }); + +// A recording stub committer: appends each reconcileChunk's deltas. +const recordingCommitter = ( + chunks: Ref.Ref }>>, +) => + Layer.succeed(WorkflowSourceCommitter, { + reconcileChunk: (boardId, lanes, deltas) => + Ref.update(chunks, (acc) => [...acc, { boardId: String(boardId), lanes, deltas }]), + }); + +// A recording stub engine: counts recoverBoardWip calls per board. Optionally +// fails recoverBoardWip to prove the defensive wrap swallows it. +const recordingEngine = ( + recoveries: Ref.Ref>, + options?: { readonly recoverFails?: boolean }, +): WorkflowEngineShape => + ({ + recoverBoardWip: (boardId: BoardId) => + Effect.flatMap( + Ref.update(recoveries, (acc) => [...acc, String(boardId)]), + () => + options?.recoverFails === true + ? Effect.die("recoverBoardWip boom") + : Effect.void, + ), + }) as unknown as WorkflowEngineShape; + +const board = ( + boardId: string, + sources: WorkflowDefinition["sources"], +): { readonly boardId: BoardId; readonly definition: WorkflowDefinition } => ({ + boardId: boardId as BoardId, + definition: { + name: boardId, + lanes: [ + { key: "todo" as LaneKey, name: "Todo", entry: "manual" }, + { key: "done" as LaneKey, name: "Done", entry: "manual", terminal: true }, + ], + sources, + } as unknown as WorkflowDefinition, +}); + +const stubBoardRegistry = ( + boards: ReadonlyArray<{ readonly boardId: BoardId; readonly definition: WorkflowDefinition }>, +): BoardRegistryShape => + ({ + listDefinitions: () => Effect.succeed(boards), + getDefinition: (boardId: BoardId) => + Effect.succeed(boards.find((b) => b.boardId === boardId)?.definition ?? null), + }) as unknown as BoardRegistryShape; + +const stubProviderRegistry = (scripts: ReadonlyMap) => { + const make = (): WorkSourceProvider => { + // Track per-sourceKey page index so successive listPage calls advance. + const cursors = new Map(); + return { + provider: "github", + selectorSchema: undefined as never, + listPage: (input): Effect.Effect => + Effect.suspend(() => { + const key = (input.selector as { readonly key: string }).key; + const script = scripts.get(key); + if (script === undefined) { + return Effect.succeed({ items: [] }); + } + if (script.failWith !== undefined) { + return Effect.fail(script.failWith); + } + const idx = cursors.get(key) ?? 0; + cursors.set(key, idx + 1); + const page: WorkSourcePage = script.pages[idx] ?? { items: [] }; + return Effect.succeed(page); + }), + getItem: (input) => + Effect.suspend(() => { + // Resolve which script this externalId belongs to (selector carries + // the source key in these tests). + const key = (input.selector as { readonly key?: string } | undefined)?.key; + const keyedScript = key === undefined ? undefined : scripts.get(key); + if (keyedScript?.getItemFailWith !== undefined) { + return Effect.fail(keyedScript.getItemFailWith); + } + for (const script of scripts.values()) { + if (script.getItemFailWith !== undefined && script.getItems.has(input.externalId)) { + return Effect.fail(script.getItemFailWith); + } + if (script.getItems.has(input.externalId)) { + return Effect.succeed(script.getItems.get(input.externalId) ?? null); + } + } + return Effect.succeed(null); + }), + }; + }; + const provider = make(); + return Layer.succeed(WorkSourceProviderRegistry, { + get: () => provider, + }); +}; + +// State-table helpers --------------------------------------------------------- + +const readState = (boardId: string, sourceId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ + readonly consecutiveFailures: number; + readonly backoffUntil: string | null; + readonly lastFullRunAt: string | null; + readonly lastError: string | null; + }>` + SELECT consecutive_failures AS "consecutiveFailures", + backoff_until AS "backoffUntil", + last_full_run_at AS "lastFullRunAt", + last_error AS "lastError" + FROM work_source_state + WHERE board_id = ${boardId} AND source_id = ${sourceId} + `; + return rows[0] ?? null; + }); + +const seedState = ( + boardId: string, + sourceId: string, + fields: { backoffUntil?: string | null; consecutiveFailures?: number; lastFullRunAt?: string | null }, +) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO work_source_state (board_id, source_id, cursor_or_etag, last_full_run_at, backoff_until, consecutive_failures, last_error) + VALUES (${boardId}, ${sourceId}, NULL, ${fields.lastFullRunAt ?? null}, ${fields.backoffUntil ?? null}, ${fields.consecutiveFailures ?? 0}, NULL) + `; + }); + +const seedMapping = ( + boardId: string, + sourceId: string, + externalId: string, + ticketId: string, +) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const now = DateTime.formatIso(yield* DateTime.now); + yield* sql` + INSERT INTO work_source_mapping ( + mapping_id, board_id, source_id, provider, external_id, ticket_id, + provider_version, content_hash, lifecycle, sync_status, + source_metadata_json, created_at, last_synced_at + ) VALUES ( + ${`m-${externalId}`}, ${boardId}, ${sourceId}, 'github', ${externalId}, ${ticketId}, + NULL, ${"stale-hash"}, 'open', 'active', NULL, ${now}, ${now} + ) + `; + }); + +const githubSource = ( + id: string, + selectorKey: string, + enabled = true, + extra?: { readonly syncIntervalSec?: number }, +): WorkflowDefinition["sources"] => + [ + { + id: id as never, + provider: "github" as const, + connectionRef: "conn-1", + selector: { key: selectorKey }, + destinationLane: "todo" as LaneKey, + closedLane: "done" as LaneKey, + enabled, + ...(extra?.syncIntervalSec === undefined ? {} : { syncIntervalSec: extra.syncIntervalSec }), + }, + ] as unknown as WorkflowDefinition["sources"]; + +// Compose the syncer under test with all stub deps + real sqlite. +const makeLayer = (params: { + readonly boards: ReadonlyArray<{ readonly boardId: BoardId; readonly definition: WorkflowDefinition }>; + readonly scripts: ReadonlyMap; + readonly chunks: Ref.Ref }>>; + readonly recoveries: Ref.Ref>; + readonly recoverFails?: boolean; +}) => + WorkflowSourceSyncerLive.pipe( + Layer.provide(Layer.succeed(BoardRegistry, stubBoardRegistry(params.boards))), + Layer.provide(stubProviderRegistry(params.scripts)), + Layer.provide(recordingCommitter(params.chunks)), + Layer.provide(Layer.succeed(WorkflowEngine, recordingEngine(params.recoveries, { recoverFails: params.recoverFails ?? false }))), + Layer.provideMerge(MigrationsLive), + Layer.provideMerge(SqlitePersistenceMemory), + ); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +it.effect("multi-page scan that exhausts → scanCompleted true, missing detected, last_full_run_at set", () => + Effect.gen(function* () { + const chunks = yield* Ref.make }>>([]); + const recoveries = yield* Ref.make>([]); + const scripts = new Map([ + [ + "src-a", + { + // 2 pages, exhausts (last has no token). Item "1" present. + pages: makePages([[item("1")], [item("2")]]), + getItems: new Map(), + }, + ], + ]); + const boards = [board("board-1", githubSource("source-a", "src-a"))]; + + const run = Effect.gen(function* () { + // Pre-existing mapping "gone" not in the scan → should produce a missing delta. + yield* seedMapping("board-1", "source-a", "gone", "ticket-gone"); + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const recorded = yield* Ref.get(chunks); + const allDeltas = recorded.flatMap((c) => c.deltas); + const tags = allDeltas.map((d) => d._tag); + assert.include(tags, "new"); // items 1,2 unmapped + assert.include(tags, "missing"); // "gone" mapping not in scan + const state = yield* readState("board-1", "source-a"); + assert.isNotNull(state!.lastFullRunAt); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect("page cap hit with nextPageToken still present → scanCompleted false, NO missing, last_full_run_at NOT set", () => + Effect.gen(function* () { + const chunks = yield* Ref.make }>>([]); + const recoveries = yield* Ref.make>([]); + // 12 pages each still carrying a nextPageToken (lastHasToken) → MAX_PAGES cap (10) reached + // while nextPageToken still present. + const pages = makePages( + Array.from({ length: 12 }, (_, i) => [item(`p${i}`)]), + { lastHasToken: true }, + ); + const scripts = new Map([ + ["src-cap", { pages, getItems: new Map() }], + ]); + const boards = [board("board-1", githubSource("source-cap", "src-cap"))]; + + const run = Effect.gen(function* () { + yield* seedMapping("board-1", "source-cap", "gone", "ticket-gone"); + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const recorded = yield* Ref.get(chunks); + const tags = recorded.flatMap((c) => c.deltas).map((d) => d._tag); + assert.notInclude(tags, "missing"); + const state = yield* readState("board-1", "source-cap"); + assert.isNull(state!.lastFullRunAt); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect("rate-limit error → backoff_until from retryAfterMs, consecutive_failures incremented; other sources still processed", () => + Effect.gen(function* () { + const chunks = yield* Ref.make }>>([]); + const recoveries = yield* Ref.make>([]); + const scripts = new Map([ + ["src-fail", { pages: [], getItems: new Map(), failWith: new WorkSourceRateLimitError({ retryAfterMs: 60_000 }) }], + ["src-ok", { pages: makePages([[item("ok1")]]), getItems: new Map() }], + ]); + // Two boards so isolation across the sweep is exercised. + const boards = [ + board("board-fail", githubSource("source-fail", "src-fail")), + board("board-ok", githubSource("source-ok", "src-ok")), + ]; + + const run = Effect.gen(function* () { + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const failState = yield* readState("board-fail", "source-fail"); + assert.equal(failState!.consecutiveFailures, 1); + assert.isNotNull(failState!.backoffUntil); + assert.isNotNull(failState!.lastError); + + // The OK source still produced a chunk. + const recorded = yield* Ref.get(chunks); + assert.isTrue(recorded.some((c) => c.boardId === "board-ok")); + // recoverBoardWip still called for BOTH boards. + const recs = yield* Ref.get(recoveries); + assert.include(recs, "board-fail"); + assert.include(recs, "board-ok"); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect("success after failure → consecutive_failures reset to 0, backoff cleared", () => + Effect.gen(function* () { + const chunks = yield* Ref.make }>>([]); + const recoveries = yield* Ref.make>([]); + const scripts = new Map([ + ["src-recover", { pages: makePages([[item("r1")]]), getItems: new Map() }], + ]); + const boards = [board("board-1", githubSource("source-recover", "src-recover"))]; + + const run = Effect.gen(function* () { + // Seed a PAST backoff + prior failures: backoff has elapsed so the source runs. + yield* seedState("board-1", "source-recover", { + backoffUntil: "2000-01-01T00:00:00Z", + consecutiveFailures: 3, + }); + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const state = yield* readState("board-1", "source-recover"); + assert.equal(state!.consecutiveFailures, 0); + assert.isNull(state!.backoffUntil); + assert.isNull(state!.lastError); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect("deltas chunked at MAX_DELTAS_PER_RECONCILE_CHUNK → multiple reconcileChunk calls", () => + Effect.gen(function* () { + const chunks = yield* Ref.make }>>([]); + const recoveries = yield* Ref.make>([]); + const count = MAX_DELTAS_PER_RECONCILE_CHUNK + 5; // forces 2 chunks + const items = Array.from({ length: count }, (_, i) => item(`x${i}`)); + const scripts = new Map([ + ["src-big", { pages: makePages([items]), getItems: new Map() }], + ]); + const boards = [board("board-1", githubSource("source-big", "src-big"))]; + + const run = Effect.gen(function* () { + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + const recorded = yield* Ref.get(chunks); + assert.equal(recorded.length, 2); + assert.equal(recorded[0]!.deltas.length, MAX_DELTAS_PER_RECONCILE_CHUNK); + assert.equal(recorded[1]!.deltas.length, 5); + // Lanes threaded through from the source config. + assert.equal(recorded[0]!.lanes.destinationLane, "todo"); + assert.equal(recorded[0]!.lanes.closedLane, "done"); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect("missing mapping → getItem called; null → confirmedDeleted true; non-null → confirmedDeleted false", () => + Effect.gen(function* () { + const chunks = yield* Ref.make }>>([]); + const recoveries = yield* Ref.make>([]); + // Scan exhausts with NO items → both seeded mappings are "missing". + // getItem: "deleted-id" → null (confirmed deleted); "exists-id" → still exists. + const scripts = new Map([ + [ + "src-miss", + { + pages: makePages([[]]), + getItems: new Map([ + ["deleted-id", null], + ["exists-id", item("exists-id")], + ]), + }, + ], + ]); + const boards = [board("board-1", githubSource("source-miss", "src-miss"))]; + + const run = Effect.gen(function* () { + yield* seedMapping("board-1", "source-miss", "deleted-id", "ticket-del"); + yield* seedMapping("board-1", "source-miss", "exists-id", "ticket-ex"); + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const recorded = yield* Ref.get(chunks); + const missing = recorded + .flatMap((c) => c.deltas) + .filter((d): d is Extract => d._tag === "missing"); + assert.equal(missing.length, 2); + const del = missing.find((d) => d.item.externalId === "deleted-id"); + const exist = missing.find((d) => d.item.externalId === "exists-id"); + assert.equal(del!.confirmedDeleted, true); + assert.equal(exist!.confirmedDeleted, false); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect("Finding #5: zero-delta board STILL calls recoverBoardWip; a recoverBoardWip failure is caught (sweep continues)", () => + Effect.gen(function* () { + const chunks = yield* Ref.make }>>([]); + const recoveries = yield* Ref.make>([]); + // A source whose scan exhausts with NO items and NO mappings → zero deltas. + const scripts = new Map([ + ["src-empty", { pages: makePages([[]]), getItems: new Map() }], + ]); + const boards = [board("board-empty", githubSource("source-empty", "src-empty"))]; + + const run = Effect.gen(function* () { + const syncer = yield* WorkflowSourceSyncer; + // recoverFails: true → must be swallowed, sweep must not crash. + yield* syncer.sweep; + const recorded = yield* Ref.get(chunks); + assert.equal(recorded.flatMap((c) => c.deltas).length, 0); + const recs = yield* Ref.get(recoveries); + assert.include(recs, "board-empty"); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries, recoverFails: true }))); + }), +); + +it.effect("source in backoff (backoff_until in the future) is SKIPPED this tick", () => + Effect.gen(function* () { + const chunks = yield* Ref.make }>>([]); + const recoveries = yield* Ref.make>([]); + const scripts = new Map([ + ["src-backoff", { pages: makePages([[item("s1")]]), getItems: new Map() }], + ]); + const boards = [board("board-1", githubSource("source-backoff", "src-backoff"))]; + + const run = Effect.gen(function* () { + yield* seedState("board-1", "source-backoff", { + backoffUntil: "2999-01-01T00:00:00Z", + consecutiveFailures: 2, + }); + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const recorded = yield* Ref.get(chunks); + // Source skipped → no chunks at all. + assert.equal(recorded.length, 0); + // State untouched (still 2 failures). + const state = yield* readState("board-1", "source-backoff"); + assert.equal(state!.consecutiveFailures, 2); + // recoverBoardWip STILL runs per board (Finding #5) even with a skipped source. + const recs = yield* Ref.get(recoveries); + assert.include(recs, "board-1"); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect("Fix 2: a getItem ERROR does NOT confirm deletion (no terminal-route) and feeds backoff", () => + Effect.gen(function* () { + const chunks = yield* Ref.make }>>([]); + const recoveries = yield* Ref.make>([]); + // Scan exhausts with NO items → the seeded mapping is "missing". + // getItem FAILS (transient) → must NOT mark confirmedDeleted; the whole + // source pass becomes a recorded backoff, so NO chunk is committed. + const scripts = new Map([ + [ + "src-err", + { + pages: makePages([[]]), + getItems: new Map([["orphan-id", null]]), + getItemFailWith: new WorkSourceTransientError({ message: "github 500 (getItem)" }), + }, + ], + ]); + const boards = [board("board-1", githubSource("source-err", "src-err"))]; + + const run = Effect.gen(function* () { + yield* seedMapping("board-1", "source-err", "orphan-id", "ticket-orphan"); + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + // No chunk committed → ticket never terminal-routed on a getItem error. + const recorded = yield* Ref.get(chunks); + assert.equal(recorded.flatMap((c) => c.deltas).length, 0); + // The error fed the per-source backoff (consecutive_failures incremented, + // backoff_until set) — i.e. it behaved like a listPage failure. + const state = yield* readState("board-1", "source-err"); + assert.equal(state!.consecutiveFailures, 1); + assert.isNotNull(state!.backoffUntil); + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect("Fix 4: a source with syncIntervalSec=600 + a recent last_full_run_at is SKIPPED this sweep", () => + Effect.gen(function* () { + const chunks = yield* Ref.make }>>([]); + const recoveries = yield* Ref.make>([]); + const scripts = new Map([ + ["src-throttle", { pages: makePages([[item("t1")]]), getItems: new Map() }], + ]); + const boards = [ + board("board-1", githubSource("source-throttle", "src-throttle", true, { syncIntervalSec: 600 })), + ]; + + const run = Effect.gen(function* () { + // The due-gate compares last_full_run_at + interval against the REAL wall + // clock (DateTime.isFutureUnsafe), not the test clock. Seed a far-future + // last_full_run_at so last_full_run_at + 600s is unambiguously in the + // future → the source is throttled/SKIPPED this sweep. + yield* seedState("board-1", "source-throttle", { + lastFullRunAt: "2999-01-01T00:00:00Z", + }); + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const recorded = yield* Ref.get(chunks); + assert.equal(recorded.length, 0); // throttled → no listPage/commit this tick + const recs = yield* Ref.get(recoveries); + assert.include(recs, "board-1"); // recoverBoardWip still runs per board + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); + +it.effect("Fix 4: a source with a STALE last_full_run_at (older than the interval) RUNS", () => + Effect.gen(function* () { + const chunks = yield* Ref.make }>>([]); + const recoveries = yield* Ref.make>([]); + const scripts = new Map([ + ["src-due", { pages: makePages([[item("d1")]]), getItems: new Map() }], + ]); + const boards = [ + board("board-1", githubSource("source-due", "src-due", true, { syncIntervalSec: 600 })), + ]; + + const run = Effect.gen(function* () { + // last_full_run_at far in the past → due → RUNS this tick. + yield* seedState("board-1", "source-due", { lastFullRunAt: "2000-01-01T00:00:00Z" }); + const syncer = yield* WorkflowSourceSyncer; + yield* syncer.sweep; + + const recorded = yield* Ref.get(chunks); + const tags = recorded.flatMap((c) => c.deltas).map((d) => d._tag); + assert.include(tags, "new"); // it ran → produced a "new" delta for d1 + }); + yield* run.pipe(Effect.provide(makeLayer({ boards, scripts, chunks, recoveries }))); + }), +); diff --git a/apps/server/src/workflow/Layers/WorkflowSourceSyncer.ts b/apps/server/src/workflow/Layers/WorkflowSourceSyncer.ts new file mode 100644 index 00000000000..0250d14d4ee --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowSourceSyncer.ts @@ -0,0 +1,432 @@ +import type { BoardId, LaneKey, WorkflowSourceConfig } from "@t3tools/contracts"; +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 Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { BoardRegistry } from "../Services/BoardRegistry.ts"; +import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; +import { + WorkSourceProviderRegistry, + WorkSourceRateLimitError, + type ExternalWorkItem, + type WorkSourcePage, + type WorkSourceProvider, + type WorkSourceProviderError, +} from "../Services/WorkSourceProvider.ts"; +import { + WorkflowSourceCommitter, + type SourceDelta, +} from "../Services/WorkflowSourceCommitter.ts"; +import { + WorkflowSourceSyncer, + type WorkflowSourceSyncerShape, +} from "../Services/WorkflowSourceSyncer.ts"; +import { classifyDeltas, type MappingRow } from "../sourceReconcileDiff.ts"; + +// --------------------------------------------------------------------------- +// Locked tuning constants (do not change without the plan owner's sign-off). +// --------------------------------------------------------------------------- + +// Hard ceiling on listPage round-trips per (board, source) per sweep tick. +// Bounds a single tick's network + memory; a source with more pages than this +// is scanned partially (scanCompleted=false) and finishes over subsequent ticks. +export const MAX_PAGES_PER_SOURCE_TICK = 10; +// Hard ceiling on accumulated items per (board, source) per sweep tick. +export const MAX_ITEMS_PER_SOURCE_TICK = 500; +// The committer takes the board locks + a transaction PER chunk and releases +// between chunks, so this bounds how long the board is locked at once. +export const MAX_DELTAS_PER_RECONCILE_CHUNK = 50; +// Fallback sweep cadence when a source omits syncIntervalSec. +export const DEFAULT_SYNC_INTERVAL_SEC = 120; + +// Exponential backoff base + cap for non-rate-limited provider failures. +const BACKOFF_BASE_MS = 30_000; // 30s +const BACKOFF_CAP_MS = 3_600_000; // 1h + +// Schema-aware runtime guard for the rate-limit variant (the codebase forbids +// `instanceof` on Schema TaggedError classes — use Schema.is). +const isRateLimitError = Schema.is(WorkSourceRateLimitError); + +// Human-readable summary of any provider error for the last_error column. +const describeError = (error: WorkSourceProviderError): string => { + switch (error._tag) { + case "WorkSourceRateLimitError": + return `rate-limited (retryAfterMs=${error.retryAfterMs})`; + case "WorkSourceAuthError": + return `auth failed (connectionRef=${error.connectionRef})`; + case "WorkSourceTransientError": + case "WorkSourceConfigError": + return `${error._tag}: ${error.message}`; + } +}; + +// --------------------------------------------------------------------------- +// SQL row shapes +// --------------------------------------------------------------------------- + +interface SourceStateRow { + readonly backoffUntil: string | null; + readonly consecutiveFailures: number; + readonly lastFullRunAt: string | null; +} + +interface MappingSelectRow { + readonly externalId: string; + readonly ticketId: string; + readonly contentHash: string; + readonly providerVersion: string | null; + readonly lifecycle: string; + readonly syncStatus: string; +} + +// --------------------------------------------------------------------------- +// Pagination result +// --------------------------------------------------------------------------- + +interface ScanResult { + readonly items: ReadonlyArray; + // scanCompleted is TRUE iff we reached a page with no nextPageToken AND + // neither the page cap nor the item cap was hit. A cap hit while a + // nextPageToken is still present means the scan is PARTIAL → completeness + // gate stays closed (no missing/orphan detection, last_full_run_at frozen). + readonly scanCompleted: boolean; +} + +const chunkArray = (items: ReadonlyArray, size: number): ReadonlyArray> => { + const out: Array> = []; + for (let i = 0; i < items.length; i += size) { + out.push(items.slice(i, i + size)); + } + return out; +}; + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const registry = yield* WorkSourceProviderRegistry; + const committer = yield* WorkflowSourceCommitter; + const engine = yield* WorkflowEngine; + const boards = yield* BoardRegistry; + + const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + + // Read (board, source) state, treating an absent row as fresh (no backoff, + // zero failures). The state row is upserted lazily on the first sweep that + // touches the source. + const readState = (boardId: BoardId, sourceId: string) => + sql` + SELECT backoff_until AS "backoffUntil", + consecutive_failures AS "consecutiveFailures", + last_full_run_at AS "lastFullRunAt" + FROM work_source_state + WHERE board_id = ${String(boardId)} AND source_id = ${sourceId} + `.pipe(Effect.map((rows) => rows[0] ?? null)); + + // Idempotent upsert of the state row keyed by (board_id, source_id). + const ensureStateRow = (boardId: BoardId, sourceId: string) => + sql` + INSERT INTO work_source_state (board_id, source_id, consecutive_failures) + VALUES (${String(boardId)}, ${sourceId}, 0) + ON CONFLICT (board_id, source_id) DO NOTHING + `; + + const readMappings = (boardId: BoardId, sourceId: string) => + sql` + SELECT external_id AS "externalId", + ticket_id AS "ticketId", + content_hash AS "contentHash", + provider_version AS "providerVersion", + lifecycle AS "lifecycle", + sync_status AS "syncStatus" + FROM work_source_mapping + WHERE board_id = ${String(boardId)} AND source_id = ${sourceId} + `.pipe( + Effect.map((rows) => + rows.map( + (row): MappingRow => ({ + externalId: row.externalId, + ticketId: row.ticketId, + contentHash: row.contentHash, + providerVersion: row.providerVersion, + lifecycle: row.lifecycle, + syncStatus: row.syncStatus, + }), + ), + ), + ); + + // Paginate listPage up to the page/item caps. completeness gate: + // scanCompleted=true ONLY when a page arrives with no nextPageToken before + // either cap was hit. If we stop because of a cap while a token remains, + // scanCompleted=false. + const scan = ( + provider: WorkSourceProvider, + source: WorkflowSourceConfig, + since: string | undefined, + ): Effect.Effect => + Effect.gen(function* () { + const items: Array = []; + let pageToken: string | undefined = undefined; + let scanCompleted = false; + for (let page = 0; page < MAX_PAGES_PER_SOURCE_TICK; page++) { + const fetched: WorkSourcePage = yield* provider.listPage({ + connectionRef: source.connectionRef, + selector: source.selector, + ...(since === undefined ? {} : { since }), + ...(pageToken === undefined ? {} : { pageToken }), + pageSize: 100, + }); + for (const it of fetched.items) { + items.push(it); + } + if (fetched.nextPageToken === undefined) { + // Reached the end of the list without hitting a cap → complete. + scanCompleted = true; + break; + } + // More pages remain. Stop early if the item cap is reached (partial). + if (items.length >= MAX_ITEMS_PER_SOURCE_TICK) { + scanCompleted = false; + break; + } + pageToken = fetched.nextPageToken; + // If this was the last allowed page and a token still remains, the + // loop exits with scanCompleted still false (partial scan). + } + return { items, scanCompleted } satisfies ScanResult; + }); + + // For each `missing` delta, ask the provider whether the item still exists. + // Result handling (CRITICAL — only a confirmed null deletes): + // - getItem succeeds with null → confirmedDeleted=true (404/gone), the + // committer may terminal-route the ticket. + // - getItem succeeds with item → the item still exists (merely fell out of + // the FILTERED scan, e.g. label removed) → confirmedDeleted=false, the + // ticket stays orphaned (NOT terminal). + // - getItem FAILS (auth/rate-limit/transient) → we CANNOT confirm deletion. + // The failure propagates to the source-pass failure channel → recorded as + // a backoff by recordFailure; the missing delta is NEVER marked + // confirmedDeleted on the strength of an error. (The whole pass is + // reprocessed next sweep, so no delta is silently dropped as deleted.) + // This getItem call is network and runs OUTSIDE any transaction. + const resolveMissing = ( + provider: WorkSourceProvider, + source: WorkflowSourceConfig, + deltas: ReadonlyArray, + ): Effect.Effect, WorkSourceProviderError> => + Effect.forEach(deltas, (delta) => { + if (delta._tag !== "missing") { + return Effect.succeed(delta); + } + return provider + .getItem({ + connectionRef: source.connectionRef, + selector: source.selector, + externalId: delta.item.externalId, + }) + .pipe( + Effect.map((item): SourceDelta => ({ + ...delta, + confirmedDeleted: item === null, + })), + ); + }); + + // On a successful source pass: reset failure tracking; only advance + // last_full_run_at when the scan was complete (a partial scan never proves + // the absence of an item). + const recordSuccess = (boardId: BoardId, sourceId: string, scanCompleted: boolean) => + Effect.gen(function* () { + // Reset failure tracking unconditionally; only bump last_full_run_at when + // the scan was complete (a partial scan never proves an item's absence, + // so the next complete scan is what authorizes missing-detection). + yield* sql` + UPDATE work_source_state + SET consecutive_failures = 0, + backoff_until = NULL, + last_error = NULL + WHERE board_id = ${String(boardId)} AND source_id = ${sourceId} + `; + if (scanCompleted) { + const now = yield* nowIso; + yield* sql` + UPDATE work_source_state + SET last_full_run_at = ${now} + WHERE board_id = ${String(boardId)} AND source_id = ${sourceId} + `; + } + }); + + // On a provider error: increment the failure counter and schedule a backoff. + // A rate-limit error uses the server-provided retryAfterMs verbatim; any + // other failure uses exponential backoff min(cap, base * 2^failures). + const recordFailure = ( + boardId: BoardId, + sourceId: string, + priorFailures: number, + error: WorkSourceProviderError, + ) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const isRateLimit = isRateLimitError(error); + const delayMs = isRateLimit + ? error.retryAfterMs + : Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** priorFailures); + const backoffUntil = DateTime.formatIso(DateTime.addDuration(now, Duration.millis(delayMs))); + const message = isRateLimit + ? `rate-limited (retryAfterMs=${error.retryAfterMs})` + : describeError(error); + yield* sql` + UPDATE work_source_state + SET consecutive_failures = consecutive_failures + 1, + backoff_until = ${backoffUntil}, + last_error = ${message} + WHERE board_id = ${String(boardId)} AND source_id = ${sourceId} + `; + }); + + // Process ONE source end-to-end. The whole body is wrapped by the caller in + // Effect.result so a provider/SQL failure here is isolated — it can never + // abort the board's other sources or the sweep. A provider error is caught + // HERE and converted into a recorded backoff (also a non-failing result). + const processSource = (boardId: BoardId, source: WorkflowSourceConfig) => + Effect.gen(function* () { + yield* ensureStateRow(boardId, source.id); + const state = yield* readState(boardId, source.id); + + // Backoff gate: skip this source this tick if its backoff has not passed. + if (state?.backoffUntil != null) { + const until = DateTime.makeUnsafe(state.backoffUntil); + if (DateTime.isFutureUnsafe(until)) { + return; + } + } + + // Per-source interval gate: the global sweep runs every + // DEFAULT_SYNC_INTERVAL_SEC, but a source may request a LONGER cadence via + // syncIntervalSec. Skip this source this tick if it completed a full scan + // (last_full_run_at set) more recently than its effective interval. A + // source that has never completed a scan (no last_full_run_at) always runs. + if (state?.lastFullRunAt != null) { + const effectiveIntervalSec = source.syncIntervalSec ?? DEFAULT_SYNC_INTERVAL_SEC; + const dueAt = DateTime.addDuration( + DateTime.makeUnsafe(state.lastFullRunAt), + Duration.seconds(effectiveIntervalSec), + ); + if (DateTime.isFutureUnsafe(dueAt)) { + return; + } + } + + const provider = registry.get(source.provider); + const priorFailures = state?.consecutiveFailures ?? 0; + + // Mapping read is plain SQL (not network). Keep it OUT of the + // provider-error capture below so the captured failure channel is purely + // WorkSourceProviderError — a backoff-able failure. (A SQL failure here + // is handled by the per-source isolation catch in the sweep.) + const mappings = yield* readMappings(boardId, source.id); + + // The network phase (listPage pagination + getItem confirmations) is the + // ONLY part that can raise a provider error; capture it so a rate-limit / + // auth / transient failure becomes a recorded backoff (not an exception). + const outcome = yield* scan(provider, source, undefined).pipe( + Effect.flatMap((scanned) => + Effect.gen(function* () { + const deltas = classifyDeltas({ + sourceId: source.id, + provider: source.provider, + items: scanned.items, + mappings, + scanCompleted: scanned.scanCompleted, + }); + // getItem confirmation for missing deltas — OUTSIDE any tx. + const resolved = yield* resolveMissing(provider, source, deltas); + return { resolved, scanCompleted: scanned.scanCompleted }; + }), + ), + Effect.result, + ); + + if (outcome._tag === "Failure") { + yield* recordFailure(boardId, source.id, priorFailures, outcome.failure); + return; + } + + const { resolved, scanCompleted } = outcome.success; + // Drive the committer per chunk; each chunk takes/releases its own locks. + for (const chunk of chunkArray(resolved, MAX_DELTAS_PER_RECONCILE_CHUNK)) { + yield* committer.reconcileChunk( + boardId, + { destinationLane: source.destinationLane as LaneKey, closedLane: source.closedLane as LaneKey }, + chunk, + ); + } + yield* recordSuccess(boardId, source.id, scanCompleted); + }); + + const sweep: WorkflowSourceSyncerShape["sweep"] = Effect.gen(function* () { + const definitions = yield* boards.listDefinitions().pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.source-syncer.list-boards-failed", { cause }).pipe( + Effect.as([] as ReadonlyArray<{ readonly boardId: BoardId }>), + ), + ), + ); + + for (const { boardId, definition } of definitions as ReadonlyArray<{ + readonly boardId: BoardId; + readonly definition: { readonly sources?: ReadonlyArray }; + }>) { + const sources = (definition.sources ?? []).filter((source) => source.enabled); + for (const source of sources) { + // Per-source isolation: any failure (provider error escaping the inner + // capture, SQL error, defect) is logged and swallowed so it never + // aborts the sweep or other sources/boards. + yield* processSource(boardId, source).pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.source-syncer.source-failed", { + boardId, + sourceId: source.id, + cause, + }), + ), + ); + } + + // FINDING #5: recover the board's WIP once per board per sweep, + // REGARDLESS of delta count. A prior cycle could have admitted a ticket + // whose committer post-tx recoverBoardWip failed; a later no-change cycle + // produces zero deltas, so without this unconditional call the + // admitted-but-unstarted pipeline is stranded forever. Defensively + // wrapped (catch + log) — a recovery failure must not abort the sweep. + yield* engine.recoverBoardWip(boardId).pipe( + Effect.catchCause((cause) => + Effect.logWarning("workflow.source-syncer.recover-wip-failed", { boardId, cause }), + ), + ); + } + }); + + const start: WorkflowSourceSyncerShape["start"] = () => + Effect.gen(function* () { + yield* Effect.forkScoped( + sweep.pipe( + Effect.catchDefect((defect: unknown) => + Effect.logWarning("workflow.source-syncer.sweep-defect", { defect }), + ), + Effect.repeat(Schedule.spaced(Duration.seconds(DEFAULT_SYNC_INTERVAL_SEC))), + ), + ); + yield* Effect.logInfo("workflow.source-syncer.started", { + intervalSec: DEFAULT_SYNC_INTERVAL_SEC, + }); + }); + + return { sweep, start } satisfies WorkflowSourceSyncerShape; +}); + +export const WorkflowSourceSyncerLive = Layer.effect(WorkflowSourceSyncer, make); 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..399695a1445 --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowTerminalRetentionSweeper.test.ts @@ -0,0 +1,563 @@ +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(), + createTicketAndEnterUnlocked: () => unsupported(), + closeTicketFromSourceUnlocked: () => unsupported(), + cancellableProviderTurnsForTicket: () => unsupported(), + supersedeProviderWorkForTicket: () => unsupported(), + editTicketFieldsUnlocked: () => unsupported(), + withBoardAdmissionLock: (_boardId, effect) => effect, + 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(WorkflowEventStoreLive), + Layer.provideMerge(WorkflowReadModelLive), + Layer.provideMerge(BoardRegistryLive), + 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..bcc9c298e1b --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowWebhook.test.ts @@ -0,0 +1,138 @@ +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("releaseDelivery lets the sender's retry be ingested after a failed ingest", () => + Effect.gen(function* () { + const webhook = yield* WorkflowWebhook; + + // Delivery recorded, then ingest fails: releasing must make the + // identical retry look fresh (not "duplicate") so the event is not lost. + assert.isFalse(yield* webhook.recordDelivery("board-retry" as never, "delivery-1")); + yield* webhook.releaseDelivery("board-retry" as never, "delivery-1"); + assert.isFalse(yield* webhook.recordDelivery("board-retry" as never, "delivery-1")); + // Once the retry succeeds, dedupe is back in force. + assert.isTrue(yield* webhook.recordDelivery("board-retry" as never, "delivery-1")); + + // Releasing only forgets the exact (board, delivery) pair. + assert.isFalse(yield* webhook.recordDelivery("board-retry" as never, "delivery-2")); + assert.isFalse(yield* webhook.recordDelivery("board-other" as never, "delivery-1")); + yield* webhook.releaseDelivery("board-retry" as never, "delivery-1"); + assert.isTrue(yield* webhook.recordDelivery("board-retry" as never, "delivery-2")); + assert.isTrue(yield* webhook.recordDelivery("board-other" 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..09b5d92779d --- /dev/null +++ b/apps/server/src/workflow/Layers/WorkflowWebhook.ts @@ -0,0 +1,112 @@ +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 releaseDelivery: WorkflowWebhookShape["releaseDelivery"] = (boardId, deliveryId) => + wrap(sql` + DELETE FROM workflow_webhook_delivery + WHERE board_id = ${boardId} AND delivery_id = ${deliveryId} + `).pipe(Effect.asVoid); + + 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, + releaseDelivery, + 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/GitHubPort.ts b/apps/server/src/workflow/Services/GitHubPort.ts new file mode 100644 index 00000000000..95cd95999cd --- /dev/null +++ b/apps/server/src/workflow/Services/GitHubPort.ts @@ -0,0 +1,70 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface GitHubPrDetail { + readonly number: number; + readonly url: string; + readonly state: "open" | "merged" | "closed"; + readonly headSha: string | null; + readonly reviewDecision: "none" | "changes_requested" | "approved"; + readonly ciState: "pending" | "success" | "failure"; +} + +export interface GitHubReviewItem { + readonly id: string; + readonly author: string; + readonly body: string; + readonly submittedAt: string; +} + +export interface GitHubPortShape { + readonly preflight: ( + cwd: string, + ) => Effect.Effect<{ ok: true } | { ok: false; reason: string }, WorkflowEventStoreError>; + readonly resolveRemote: ( + cwd: string, + ) => Effect.Effect<{ remoteName: string; repo: string }, WorkflowEventStoreError>; + readonly defaultBranch: (cwd: string) => Effect.Effect; + readonly openPr: (input: { + readonly cwd: string; + readonly branch: string; + readonly base: string; + readonly title: string; + readonly body: string; + readonly draft: boolean; + }) => Effect.Effect<{ number: number; url: string; adopted: boolean }, WorkflowEventStoreError>; + readonly prDetail: (input: { + readonly cwd: string; + readonly prNumber: number; + }) => Effect.Effect; + // Read-only: find an existing open PR for a branch WITHOUT pushing or + // creating one. Used by recovery to adopt a PR that was created before the + // crash but never recorded via TicketPrOpened. + readonly findPrForBranch: (input: { + readonly cwd: string; + readonly branch: string; + }) => Effect.Effect<{ number: number; url: string } | null, WorkflowEventStoreError>; + readonly mergePr: (input: { + readonly cwd: string; + readonly prNumber: number; + readonly strategy: "squash" | "merge" | "rebase"; + readonly deleteBranch: boolean; + readonly branch: string; + readonly remoteName: string; + }) => Effect.Effect<{ ok: true } | { ok: false; reason: string }, WorkflowEventStoreError>; + readonly failingCheckLogs: (input: { + readonly cwd: string; + readonly prNumber: number; + }) => Effect.Effect; + readonly listReviewFeedback: (input: { + readonly cwd: string; + readonly prNumber: number; + readonly repo: string; + }) => Effect.Effect, WorkflowEventStoreError>; +} + +export class GitHubPort extends Context.Service()( + "t3/workflow/Services/GitHubPort", +) {} 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..dd76041c68e --- /dev/null +++ b/apps/server/src/workflow/Services/SetupRunService.ts @@ -0,0 +1,45 @@ +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 threadId: string; readonly terminalId: string | null }, WorkflowEventStoreError>; + readonly awaitExit: (input: { + readonly threadId: string; + 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/TicketPullRequestService.ts b/apps/server/src/workflow/Services/TicketPullRequestService.ts new file mode 100644 index 00000000000..afe037d88d3 --- /dev/null +++ b/apps/server/src/workflow/Services/TicketPullRequestService.ts @@ -0,0 +1,24 @@ +import type { PullRequestStep, StepOutcome, 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 TicketPullRequestInput { + readonly ticketId: TicketId; + readonly stepRunId: StepRunId; + readonly repoRoot: string; + readonly worktreePath: string; + readonly worktreeRef: string; // the per-ticket branch name, e.g. "workflow/" + readonly step: PullRequestStep; +} + +export interface TicketPullRequestServiceShape { + readonly open: (input: TicketPullRequestInput) => Effect.Effect; + readonly land: (input: TicketPullRequestInput) => Effect.Effect; +} + +export class TicketPullRequestService extends Context.Service< + TicketPullRequestService, + TicketPullRequestServiceShape +>()("t3/workflow/Services/TicketPullRequestService") {} 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/WorkSourceConnectionStore.ts b/apps/server/src/workflow/Services/WorkSourceConnectionStore.ts new file mode 100644 index 00000000000..8c9c44467ba --- /dev/null +++ b/apps/server/src/workflow/Services/WorkSourceConnectionStore.ts @@ -0,0 +1,50 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Data from "effect/Data"; +import type { WorkSourceConnectionView } from "@t3tools/contracts/workSource"; +import type { WorkSourceProviderName } from "@t3tools/contracts/workSource"; +import type { WorkSourceAuthError } from "./WorkSourceProvider.ts"; + +export class WorkSourceConnectionStoreError extends Data.TaggedError( + "WorkSourceConnectionStoreError", +)<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface WorkSourceConnectionStoreShape { + /** Retrieve the PAT for an existing connection. Fails with WorkSourceAuthError + * when no row matches the connectionRef OR when the row's provider does not + * equal `expectedProvider` (a source must not use another provider's token). */ + readonly getToken: ( + connectionRef: string, + expectedProvider: WorkSourceProviderName, + ) => Effect.Effect; + + /** Create a new connection, storing the PAT in the secret store. */ + readonly create: (input: { + readonly provider: WorkSourceProviderName; + readonly displayName: string; + readonly token: string; + }) => Effect.Effect; + + /** List all connections (no token in the view). */ + readonly list: () => Effect.Effect< + ReadonlyArray, + WorkSourceConnectionStoreError + >; + + /** Remove a connection + delete the stored secret. + * + * v1 note: does NOT check for boards still referencing this connectionRef. + * A dangling connectionRef in a board source will surface as a WorkSourceAuthError + * at sync time (getToken fails → provider backs off). The syncer handles this + * gracefully — that source enters backoff and no tickets are affected. + */ + readonly remove: (connectionRef: string) => Effect.Effect; +} + +export class WorkSourceConnectionStore extends Context.Service< + WorkSourceConnectionStore, + WorkSourceConnectionStoreShape +>()("t3/workflow/Services/WorkSourceConnectionStore") {} diff --git a/apps/server/src/workflow/Services/WorkSourceProvider.ts b/apps/server/src/workflow/Services/WorkSourceProvider.ts new file mode 100644 index 00000000000..529d73cf44e --- /dev/null +++ b/apps/server/src/workflow/Services/WorkSourceProvider.ts @@ -0,0 +1,92 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import type { WorkSourceProviderName } from "@t3tools/contracts/workSource"; + +export class WorkSourceAuthError extends Schema.TaggedErrorClass()( + "WorkSourceAuthError", + { connectionRef: Schema.String }, +) {} + +export class WorkSourceRateLimitError extends Schema.TaggedErrorClass()( + "WorkSourceRateLimitError", + { retryAfterMs: Schema.Number }, +) {} + +export class WorkSourceTransientError extends Schema.TaggedErrorClass()( + "WorkSourceTransientError", + { message: Schema.String }, +) {} + +export class WorkSourceConfigError extends Schema.TaggedErrorClass()( + "WorkSourceConfigError", + { message: Schema.String }, +) {} + +export type WorkSourceProviderError = + | WorkSourceAuthError + | WorkSourceRateLimitError + | WorkSourceTransientError + | WorkSourceConfigError; + +export interface ExternalWorkItem { + readonly provider: WorkSourceProviderName; + readonly externalId: string; + readonly url: string; + readonly lifecycle: "open" | "closed" | "deleted"; + readonly version: { readonly updatedAt?: string; readonly etag?: string }; + readonly fields: { + readonly title: string; + readonly description?: string; + readonly assignees?: ReadonlyArray; + readonly labels?: ReadonlyArray; + }; +} + +export interface WorkSourcePage { + readonly items: ReadonlyArray; + readonly nextPageToken?: string; // present => more pages remain +} + +export interface WorkSourceProvider { + readonly provider: WorkSourceProviderName; + readonly selectorSchema: Schema.Schema; // PURE; used by synchronous lint + readonly listPage: (input: { + readonly connectionRef: string; + readonly selector: unknown; + readonly since?: string; + readonly pageToken?: string; + readonly pageSize: number; + }) => Effect.Effect; + readonly getItem: (input: { + readonly connectionRef: string; + // The source's selector (provider-specific). GitHub needs owner/repo from + // it to issue the single-issue lookup; Asana's gid is global so it ignores + // this. A `null` result means the provider CONFIRMS deletion (404/gone); a + // typed failure means "could not confirm" and must NOT be read as deleted. + readonly selector: unknown; + readonly externalId: string; + }) => Effect.Effect; + // writeBack?: FUTURE two-way seam — intentionally omitted in v1. +} + +export interface WorkSourceProviderRegistryShape { + readonly get: (provider: WorkSourceProviderName) => WorkSourceProvider; +} + +export class WorkSourceProviderRegistry extends Context.Service< + WorkSourceProviderRegistry, + WorkSourceProviderRegistryShape +>()("t3/workflow/Services/WorkSourceProvider/WorkSourceProviderRegistry") {} + +// Placeholder Context.Service tags for the two concrete providers. +// Tasks 4 and 5 provide Layer implementations for these exact tags. +export class GithubIssuesProvider extends Context.Service< + GithubIssuesProvider, + WorkSourceProvider +>()("t3/workflow/Services/WorkSourceProvider/GithubIssuesProvider") {} + +export class AsanaProvider extends Context.Service< + AsanaProvider, + WorkSourceProvider +>()("t3/workflow/Services/WorkSourceProvider/AsanaProvider") {} 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/WorkflowBoardNotificationDispatcher.ts b/apps/server/src/workflow/Services/WorkflowBoardNotificationDispatcher.ts new file mode 100644 index 00000000000..849bd5143d9 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowBoardNotificationDispatcher.ts @@ -0,0 +1,20 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +export interface WorkflowBoardNotificationSweepResult { + readonly claimed: number; + readonly sent: number; + readonly superseded: number; + readonly failed: number; +} + +export interface WorkflowBoardNotificationDispatcherShape { + readonly sweep: () => Effect.Effect; + readonly start: () => Effect.Effect; +} + +export class WorkflowBoardNotificationDispatcher extends Context.Service< + WorkflowBoardNotificationDispatcher, + WorkflowBoardNotificationDispatcherShape +>()("t3/workflow/Services/WorkflowBoardNotificationDispatcher") {} diff --git a/apps/server/src/workflow/Services/WorkflowBoardNotificationRelay.ts b/apps/server/src/workflow/Services/WorkflowBoardNotificationRelay.ts new file mode 100644 index 00000000000..faaf5ca60f7 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowBoardNotificationRelay.ts @@ -0,0 +1,20 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayBoardTicketState } from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +export interface WorkflowBoardNotificationRelayShape { + readonly publishTicket: (input: { + readonly environmentId: EnvironmentId; + readonly boardId: string; + readonly ticketId: string; + readonly state: RelayBoardTicketState; + }) => Effect.Effect; +} + +export class WorkflowBoardNotificationRelay extends Context.Service< + WorkflowBoardNotificationRelay, + WorkflowBoardNotificationRelayShape +>()("t3/workflow/Services/WorkflowBoardNotificationRelay") {} 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..7b940992af7 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowEngine.ts @@ -0,0 +1,139 @@ +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; + // Committer-facing UNLOCKED ops for the work-source syncer (Task 9). The CALLER + // MUST already hold the board save lock for the affected board AND be inside an + // open `sql.withTransaction`; these never acquire the save lock, never open a + // transaction, and never take the admission lock. Driving them is how a batch + // syncer creates/closes/edits tickets under ONE lock + ONE transaction per + // chunk without deadlocking the non-reentrant save lock. + readonly createTicketAndEnterUnlocked: (input: { + readonly boardId: BoardId; + readonly title: string; + readonly description?: string; + readonly destinationLane: LaneKey; + }) => Effect.Effect< + { readonly ticketId: TicketId; readonly outcome: "moved" | "queued" | "none" }, + WorkflowEventStoreError + >; + readonly closeTicketFromSourceUnlocked: ( + ticketId: TicketId, + closedLane: LaneKey, + ) => Effect.Effect; + // Snapshot the ticket's cancellable provider turns (pending/started dispatch + // outbox rows). The source committer captures this INSIDE the chunk tx, BEFORE + // closeTicketFromSourceUnlocked tombstones those rows, then replays it through + // supersedeProviderWorkForTicket AFTER the tx commits. + readonly cancellableProviderTurnsForTicket: ( + ticketId: TicketId, + ) => Effect.Effect< + ReadonlyArray<{ readonly threadId: ThreadId; readonly turnId: TurnId | null }>, + WorkflowEventStoreError + >; + // POST-TX provider cancellation for a source-closed ticket: interrupt the + // running pipeline fiber + cancel the captured provider turns. NO DB writes + // (the in-tx close already tombstoned the outbox). Idempotent. The committer + // calls this after the chunk transaction commits so no provider/fiber IO runs + // inside the transaction. + readonly supersedeProviderWorkForTicket: ( + ticketId: TicketId, + turns: ReadonlyArray<{ readonly threadId: ThreadId; readonly turnId: TurnId | null }>, + ) => Effect.Effect; + readonly editTicketFieldsUnlocked: ( + ticketId: TicketId, + fields: { readonly title?: string | undefined; readonly description?: string | undefined }, + ) => Effect.Effect; + // Acquire the per-board admission semaphore (the WIP read-decide serializer). + // The source committer MUST wrap its chunk in this (OUTER) -> the board save + // lock (INNER) -> the transaction, matching the public enterLane lock order + // (admission->save), so sync admits serialize against concurrent user moves + // and cannot violate a WIP limit. The unlocked enterLane cores assume this is + // already held. + readonly withBoardAdmissionLock: ( + boardId: BoardId, + effect: Effect.Effect, + ) => 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..addcaeca113 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowEventCommitter.ts @@ -0,0 +1,37 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; +import type { PersistedWorkflowEvent, WorkflowEventInput } from "./WorkflowEventStore.ts"; + +export interface WorkflowEventCommitterShape { + readonly commit: (event: WorkflowEventInput) => Effect.Effect; + readonly commitMany: ( + events: ReadonlyArray, + ) => Effect.Effect; + // Lock-free append+project core. CALLER MUST already hold the board save lock + // for every affected board AND be inside an open `sql.withTransaction`. Unlike + // commit/commitMany this neither acquires the save lock nor opens a + // transaction (it would deadlock the non-reentrant lock / nest the tx), and it + // does NOT publish ticket views — the caller is responsible for the post-lock + // recheck, publish, and pipeline starts. Used by batch syncers (Task 9) that + // open one lock + one transaction per chunk and then call engine unlocked ops. + readonly appendManyUnlocked: ( + events: ReadonlyArray, + ) => Effect.Effect, WorkflowEventStoreError>; + // Publish a live ticket view to WorkflowBoardEvents for a ticket id, mirroring + // the post-lock publish commit/commitMany perform. Batch syncers that drive + // appendManyUnlocked (which does NOT publish) call this AFTER releasing the + // lock/tx so synced creates/edits/closes reach the live board stream. + // `republishDependents` republishes the ticket's dependents too, matching + // publishTicket's behavior on a terminal/lane move. + readonly publishTicketView: ( + ticketId: PersistedWorkflowEvent["ticketId"], + options?: { readonly republishDependents?: boolean }, + ) => 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/WorkflowGitHubPoller.ts b/apps/server/src/workflow/Services/WorkflowGitHubPoller.ts new file mode 100644 index 00000000000..d56416267c7 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowGitHubPoller.ts @@ -0,0 +1,24 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +export interface WorkflowGitHubPollerSweepResult { + // Watched tickets observed this sweep (pr_state='open', non-terminal). + readonly observedTickets: number; + // New observation rows inserted (deduped by dedup_key). + readonly recordedObservations: number; + // Pending observations marked 'applied' in phase 2. + readonly appliedObservations: number; + // Watched tickets whose gh observation failed (logged + skipped). + readonly failedTickets: number; +} + +export interface WorkflowGitHubPollerShape { + readonly sweep: () => Effect.Effect; + readonly start: () => Effect.Effect; +} + +export class WorkflowGitHubPoller extends Context.Service< + WorkflowGitHubPoller, + WorkflowGitHubPollerShape +>()("t3/workflow/Services/WorkflowGitHubPoller") {} diff --git a/apps/server/src/workflow/Services/WorkflowIds.ts b/apps/server/src/workflow/Services/WorkflowIds.ts new file mode 100644 index 00000000000..14acad2a701 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowIds.ts @@ -0,0 +1,28 @@ +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; + // Opaque unique id for a work_source_mapping row (Task 9 committer). Not a + // branded contract type — the mapping_id column is a plain TEXT primary key. + readonly mappingId: () => 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..a02395defb6 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowReadModel.ts @@ -0,0 +1,312 @@ +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 TicketPrView { + readonly number: number; + readonly url: string; + readonly state: "open" | "merged" | "closed"; + readonly ciState?: "pending" | "success" | "failure"; +} + +// One actionable lane transition offered to a human, mirroring the +// WorkflowLaneAction shape ({ label, to, hint? }) from the board definition. +export interface WorkflowLaneActionRow { + readonly label: string; + readonly to: string; + readonly hint?: string; +} + +// The ticket's current lane resolved from the board definition — name and the +// human-facing actions available from here. Falls back to key-only when the +// board definition is not registered. +export interface WorkflowCurrentLaneRow { + readonly key: string; + readonly name: string; + readonly actions: ReadonlyArray; +} + +// "waiting_for_approval" | "waiting_for_input" | "blocked" — kept loose here so +// the read model does not depend on the contracts enum; the RPC layer narrows. +export type TicketAttentionKind = string; + +export interface TicketPrStateRow { + readonly prNumber: number; + readonly prUrl: string; + readonly branch: string; + readonly remoteName: string; + readonly repo: string; + readonly prState: string; + readonly lastHeadSha: string | null; + readonly lastCiState: string | null; + readonly lastReviewDecision: string | null; + readonly lastCommentCursor: string | null; +} + +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; + // PR view — present when a workflow_pr_state row exists for this ticket. + readonly pr?: TicketPrView; + // Attention fields — projected onto the ticket; null when the ticket is not + // in a needs-you state. + readonly attentionKind?: TicketAttentionKind | null; + readonly attentionReason?: string | null; + // Current lane detail (key/name/actions) — present on detail reads, resolved + // from the board definition. + readonly currentLane?: WorkflowCurrentLaneRow; +} + +// A ticket awaiting human attention across the boards in this environment's DB, +// joined with its board name. Mirrors WorkflowNeedsAttentionTicketView (T1). +export interface WorkflowNeedsAttentionTicketRow { + readonly ticketId: string; + readonly boardId: string; + readonly boardName: string; + readonly title: string; + readonly status: string; + readonly currentLaneKey: string; + readonly attentionKind: TicketAttentionKind | null; + readonly attentionReason: string | 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" + | "work_source"; + 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; + readonly syncedSource?: { + readonly provider: "github" | "asana"; + readonly url: string; + readonly assignees?: ReadonlyArray; + readonly labels?: 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; + // Every ticket awaiting human attention (waiting_on_user / blocked) across + // the boards in this DB, joined with board name, oldest-touched first. The WS + // connection is environment-scoped, so no environment filter is needed. + readonly listNeedsAttentionTickets: () => Effect.Effect< + ReadonlyArray, + WorkflowEventStoreError + >; + // 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>; + // Full workflow_pr_state row for a ticket, or null when no PR has been opened. + readonly getTicketPrState: ( + ticketId: TicketId, + ) => Effect.Effect; +} + +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/WorkflowSourceCommitter.ts b/apps/server/src/workflow/Services/WorkflowSourceCommitter.ts new file mode 100644 index 00000000000..0744454912e --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowSourceCommitter.ts @@ -0,0 +1,91 @@ +import type { BoardId, LaneKey } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { WorkflowEventStoreError } from "./Errors.ts"; + +// Read-only display metadata captured from the external provider item. Serialized +// into `work_source_mapping.source_metadata_json` and surfaced by Task 13. +export interface SourceItemMetadata { + readonly provider: string; + readonly url?: string | undefined; + readonly assignees?: ReadonlyArray | undefined; + readonly labels?: ReadonlyArray | undefined; + readonly lifecycle?: string | undefined; +} + +// The external item fields a reconcile delta carries. These are the +// provider-derived values the committer writes to the ticket + mapping row. +export interface SourceItemFields { + readonly sourceId: string; + readonly provider: string; + readonly externalId: string; + readonly title: string; + readonly description?: string | undefined; + // Stable hash of the upstream content. The change/close gate compares this + // against the stored mapping.content_hash so a re-run with no upstream change + // writes nothing (idempotency). + readonly contentHash: string; + readonly providerVersion?: string | undefined; + readonly metadata: SourceItemMetadata; +} + +// A single per-item reconcile decision computed by the Task 10 diff (OUTSIDE the +// lock). The committer re-validates each one in-tx before applying it. +// +// - `new`: unmapped upstream item → create ticket + mapping. +// - `changed`: mapped item whose content may differ → version-gated edit. +// - `closed`: mapped item the provider reports terminal → source-aware close. +// - `missing`: mapped item not seen in a COMPLETE scan → mark orphaned; if the +// syncer (Task 11) confirmed deletion via a provider getItem call +// (network OUT of this tx) it sets `confirmedDeleted` so the +// committer also terminal-routes the ticket. +export type SourceDelta = + | { + readonly _tag: "new"; + readonly item: SourceItemFields; + } + | { + readonly _tag: "changed"; + readonly item: SourceItemFields; + // The mapping row as seen by the out-of-lock diff. The committer re-reads + // by the unique key in-tx and uses the fresh row for the version gate. + readonly ticketId: string; + } + | { + readonly _tag: "closed"; + readonly item: SourceItemFields; + readonly ticketId: string; + } + | { + readonly _tag: "missing"; + readonly item: SourceItemFields; + readonly ticketId: string; + // Set true by the syncer only after a provider getItem confirms the item + // is genuinely gone (404/closed), authorizing a terminal route here. + readonly confirmedDeleted?: boolean | undefined; + }; + +export interface ReconcileLanes { + // Lane new tickets are admitted into. + readonly destinationLane: LaneKey; + // Terminal lane a source-driven close routes into. + readonly closedLane: LaneKey; +} + +export interface WorkflowSourceCommitterShape { + // Apply a per-board batch ("chunk") of reconcile deltas to tickets + the + // work_source_mapping table under admission(OUTER) -> save(INNER) -> + // transaction (innermost), then trigger the board's auto-lane pipeline starts + // AFTER the transaction commits. Idempotent. No network here. + readonly reconcileChunk: ( + boardId: BoardId, + lanes: ReconcileLanes, + deltas: ReadonlyArray, + ) => Effect.Effect; +} + +export class WorkflowSourceCommitter extends Context.Service< + WorkflowSourceCommitter, + WorkflowSourceCommitterShape +>()("t3/workflow/Services/WorkflowSourceCommitter") {} diff --git a/apps/server/src/workflow/Services/WorkflowSourceSyncer.ts b/apps/server/src/workflow/Services/WorkflowSourceSyncer.ts new file mode 100644 index 00000000000..b6bb64d5bc6 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowSourceSyncer.ts @@ -0,0 +1,22 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Scope from "effect/Scope"; + +// The syncer fiber: a background loop that, each tick ("sweep"), pulls every +// registered board's enabled work sources, diffs them against the stored +// mappings, and drives the committer to admit/edit/close/orphan tickets. +// +// - `sweep` runs ONE full pass over all boards/sources and returns when done. +// Per-source failures are isolated (one source failing never aborts the +// sweep) and recorded as backoff in `work_source_state`. +// - `start()` forks the sweep loop on a fixed schedule under the current scope +// (mirrors WorkflowGitHubPoller). Wiring is Task 16; here we only expose it. +export interface WorkflowSourceSyncerShape { + readonly sweep: Effect.Effect; + readonly start: () => Effect.Effect; +} + +export class WorkflowSourceSyncer extends Context.Service< + WorkflowSourceSyncer, + WorkflowSourceSyncerShape +>()("t3/workflow/Services/WorkflowSourceSyncer") {} 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..616f3681c94 --- /dev/null +++ b/apps/server/src/workflow/Services/WorkflowWebhook.ts @@ -0,0 +1,61 @@ +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; + /** + * Forgets a recorded delivery after a failed ingest so the sender's retry + * is processed instead of being answered "duplicate". + */ + readonly releaseDelivery: ( + 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..5dfb5901e34 --- /dev/null +++ b/apps/server/src/workflow/WorkflowEngineLive.ts @@ -0,0 +1,23 @@ +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), + // BoardRegistry is also re-exported by WorkflowFoundationLive below; same module export → Effect memoizes one instance. + 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..a06fdaa1807 --- /dev/null +++ b/apps/server/src/workflow/WorkflowFoundationLive.test.ts @@ -0,0 +1,74 @@ +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 { BoardRegistry } from "./Services/BoardRegistry.ts"; +import { WorkflowEventStore } from "./Services/WorkflowEventStore.ts"; +import { WorkflowProjectionPipeline } from "./Services/WorkflowProjectionPipeline.ts"; +import { WorkflowReadModel } from "./Services/WorkflowReadModel.ts"; +import { WorkflowFoundationLive } from "./WorkflowFoundationLive.ts"; + +// WorkflowFoundationLive already provides AND re-exports BoardRegistry (the read +// model depends on it), so the foundation stack alone satisfies BoardRegistry. +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); + }), + ); + + it.effect("read model resolves lane actions from the same registry boards register into", () => + Effect.gen(function* () { + const registry = yield* BoardRegistry; + const pipeline = yield* WorkflowProjectionPipeline; + const read = yield* WorkflowReadModel; + + // Register the definition via BoardRegistry, then read it back through the + // read model — if these were separate registry instances, getTicketDetail + // would see no definition and fall back to an empty actions array. + yield* registry.register("b-shared-registry" as never, { + name: "Shared registry board", + lanes: [ + { + key: "review", + name: "Review", + entry: "manual", + actions: [{ label: "Approve", to: "done", hint: "Ship it" }], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ], + }); + yield* pipeline.projectEvent({ + type: "TicketCreated", + eventId: "shared-registry-a" as never, + ticketId: "t-shared-registry" as never, + streamVersion: 0, + occurredAt: "2026-06-08T00:00:00.000Z" as never, + payload: { + boardId: "b-shared-registry" as never, + title: "Shared" as never, + laneKey: "review" as never, + }, + }); + + const detail = yield* read.getTicketDetail("t-shared-registry" as never); + assert.deepEqual(detail?.ticket.currentLane, { + key: "review", + name: "Review", + actions: [{ label: "Approve", to: "done", hint: "Ship it" }], + }); + }), + ); +}); diff --git a/apps/server/src/workflow/WorkflowFoundationLive.ts b/apps/server/src/workflow/WorkflowFoundationLive.ts new file mode 100644 index 00000000000..97d60f154f4 --- /dev/null +++ b/apps/server/src/workflow/WorkflowFoundationLive.ts @@ -0,0 +1,20 @@ +import * as Layer from "effect/Layer"; + +import { BoardRegistryLive } from "./Layers/BoardRegistry.ts"; +import { WorkflowEventStoreLive } from "./Layers/WorkflowEventStore.ts"; +import { WorkflowBoardVersionStoreLive } from "./Layers/WorkflowBoardVersionStore.ts"; +import { WorkflowProjectionPipelineLive } from "./Layers/WorkflowProjectionPipeline.ts"; +import { WorkflowReadModelLive } from "./Layers/WorkflowReadModel.ts"; + +// WorkflowReadModelLive resolves current-lane actions from board definitions, +// so it requires BoardRegistry. We provideMerge BoardRegistryLive here: this +// both satisfies the read model's requirement and re-exports BoardRegistry as +// part of the foundation, so the registry boards are registered into is the +// same instance the read model reads. Effect memoizes layers by reference, so +// consumers that also reference BoardRegistryLive share this one instance. +export const WorkflowFoundationLive = Layer.mergeAll( + WorkflowEventStoreLive, + WorkflowBoardVersionStoreLive, + WorkflowProjectionPipelineLive, + WorkflowReadModelLive, +).pipe(Layer.provideMerge(BoardRegistryLive)); diff --git a/apps/server/src/workflow/WorkflowRuntimeLive.ts b/apps/server/src/workflow/WorkflowRuntimeLive.ts new file mode 100644 index 00000000000..d530b7b1279 --- /dev/null +++ b/apps/server/src/workflow/WorkflowRuntimeLive.ts @@ -0,0 +1,186 @@ +import * as Layer from "effect/Layer"; +import { FetchHttpClient } from "effect/unstable/http"; + +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 { GitHubPortLive } from "./Layers/GitHubPort.ts"; +import { TicketPullRequestServiceLive } from "./Layers/TicketPullRequestService.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 { WorkflowBoardNotificationDispatcherLive } from "./Layers/WorkflowBoardNotificationDispatcher.ts"; +import { WorkflowBoardNotificationRelayLive } from "./Layers/WorkflowBoardNotificationRelay.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 { WorkflowGitHubPollerLive } from "./Layers/WorkflowGitHubPoller.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 { AsanaProviderLive } from "./Layers/AsanaProvider.ts"; +import { GithubIssuesProviderLive } from "./Layers/GithubIssuesProvider.ts"; +import { WorkSourceConnectionStoreLive } from "./Layers/WorkSourceConnectionStore.ts"; +import { WorkSourceProviderRegistryLive } from "./Layers/WorkSourceProviderRegistry.ts"; +import { WorkflowSourceCommitterLive } from "./Layers/WorkflowSourceCommitter.ts"; +import { WorkflowSourceSyncerLive } from "./Layers/WorkflowSourceSyncer.ts"; +import { WorktreeLeaseServiceLive } from "./Layers/WorktreeLeaseService.ts"; +import { WorkflowFoundationLive } from "./WorkflowFoundationLive.ts"; + +// PR steps run through the GitHub port. GitHubPortLive leaks GitHubCli + +// SourceControlProviderRegistry as runtime requirements (mirrors how +// MergeGitPort/WorktreePort leak the git driver stack), satisfied by the +// server's source-control wiring. +const StepExecutionLive = RealStepExecutorLive.pipe( + Layer.provideMerge(TicketMergeServiceLive), + Layer.provideMerge(TicketPullRequestServiceLive), + Layer.provideMerge(GitHubPortLive), +); + +const WorkflowRuntimeCoreBaseLive = Layer.mergeAll( + WorkflowEngineLayer, + WorkflowRecoveryLive.pipe(Layer.provideMerge(WorkflowEngineLayer)), + WorkflowTerminalRetentionSweeperLive.pipe(Layer.provideMerge(WorkflowEngineLayer)), + WorkflowGitHubPollerLive.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), + // BoardRegistry is also re-exported by WorkflowFoundationLive below; same module export → Effect memoizes one instance. + 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( + // BoardRegistry is also re-exported by WorkflowFoundationLive below; same module export → Effect memoizes one instance. + Layer.provideMerge(BoardRegistryLive), + Layer.provideMerge(WorkflowBoardEventsLive), + Layer.provideMerge(WorkflowFoundationLive), + Layer.provideMerge(WorkflowFilePortLive), + Layer.provideMerge(WorkflowProviderInstancePortLive), + Layer.provideMerge(WorktreeDiffPortLive), +); + +// Board push-notification dispatcher + relay. The relay (HTTP publish client) +// depends on ServerSecretStore + ServerEnvironment + Crypto; the dispatcher +// depends on the relay + WorkflowReadModel + ServerEnvironment + SqlClient. +// WorkflowReadModel is provided inside WorkflowRuntimeLive (via +// WorkflowFoundationLive), so we provideMerge the dispatcher (with the relay +// merged in) over the runtime here; the remaining server-level requirements +// (ServerSecretStore, ServerEnvironment, Crypto, SqlClient) propagate upward +// and are satisfied by the server runtime composition in server.ts. +const WorkflowBoardNotificationLive = WorkflowBoardNotificationDispatcherLive.pipe( + Layer.provideMerge(WorkflowBoardNotificationRelayLive), +); + +// Work-source sync stack ("work arrives by itself"). +// +// The two HTTP providers (GitHub Issues, Asana) require an HttpClient and the +// WorkSourceConnectionStore. We provide FetchHttpClient + the connection store +// to the provider registry so the registry's two provider tags resolve, then +// merge the connection store back out (it is also consumed by the ws.ts +// connection RPCs, so it must be visible at the runtime surface). The committer +// and syncer depend on engine/board-registry/save-locks/sql/ids — all provided +// by WorkflowRuntimeLive below — so those requirements propagate upward and are +// satisfied where WorkSourceLive is merged into the server runtime. Remaining +// server-level deps (SqlClient, ServerSecretStore) propagate to server.ts, the +// same way the notification dispatcher/relay deps do. +const WorkSourceProviderStackLive = WorkSourceProviderRegistryLive.pipe( + Layer.provide(GithubIssuesProviderLive), + Layer.provide(AsanaProviderLive), + Layer.provide(FetchHttpClient.layer), +); + +const WorkSourceLive = WorkflowSourceSyncerLive.pipe( + // The syncer requires the committer + provider registry; provide (and + // re-export, so both stay visible at the runtime surface) via provideMerge. + Layer.provideMerge(WorkflowSourceCommitterLive), + Layer.provideMerge(WorkSourceProviderStackLive), + Layer.provideMerge(WorkSourceConnectionStoreLive), +); + +export const WorkflowServerRuntimeLive = WorkflowIntakeLive.pipe( + Layer.provideMerge(WorkflowRpcSupportLive), + Layer.provideMerge(WorkflowBoardNotificationLive), + Layer.provideMerge(WorkSourceLive), + 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..b72730e2a3e --- /dev/null +++ b/apps/server/src/workflow/boardDeletion.test.ts @@ -0,0 +1,347 @@ +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 { BoardRegistryLive } from "./Layers/BoardRegistry.ts"; +import { WorkflowEventStoreLive } from "./Layers/WorkflowEventStore.ts"; +import { WorkflowReadModelLive } from "./Layers/WorkflowReadModel.ts"; + +const deletionLayer = Layer.mergeAll(WorkflowEventStoreLive, WorkflowReadModelLive).pipe( + Layer.provideMerge(BoardRegistryLive), + 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* sql` + INSERT INTO workflow_pr_state ( + ticket_id, pr_number, pr_url, branch, remote_name, repo, pr_state, updated_at + ) + VALUES ( + ${ticketId}, 1, ${`https://github.com/owner/repo/pull/1`}, + 'ft/branch', 'origin', 'owner/repo', 'open', ${now} + ) + `; + yield* sql` + INSERT INTO workflow_pr_observation ( + observation_id, ticket_id, dedup_key, event_name, payload_json, status, created_at + ) + VALUES ( + ${`obs-${ticketId}`}, ${ticketId}, ${`dedup-${ticketId}`}, + 'ci_check', '{}', 'pending', ${now} + ) + `; + // sequence is UNIQUE across the table; derive a stable per-ticket integer. + const outboxSequence = Array.from(ticketId).reduce( + (acc, char) => acc + char.charCodeAt(0), + 0, + ); + yield* sql` + INSERT INTO workflow_notification_outbox ( + outbox_id, ticket_id, board_id, sequence, status, created_at + ) + VALUES ( + ${`outbox-${ticketId}`}, ${ticketId}, 'board-ticket-cascade', + ${outboxSequence}, 'waiting_on_user', ${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} + UNION ALL SELECT COUNT(*) AS count FROM workflow_pr_state WHERE ticket_id = ${ticketId} + UNION ALL SELECT COUNT(*) AS count FROM workflow_pr_observation 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), 10); + }).pipe(Effect.provide(deletionLayer)), +); + +it.effect( + "board deletion cascades into workflow_pr_state and workflow_pr_observation, and workflow_notification_outbox", + () => + Effect.gen(function* () { + const eventStore = yield* WorkflowEventStore; + const readModel = yield* WorkflowReadModel; + const sql = yield* SqlClient.SqlClient; + const ticketId = "ticket-pr-cascade"; + + yield* seedTicketOwnedRows(ticketId); + + yield* deleteWorkflowBoardTicketOwnedState( + { + saveLocks: { + withSaveLock: (_boardId, effect) => effect, + }, + engine: { + cancelTicketPipelines: () => Effect.void, + }, + eventStore, + readModel, + sql, + }, + "board-ticket-cascade" as never, + ticketId as never, + ); + + const prStateCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_pr_state WHERE ticket_id = ${ticketId} + `; + const prObsCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_pr_observation WHERE ticket_id = ${ticketId} + `; + const outboxCount = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM workflow_notification_outbox WHERE ticket_id = ${ticketId} + `; + assert.equal(prStateCount[0]?.count, 0); + assert.equal(prObsCount[0]?.count, 0); + assert.equal(outboxCount[0]?.count, 0); + }).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/redactSensitiveText.test.ts b/apps/server/src/workflow/redactSensitiveText.test.ts new file mode 100644 index 00000000000..cf1037acc7f --- /dev/null +++ b/apps/server/src/workflow/redactSensitiveText.test.ts @@ -0,0 +1,216 @@ +import { assert, describe, it } from "@effect/vitest"; +import { redactSensitiveText, truncateKeepingTail } from "./redactSensitiveText.ts"; + +describe("redactSensitiveText", () => { + describe("GitHub tokens", () => { + it("redacts ghp_ tokens", () => { + assert.equal( + redactSensitiveText("token: ghp_abcdefghijklmnopqrstu1234567890"), + "token: [redacted]", + ); + }); + + it("redacts gho_ tokens via the gh prefix pattern (not high-entropy)", () => { + // All lowercase+digits, no uppercase: HIGH_ENTROPY's uppercase lookahead + // cannot fire, so redaction here proves the gh[pousr]_ pattern matched. + assert.equal( + redactSensitiveText("auth gho_abcdefghij1234567890ab"), + "auth [redacted]", + ); + }); + + it("does not redact a too-short gho_ token", () => { + const text = "gho_abc"; + assert.equal(redactSensitiveText(text), text); + }); + + it("redacts github_pat_ tokens", () => { + assert.equal( + redactSensitiveText("github_pat_ABCDEFGHIJKLMNOPQRSTUVWXYZabc1234567890"), + "[redacted]", + ); + }); + }); + + describe("OpenAI tokens", () => { + it("redacts sk- tokens", () => { + assert.equal( + redactSensitiveText("key=sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabc123"), + "key=[redacted]", + ); + }); + }); + + describe("Bearer tokens", () => { + it("redacts Bearer header values", () => { + assert.equal( + redactSensitiveText("Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), + "Authorization: [redacted]", + ); + }); + + it("does not redact very short Bearer values", () => { + const text = "Bearer short"; + // "short" is only 5 chars, well below 16 + assert.equal(redactSensitiveText(text), text); + }); + }); + + describe("AWS keys", () => { + it("redacts AKIA keys", () => { + assert.equal( + redactSensitiveText("key: AKIAIOSFODNN7EXAMPLE"), + "key: [redacted]", + ); + }); + + it("does not redact AKIA with wrong length", () => { + // Only 15 chars after AKIA (needs 16) + const text = "AKIAIOSFODNN7EXA"; + assert.equal(redactSensitiveText(text), text); + }); + }); + + describe("NAME=value / NAME: value lines", () => { + it("redacts TOKEN= assignments", () => { + assert.equal( + redactSensitiveText("MY_TOKEN=supersecretvalue123"), + "MY_TOKEN=[redacted]", + ); + }); + + it("redacts SECRET: assignments", () => { + assert.equal( + redactSensitiveText("APP_SECRET: mysecretvalue"), + "APP_SECRET=[redacted]", + ); + }); + + it("redacts PASSWORD= assignments", () => { + assert.equal( + redactSensitiveText("DB_PASSWORD=hunter2"), + "DB_PASSWORD=[redacted]", + ); + }); + + it("redacts KEY= assignments", () => { + assert.equal( + redactSensitiveText("API_KEY=abcdef123456"), + "API_KEY=[redacted]", + ); + }); + + it("redacts CREDENTIAL= assignments", () => { + assert.equal( + redactSensitiveText("AWS_CREDENTIAL=some_cred_value"), + "AWS_CREDENTIAL=[redacted]", + ); + }); + + it("redacts case-insensitively (lowercase token)", () => { + assert.equal( + redactSensitiveText("access_token=abc123xyz"), + "access_token=[redacted]", + ); + }); + + it("handles multiline input, only redacts matching lines", () => { + const input = `HOST=localhost\nAPI_KEY=supersecret\nPORT=3000`; + const output = redactSensitiveText(input); + assert.include(output, "HOST=localhost"); + assert.include(output, "API_KEY=[redacted]"); + assert.include(output, "PORT=3000"); + }); + + it("does not redact KEYBOARD= (no matching sensitive word)", () => { + const text = "KEYBOARD=qwerty"; + assert.equal(redactSensitiveText(text), text); + }); + + it("does not trip on ordinary sentence with KEYBOARD word not followed by =", () => { + const text = "the KEYBOARD shortcut is ctrl+c"; + assert.equal(redactSensitiveText(text), text); + }); + }); + + describe("high-entropy strings", () => { + it("redacts high-entropy 32+ char strings", () => { + // 32-char string with upper, lower, digit + const secret = "aBcDeFgH1234567890ABCDEFGHIJKLMN"; + assert.equal(redactSensitiveText(secret), "[redacted]"); + }); + + it("does not redact short strings even if mixed-case", () => { + const text = "Hello World 123"; + assert.equal(redactSensitiveText(text), text); + }); + + it("does not redact long all-lowercase strings (no uppercase)", () => { + const text = "abcdefghijklmnopqrstuvwxyz1234567890abc"; + assert.equal(redactSensitiveText(text), text); + }); + + it("does not redact long all-uppercase strings (no lowercase)", () => { + const text = "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABC"; + assert.equal(redactSensitiveText(text), text); + }); + + it("does not redact long strings with no digit", () => { + const text = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijkl"; + assert.equal(redactSensitiveText(text), text); + }); + }); + + describe("leaves ordinary prose untouched", () => { + it("does not redact build failure message", () => { + const text = "the build failed at line 42"; + assert.equal(redactSensitiveText(text), text); + }); + + it("does not redact import statement", () => { + const text = "import { foo } from 'bar'"; + assert.equal(redactSensitiveText(text), text); + }); + + it("does not redact normal sentence", () => { + const text = "Error: could not find module at path /home/user/app"; + assert.equal(redactSensitiveText(text), text); + }); + }); +}); + +describe("truncateKeepingTail", () => { + it("returns text unchanged when length <= max", () => { + assert.equal(truncateKeepingTail("hello", 10), "hello"); + assert.equal(truncateKeepingTail("hello", 5), "hello"); + }); + + it("truncates with the marker INCLUDED in the budget (result length <= max)", () => { + const marker = "…[truncated]\n"; + const text = "abcdefghijklmnopqrstuvwxyz"; + const result = truncateKeepingTail(text, 20); + // The result must fit within max — the marker is part of the budget. + assert.isTrue(result.length <= 20); + assert.isTrue(result.startsWith(marker)); + // Tail is the last (max - markerLength) chars of the source. + assert.equal(result.slice(marker.length), text.slice(text.length - (20 - marker.length))); + }); + + it("never exceeds max for a large body capped at the ticket body limit", () => { + const text = "y".repeat(20_000); + const max = 8_000; + const result = truncateKeepingTail(text, max); + assert.isTrue(result.length <= max); + assert.isTrue(result.startsWith("…[truncated]\n")); + }); + + it("handles empty string", () => { + assert.equal(truncateKeepingTail("", 10), ""); + }); + + it("handles max smaller than the marker without exceeding max", () => { + // text longer than max forces truncation; max < marker length (13). + const result = truncateKeepingTail("hello world", 5); + assert.isTrue(result.length <= 5); + }); +}); diff --git a/apps/server/src/workflow/redactSensitiveText.ts b/apps/server/src/workflow/redactSensitiveText.ts new file mode 100644 index 00000000000..14b61adf706 --- /dev/null +++ b/apps/server/src/workflow/redactSensitiveText.ts @@ -0,0 +1,91 @@ +/** + * Redacts sensitive strings (tokens, secrets, high-entropy values) from text, + * and provides a tail-keeping truncation utility. + * + * Pure module — no Effect, no external dependencies. + */ + +/** + * Pattern that tests whether a variable name contains a sensitive word as a + * complete underscore-delimited segment (e.g. MY_TOKEN, API_KEY, access_token) + * but NOT as an arbitrary substring (e.g. KEYBOARD is not sensitive). + */ +const SENSITIVE_NAME = /(?:^|_)(?:TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL)(?:_|$)/i; + +/** + * Build each regex fresh per call so we never have lastIndex statefulness + * issues from module-level shared regexes used with repeated replacements. + * + * Order matters: specific token patterns run before the NAME=value sweep so + * that tokens embedded in `name: ` lines are redacted by the token + * pattern first, leaving `name: [redacted]`. The NAME=value pattern then uses + * a negative lookahead to skip lines whose value is already `[redacted]`. + */ +const buildPatterns = (): Array<(text: string) => string> => [ + // GitHub tokens: ghp_, gho_, ghu_, ghs_, ghr_ + (t) => t.replace(/gh[pousr]_[A-Za-z0-9_]{20,}/g, "[redacted]"), + // GitHub fine-grained PATs + (t) => t.replace(/github_pat_[A-Za-z0-9_]{20,}/g, "[redacted]"), + // OpenAI API keys + (t) => t.replace(/sk-[A-Za-z0-9_-]{20,}/g, "[redacted]"), + // Bearer tokens (HTTP Authorization header values, ≥16 chars) + (t) => t.replace(/\bBearer\s+[A-Za-z0-9._~+/\-]{16,}=*/g, "[redacted]"), + // AWS access key IDs (AKIA + exactly 16 uppercase alnum chars) + (t) => t.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]"), + // NAME=value or NAME: value lines where NAME contains a sensitive word as a + // complete _ segment. Only fires when the value is NOT already `[redacted]` + // (prevents double-processing lines already handled by a specific pattern). + (t) => + t.replace( + /^([A-Za-z_]+)\s*[=:]\s*(?!\[redacted\])(\S+)$/gim, + (_, name: string) => (SENSITIVE_NAME.test(name) ? `${name}=[redacted]` : _), + ), +]; + +/** + * High-entropy string pattern: ≥32 non-whitespace chars that contain at least + * one uppercase letter, one lowercase letter, and one digit. + * Applied last — already-replaced `[redacted]` markers are 10 chars and won't + * match, so there's no risk of double-processing. + * + * Known limitation: this also catches benign mixed-case ≥32-char tokens that + * commonly appear in CI logs — e.g. npm SRI integrity hashes (`sha512-...`), + * JWTs, and git/content hashes. These are reproducible and non-damaging, so + * over-redacting them is acceptable; noted here so a future reader isn't + * surprised that such values are missing from redacted PR feedback. + */ +const HIGH_ENTROPY_RE = + /\b(?=[^\s]*[A-Z])(?=[^\s]*[a-z])(?=[^\s]*\d)[A-Za-z0-9+/_=-]{32,}\b/g; + +/** + * Redacts known credential patterns and high-entropy strings from `text`. + * Returns the sanitised copy; the original is not mutated. + */ +export const redactSensitiveText = (text: string): string => { + let out = text; + for (const apply of buildPatterns()) out = apply(out); + // High-entropy sweep runs after all known patterns. + out = out.replace(HIGH_ENTROPY_RE, "[redacted]"); + return out; +}; + +const TRUNCATION_MARKER = "…[truncated]\n"; + +/** + * If `text` is longer than `max` characters, returns a string of length ≤ `max` + * that starts with the marker line "…[truncated]\n" followed by the LAST chars + * of `text`. The marker is INCLUDED in the budget, so the result never exceeds + * `max` — callers can pass a hard limit (e.g. the ticket message body cap) and + * rely on the output fitting under it. Otherwise returns `text` unchanged. + * + * When `max` is smaller than the marker itself, the marker is truncated to fit + * (degenerate but bounded) so the contract — result length ≤ max — always holds. + */ +export const truncateKeepingTail = (text: string, max: number): string => { + if (text.length <= max) return text; + if (max <= TRUNCATION_MARKER.length) { + return TRUNCATION_MARKER.slice(0, Math.max(0, max)); + } + const tailBudget = max - TRUNCATION_MARKER.length; + return `${TRUNCATION_MARKER}${text.slice(text.length - tailBudget)}`; +}; diff --git a/apps/server/src/workflow/sampleBoardFile.test.ts b/apps/server/src/workflow/sampleBoardFile.test.ts new file mode 100644 index 00000000000..2d124dec0ad --- /dev/null +++ b/apps/server/src/workflow/sampleBoardFile.test.ts @@ -0,0 +1,57 @@ +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), + [], + ); + }), + ); +}); + +it.layer(NodeServices.layer)("github-flow example board", (it) => { + it.effect("decodes and lints with no errors", () => + 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, "docs/workflow-boards/github-flow-example.json"), + ); + const definition = yield* decodeWorkflowDefinitionJson(raw); + const lintErrors = lintWorkflowDefinition(definition, { + providerInstanceExists: (instanceId) => instanceId === "codex", + instructionFileExists: () => true, + }); + + assert.equal(definition.name, "GitHub flow"); + assert.deepEqual( + lintErrors.map((error) => error.code), + [], + ); + }), + ); +}); diff --git a/apps/server/src/workflow/sourceReconcileDiff.test.ts b/apps/server/src/workflow/sourceReconcileDiff.test.ts new file mode 100644 index 00000000000..3727de82b9e --- /dev/null +++ b/apps/server/src/workflow/sourceReconcileDiff.test.ts @@ -0,0 +1,380 @@ +import { assert, describe, it } from "@effect/vitest"; + +import type { ExternalWorkItem } from "./Services/WorkSourceProvider.ts"; +import { + classifyDeltas, + hashContent, + type MappingRow, +} from "./sourceReconcileDiff.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeItem = (over: Partial = {}): ExternalWorkItem => ({ + provider: "github", + externalId: "issue-1", + url: "https://github.com/owner/repo/issues/1", + lifecycle: "open", + version: { updatedAt: "2026-01-01T00:00:00Z" }, + fields: { + title: "Fix the bug", + description: "It is broken", + assignees: ["alice"], + labels: ["bug"], + }, + ...over, +}); + +const makeMapping = (over: Partial = {}): MappingRow => ({ + externalId: "issue-1", + ticketId: "ticket-abc", + contentHash: hashContent({ title: "Fix the bug", description: "It is broken" }), + providerVersion: "2026-01-01T00:00:00Z", + lifecycle: "open", + syncStatus: "active", + ...over, +}); + +const defaultInput = { + sourceId: "src-1", + provider: "github", +} as const; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("hashContent", () => { + it("produces the same hash for identical title+description", () => { + const h1 = hashContent({ title: "Fix the bug", description: "It is broken" }); + const h2 = hashContent({ title: "Fix the bug", description: "It is broken" }); + assert.equal(h1, h2); + }); + + it("produces a different hash when title changes", () => { + const h1 = hashContent({ title: "Fix the bug", description: "It is broken" }); + const h2 = hashContent({ title: "Fix another bug", description: "It is broken" }); + assert.notEqual(h1, h2); + }); + + it("produces a different hash when description changes", () => { + const h1 = hashContent({ title: "Fix the bug", description: "It is broken" }); + const h2 = hashContent({ title: "Fix the bug", description: "It is very broken" }); + assert.notEqual(h1, h2); + }); + + it("treats absent description consistently (undefined vs omitted)", () => { + const h1 = hashContent({ title: "Title" }); + const h2 = hashContent({ title: "Title", description: undefined }); + assert.equal(h1, h2); + }); +}); + +describe("classifyDeltas", () => { + it("new: an item with no mapping row produces a new delta", () => { + const item = makeItem(); + const result = classifyDeltas({ + ...defaultInput, + items: [item], + mappings: [], + scanCompleted: true, + }); + + assert.lengthOf(result, 1); + const delta = result[0]!; + assert.equal(delta._tag, "new"); + if (delta._tag === "new") { + assert.equal(delta.item.externalId, "issue-1"); + assert.equal(delta.item.title, "Fix the bug"); + assert.equal(delta.item.description, "It is broken"); + assert.equal( + delta.item.contentHash, + hashContent({ title: "Fix the bug", description: "It is broken" }), + ); + } + }); + + it("changed: a mapped item with a different content hash produces a changed delta", () => { + const item = makeItem({ fields: { title: "Renamed issue", description: "New body" } }); + // Mapping has the OLD hash (computed from the old title/description). + const mapping = makeMapping({ + contentHash: hashContent({ title: "Fix the bug", description: "It is broken" }), + }); + + const result = classifyDeltas({ + ...defaultInput, + items: [item], + mappings: [mapping], + scanCompleted: true, + }); + + assert.lengthOf(result, 1); + const delta = result[0]!; + assert.equal(delta._tag, "changed"); + if (delta._tag === "changed") { + assert.equal(delta.ticketId, "ticket-abc"); + assert.equal(delta.item.title, "Renamed issue"); + assert.equal(delta.item.description, "New body"); + assert.equal( + delta.item.contentHash, + hashContent({ title: "Renamed issue", description: "New body" }), + ); + } + }); + + it("Fix 1: an upstream description cleared (non-empty → empty) produces a changed delta that CLEARS the description to \"\"", () => { + // Upstream item used to have a description; now it's cleared (absent → undefined + // on the ExternalWorkItem). The mapping carries the OLD hash (with the body). + const item = makeItem({ + fields: { title: "Fix the bug" }, // description omitted → cleared upstream + }); + const mapping = makeMapping({ + contentHash: hashContent({ title: "Fix the bug", description: "It is broken" }), + }); + + const result = classifyDeltas({ + ...defaultInput, + items: [item], + mappings: [mapping], + scanCompleted: true, + }); + + assert.lengthOf(result, 1); + const delta = result[0]!; + assert.equal(delta._tag, "changed"); + if (delta._tag === "changed") { + // The delta CARRIES an empty-string description (authoritative clear), + // never undefined — so the committer WRITES the clear. + assert.equal(delta.item.description, ""); + // hash is computed over the SAME normalized {title, description:""} so the + // carried value and the stored hash agree → next cycle is a no-op. + assert.equal(delta.item.contentHash, hashContent({ title: "Fix the bug", description: "" })); + } + + // Next cycle: the mapping now stores the cleared hash → no further delta. + const noop = classifyDeltas({ + ...defaultInput, + items: [item], + mappings: [makeMapping({ contentHash: hashContent({ title: "Fix the bug", description: "" }) })], + scanCompleted: true, + }); + assert.lengthOf(noop, 0); + }); + + it("no-op: a mapped item whose hash matches produces NO delta", () => { + const item = makeItem(); + // Mapping hash matches the item exactly. + const mapping = makeMapping({ + contentHash: hashContent({ title: "Fix the bug", description: "It is broken" }), + }); + + const result = classifyDeltas({ + ...defaultInput, + items: [item], + mappings: [mapping], + scanCompleted: true, + }); + + assert.lengthOf(result, 0); + }); + + it("closed: lifecycle closed with open mapping produces a closed delta (takes precedence over changed)", () => { + // Title is also different → content changed AND lifecycle closed. + const item = makeItem({ + lifecycle: "closed", + fields: { title: "Renamed AND closed", description: "Different body" }, + }); + const mapping = makeMapping({ + contentHash: hashContent({ title: "Fix the bug", description: "It is broken" }), + lifecycle: "open", + }); + + const result = classifyDeltas({ + ...defaultInput, + items: [item], + mappings: [mapping], + scanCompleted: true, + }); + + // Only ONE delta: closed wins over changed. + assert.lengthOf(result, 1); + const delta = result[0]!; + assert.equal(delta._tag, "closed"); + if (delta._tag === "closed") { + assert.equal(delta.ticketId, "ticket-abc"); + } + }); + + it("already-closed mapping + closed item → no redundant closed delta", () => { + const item = makeItem({ lifecycle: "closed" }); + // Mapping already has lifecycle 'closed' → nothing to emit. + const mapping = makeMapping({ + lifecycle: "closed", + contentHash: hashContent({ title: "Fix the bug", description: "It is broken" }), + }); + + const result = classifyDeltas({ + ...defaultInput, + items: [item], + mappings: [mapping], + scanCompleted: true, + }); + + assert.lengthOf(result, 0); + }); + + it("missing-when-complete: active mapping absent from items when scanCompleted:true → missing delta", () => { + // No items in the fetch, but there IS an active mapping. + const mapping = makeMapping({ syncStatus: "active", lifecycle: "open" }); + + const result = classifyDeltas({ + ...defaultInput, + items: [], + mappings: [mapping], + scanCompleted: true, + }); + + assert.lengthOf(result, 1); + const delta = result[0]!; + assert.equal(delta._tag, "missing"); + if (delta._tag === "missing") { + assert.equal(delta.ticketId, "ticket-abc"); + assert.equal(delta.confirmedDeleted, false); + } + }); + + it("missing-suppressed: active mapping absent from items when scanCompleted:false → NO missing delta", () => { + const mapping = makeMapping({ syncStatus: "active" }); + + const result = classifyDeltas({ + ...defaultInput, + items: [], + mappings: [mapping], + scanCompleted: false, + }); + + assert.lengthOf(result, 0); + }); + + it("orphaned mapping not re-emitted as missing (only active rows)", () => { + const mapping = makeMapping({ syncStatus: "orphaned" }); + + const result = classifyDeltas({ + ...defaultInput, + items: [], + mappings: [mapping], + scanCompleted: true, + }); + + assert.lengthOf(result, 0); + }); + + it("multiple items: correct classification for each", () => { + const itemNew = makeItem({ externalId: "issue-100" }); + const itemChanged = makeItem({ + externalId: "issue-200", + fields: { title: "Changed title", description: "Changed body" }, + }); + const itemNoop = makeItem({ externalId: "issue-300" }); + const itemClosed = makeItem({ externalId: "issue-400", lifecycle: "closed" }); + + const mappingChanged = makeMapping({ + externalId: "issue-200", + ticketId: "ticket-200", + contentHash: hashContent({ title: "Old title", description: "Old body" }), + }); + const mappingNoop = makeMapping({ + externalId: "issue-300", + ticketId: "ticket-300", + contentHash: hashContent({ title: "Fix the bug", description: "It is broken" }), + }); + const mappingClosed = makeMapping({ + externalId: "issue-400", + ticketId: "ticket-400", + lifecycle: "open", + contentHash: hashContent({ title: "Fix the bug", description: "It is broken" }), + }); + const mappingMissing = makeMapping({ + externalId: "issue-999", + ticketId: "ticket-999", + syncStatus: "active", + }); + + const result = classifyDeltas({ + ...defaultInput, + items: [itemNew, itemChanged, itemNoop, itemClosed], + mappings: [mappingChanged, mappingNoop, mappingClosed, mappingMissing], + scanCompleted: true, + }); + + // new (issue-100) + changed (issue-200) + closed (issue-400) + missing (issue-999) + // issue-300 is a no-op. + assert.lengthOf(result, 4); + assert.equal(result[0]!._tag, "new"); + assert.equal(result[1]!._tag, "changed"); + assert.equal(result[2]!._tag, "closed"); + assert.equal(result[3]!._tag, "missing"); + }); + + it("output ordering is deterministic: items in input order, then missing in mapping order", () => { + const item1 = makeItem({ externalId: "issue-1" }); + const item2 = makeItem({ externalId: "issue-2" }); + const mappingMissing1 = makeMapping({ externalId: "issue-99", ticketId: "ticket-99" }); + const mappingMissing2 = makeMapping({ externalId: "issue-98", ticketId: "ticket-98" }); + + const result = classifyDeltas({ + ...defaultInput, + items: [item1, item2], + mappings: [mappingMissing1, mappingMissing2], + scanCompleted: true, + }); + + // Both items are new (unmapped), then two missing in mapping-array order. + assert.equal(result[0]!._tag, "new"); + if (result[0]!._tag === "new") assert.equal(result[0]!.item.externalId, "issue-1"); + assert.equal(result[1]!._tag, "new"); + if (result[1]!._tag === "new") assert.equal(result[1]!.item.externalId, "issue-2"); + assert.equal(result[2]!._tag, "missing"); + if (result[2]!._tag === "missing") assert.equal(result[2]!.item.externalId, "issue-99"); + assert.equal(result[3]!._tag, "missing"); + if (result[3]!._tag === "missing") assert.equal(result[3]!.item.externalId, "issue-98"); + }); + + it("deleted: lifecycle=deleted with open mapping and differing hash → closed delta (not changed)", () => { + // An item with lifecycle "deleted" should be treated like "closed" — + // emitting a "closed" delta even when the content hash differs. + const item = makeItem({ + lifecycle: "deleted", + fields: { title: "Deleted title", description: "Deleted body" }, + }); + const mapping = makeMapping({ + contentHash: hashContent({ title: "Fix the bug", description: "It is broken" }), + lifecycle: "open", + }); + + const result = classifyDeltas({ + ...defaultInput, + items: [item], + mappings: [mapping], + scanCompleted: true, + }); + + assert.lengthOf(result, 1); + assert.equal(result[0]!._tag, "closed"); + }); + + it("deleted: lifecycle=deleted with already-closed mapping → no delta (already terminal)", () => { + const item = makeItem({ lifecycle: "deleted" }); + const mapping = makeMapping({ lifecycle: "closed" }); + + const result = classifyDeltas({ + ...defaultInput, + items: [item], + mappings: [mapping], + scanCompleted: true, + }); + + assert.lengthOf(result, 0); + }); +}); diff --git a/apps/server/src/workflow/sourceReconcileDiff.ts b/apps/server/src/workflow/sourceReconcileDiff.ts new file mode 100644 index 00000000000..4e48d254a6e --- /dev/null +++ b/apps/server/src/workflow/sourceReconcileDiff.ts @@ -0,0 +1,193 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { createHash } from "node:crypto"; + +import type { ExternalWorkItem } from "./Services/WorkSourceProvider.ts"; +import type { + SourceDelta, + SourceItemFields, + SourceItemMetadata, +} from "./Services/WorkflowSourceCommitter.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * A row from `work_source_mapping` as required by the diff. + * Only the columns the reconciler reads are declared here; the committer's + * `readMapping` helper in Task 9 selects an equivalent superset. + */ +export interface MappingRow { + readonly externalId: string; + readonly ticketId: string; + readonly contentHash: string; + readonly providerVersion: string | null; + readonly lifecycle: string; // 'open' | 'closed' + readonly syncStatus: string; // 'active' | 'orphaned' +} + +export interface ClassifyDeltasInput { + readonly sourceId: string; + readonly provider: string; + readonly items: ReadonlyArray; + /** All `work_source_mapping` rows for this (boardId, sourceId). */ + readonly mappings: ReadonlyArray; + /** + * `true` when the provider returned all pages without hitting a page cap + * or error. Only a complete scan may produce `missing` deltas — a partial + * scan must never orphan items that simply weren't fetched yet. + */ + readonly scanCompleted: boolean; +} + +// --------------------------------------------------------------------------- +// Content hashing +// --------------------------------------------------------------------------- + +/** + * Deterministic content hash of the upstream fields that are synced to the + * ticket. Only `title` and `description` are authoritative (the committer's + * version gate compares this against `work_source_mapping.content_hash`). + * + * Uses SHA-256 over canonical JSON so the hash is stable across runs. + */ +export const hashContent = (fields: { title: string; description?: string | undefined }): string => { + // Source-owned descriptions are authoritative: a cleared/absent upstream + // description normalizes to "" (NOT null) so the hash AND the carried value + // agree. (An upstream item whose description was cleared must clear the + // ticket's description, and the stored hash must reflect the cleared value + // so the next no-change cycle is a no-op.) + const canonical = JSON.stringify({ title: fields.title, description: fields.description ?? "" }); + return createHash("sha256").update(canonical).digest("hex"); +}; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +const buildMetadata = (item: ExternalWorkItem): SourceItemMetadata => ({ + provider: item.provider, + url: item.url, + assignees: item.fields.assignees, + labels: item.fields.labels, + lifecycle: item.lifecycle, +}); + +const buildItemFields = ( + sourceId: string, + item: ExternalWorkItem, + contentHash: string, +): SourceItemFields => ({ + sourceId, + provider: item.provider, + externalId: item.externalId, + title: item.fields.title, + // Always carry the (possibly empty) description so the committer WRITES it — + // a cleared upstream description must clear the ticket, not leave it stale. + // "" is a valid clear; never carry undefined for a synced item. + description: item.fields.description ?? "", + contentHash, + providerVersion: + item.version.updatedAt ?? item.version.etag ?? undefined, + metadata: buildMetadata(item), +}); + +/** + * Reconstruct a minimal `SourceItemFields` from a mapping row for use in + * `missing` deltas. The actual field values are not critical here — the + * committer only uses `externalId`/`sourceId`/`provider` to find the row + * in-tx; title/description/contentHash/metadata come from the stored mapping. + */ +const buildMissingFields = ( + sourceId: string, + provider: string, + row: MappingRow, +): SourceItemFields => ({ + sourceId, + provider, + externalId: row.externalId, + title: "", + description: undefined, + contentHash: row.contentHash, + providerVersion: row.providerVersion ?? undefined, + metadata: { provider }, +}); + +// --------------------------------------------------------------------------- +// Core classifier +// --------------------------------------------------------------------------- + +/** + * Pure reconcile diff — no IO. + * + * **Precedence rule (closed/deleted vs changed):** + * If an item is both changed (hash differs) AND closed/deleted (lifecycle === "closed" | "deleted"), + * we emit only `closed`. The committer already updates the mapping's + * `content_hash` when processing a `closed` delta, so a subsequent run will + * see no content delta. This keeps the committer logic simple and the user + * sees the correct terminal routing immediately. + * + * **Ordering:** + * Output follows the input `items` array order (new/changed/closed from + * items) then missing (from mappings, in their original order). This is + * deterministic and stable so tests can rely on ordering. + * + * **scan-completeness gate:** + * `missing` deltas are only emitted when `scanCompleted === true`. A partial + * or failed scan must never produce orphan deltas. + */ +export const classifyDeltas = (input: ClassifyDeltasInput): ReadonlyArray => { + const { sourceId, provider, items, mappings, scanCompleted } = input; + + // Build an index of mappings by externalId for O(1) lookup. + const mappingByExternalId = new Map(); + for (const row of mappings) { + mappingByExternalId.set(row.externalId, row); + } + + // Track which externalIds were seen in the fetched items. + const seenExternalIds = new Set(); + + const deltas: SourceDelta[] = []; + + for (const item of items) { + seenExternalIds.add(item.externalId); + const contentHash = hashContent(item.fields); + const fields = buildItemFields(sourceId, item, contentHash); + const row = mappingByExternalId.get(item.externalId); + + if (row === undefined) { + // No mapping → this is a new item. + deltas.push({ _tag: "new", item: fields }); + } else { + // Mapped item. Closed/deleted takes precedence over changed. + const isTerminal = item.lifecycle === "closed" || item.lifecycle === "deleted"; + if (isTerminal && row.lifecycle !== "closed") { + deltas.push({ _tag: "closed", item: fields, ticketId: row.ticketId }); + } else if (!isTerminal && contentHash !== row.contentHash) { + // Only emit changed for open items with differing hash. + deltas.push({ _tag: "changed", item: fields, ticketId: row.ticketId }); + } + // If hash === row.contentHash (or item is already closed in both places): + // no delta — this is a no-op. + } + } + + // Missing deltas: mappings whose externalId was NOT in the fetched items. + // Only emit when the scan was complete (all pages fetched without cap hit). + if (scanCompleted) { + for (const row of mappings) { + if (!seenExternalIds.has(row.externalId) && row.syncStatus === "active") { + const fields = buildMissingFields(sourceId, provider, row); + deltas.push({ + _tag: "missing", + item: fields, + ticketId: row.ticketId, + confirmedDeleted: false, + }); + } + } + } + + return deltas; +}; 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..261a55d3932 --- /dev/null +++ b/apps/server/src/workflow/webhookRoute.ts @@ -0,0 +1,192 @@ +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") { + // The delivery was recorded before ingest; release it so the sender's + // retry is ingested instead of answered "duplicate" for an event that + // never landed. 503 keeps the failure retryable even if the release + // itself fails (the retry then dedupes, but nothing was lost silently). + if (parsed.deliveryId !== undefined) { + yield* webhook + .releaseDelivery(boardId as never, parsed.deliveryId) + .pipe(Effect.orElseSucceed(() => undefined)); + } + return HttpServerResponse.text("event could not be ingested", { status: 503 }); + } + 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..3aedccbca96 --- /dev/null +++ b/apps/server/src/workflow/workflowFile.test.ts @@ -0,0 +1,995 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + WorkflowDefinition, + type WorkflowDefinition as WorkflowDefinitionType, +} from "@t3tools/contracts"; +import { AsanaSelector, GithubSelector } from "@t3tools/contracts/workSource"; +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 pullRequest steps", () => { + const lintLane = (pipeline: ReadonlyArray) => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition( + base([ + { key: "a", name: "A", entry: "manual", pipeline }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ); + return lintWorkflowDefinition(definition, ctx); + }); + + it.effect("lints pullRequest steps", () => + Effect.gen(function* () { + // open step with land-only fields → invalid_step + const openWithLandFields = yield* lintLane([ + { key: "pr", type: "pullRequest", action: "open", strategy: "squash", deleteBranch: true }, + ]); + assert.isTrue(openWithLandFields.some((e) => e.code === "invalid_step")); + + // land step with open-only fields → invalid_step + const landWithOpenFields = yield* lintLane([ + { key: "pr", type: "pullRequest", action: "land", base: "main", draft: false, titleTemplate: "My PR", bodyTemplate: "Body" }, + ]); + assert.isTrue(landWithOpenFields.some((e) => e.code === "invalid_step")); + + // open step with unknown placeholder in titleTemplate → unknown_template_placeholder + const badTitle = yield* lintLane([ + { key: "pr", type: "pullRequest", action: "open", titleTemplate: "PR: {{ticket.bogus.path}}" }, + ]); + assert.isTrue(badTitle.some((e) => e.code === "unknown_template_placeholder")); + + // open step with unknown placeholder in bodyTemplate → unknown_template_placeholder + const badBody = yield* lintLane([ + { key: "pr", type: "pullRequest", action: "open", bodyTemplate: "Fixes {{ticket.unknown}}" }, + ]); + assert.isTrue(badBody.some((e) => e.code === "unknown_template_placeholder")); + + // clean open step → no errors + const cleanOpen = yield* lintLane([ + { key: "pr", type: "pullRequest", action: "open", base: "main", titleTemplate: "PR: {{ticket.title}}", bodyTemplate: "{{ticket.description}}" }, + ]); + assert.deepEqual(cleanOpen, []); + + // clean land step → no errors + const cleanLand = yield* lintLane([ + { key: "pr", type: "pullRequest", action: "land", strategy: "squash", deleteBranch: true }, + ]); + assert.deepEqual(cleanLand, []); + }), + ); + + it.effect("allows steps..output for pullRequest open steps", () => + Effect.gen(function* () { + const definition = yield* decodeWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { key: "openPr", type: "pullRequest", action: "open" }, + { key: "landPr", type: "pullRequest", action: "land" }, + ], + transitions: [ + { when: { var: "steps.openPr.output.prNumber" }, to: "done" }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ); + const errors = lintWorkflowDefinition(definition, ctx); + // reading output from open step is fine + assert.isFalse(errors.some((e) => e.code === "unknown_predicate_path" && e.message?.includes("openPr"))); + + // reading output from land step → error + const definitionWithLandOutput = yield* decodeWorkflowDefinition( + base([ + { + key: "a", + name: "A", + entry: "auto", + pipeline: [ + { key: "landPr", type: "pullRequest", action: "land" }, + ], + transitions: [ + { when: { var: "steps.landPr.output.prNumber" }, to: "done" }, + ], + on: { success: "done" }, + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ); + const landOutputErrors = lintWorkflowDefinition(definitionWithLandOutput, ctx); + assert.isTrue(landOutputErrors.some((e) => e.code === "unknown_predicate_path" && e.message?.includes("can only read output"))); + }), + ); + + it.effect("allows pr.* in onEvent.when", () => + Effect.gen(function* () { + // pr.ciState and pr.reviewDecision lint clean + const definitionClean = yield* decodeWorkflowDefinition( + base([ + { + key: "review", + name: "Review", + entry: "manual", + onEvent: [ + { name: "pr_update", when: { var: "pr.ciState" }, to: "done" }, + { name: "pr_review", when: { var: "pr.reviewDecision" }, to: "done" }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ); + assert.deepEqual(lintWorkflowDefinition(definitionClean, ctx), []); + + // pr.bogus → unknown_predicate_path + const definitionBogus = yield* decodeWorkflowDefinition( + base([ + { + key: "review", + name: "Review", + entry: "manual", + onEvent: [ + { name: "pr_update", when: { var: "pr.bogus" }, to: "done" }, + ], + }, + { key: "done", name: "Done", entry: "manual", terminal: true }, + ]), + ); + const bogusErrors = lintWorkflowDefinition(definitionBogus, ctx); + assert.isTrue(bogusErrors.some((e) => e.code === "unknown_predicate_path")); + // error message mentions the allowed pr.* paths + const prError = bogusErrors.find((e) => e.code === "unknown_predicate_path"); + assert.match(prError?.message ?? "", /pr\.ciState/); + assert.match(prError?.message ?? "", /pr\.reviewDecision/); + }), + ); +}); + +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), []); + }), + ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Source lint tests +// ───────────────────────────────────────────────────────────────────────────── + +const baseWithSources = ( + lanes: unknown, + sources: unknown, +): WorkflowDefinitionType => + ({ name: "wf", lanes, sources }) as unknown as WorkflowDefinitionType; + +const selectorCtx = { + providerInstanceExists: () => true, + instructionFileExists: () => true, + selectorSchemaFor: (p: string) => + p === "github" ? GithubSelector : p === "asana" ? AsanaSelector : null, +}; + +const twoLanes = [ + { key: "backlog", name: "Backlog", entry: "manual" }, + { key: "done", name: "Done", entry: "manual", terminal: true }, +]; + +describe("lintWorkflowDefinition sources", () => { + it("flags destinationLane referencing a missing lane", () => { + const def = baseWithSources(twoLanes, [ + { + id: "gh1", + provider: "github", + connectionRef: "conn-1", + selector: { owner: "acme", repo: "api" }, + destinationLane: "nonexistent", + closedLane: "done", + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.isTrue(errors.some((e) => e.code === "missing_lane_ref" && /destinationLane/.test(e.message))); + }); + + it("flags closedLane referencing a missing lane", () => { + const def = baseWithSources(twoLanes, [ + { + id: "gh1", + provider: "github", + connectionRef: "conn-1", + selector: { owner: "acme", repo: "api" }, + destinationLane: "backlog", + closedLane: "ghost", + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.isTrue(errors.some((e) => e.code === "missing_lane_ref" && /closedLane/.test(e.message))); + }); + + it("flags closedLane that exists but is not terminal", () => { + const def = baseWithSources(twoLanes, [ + { + id: "gh1", + provider: "github", + connectionRef: "conn-1", + selector: { owner: "acme", repo: "api" }, + destinationLane: "backlog", + closedLane: "backlog", // exists but not terminal + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.isTrue(errors.some((e) => e.code === "invalid_source" && /terminal/.test(e.message))); + }); + + it("flags a github selector missing required fields (owner/repo)", () => { + const def = baseWithSources(twoLanes, [ + { + id: "gh1", + provider: "github", + connectionRef: "conn-1", + selector: { labels: ["bug"] }, // missing owner and repo + destinationLane: "backlog", + closedLane: "done", + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.isTrue(errors.some((e) => e.code === "invalid_source")); + }); + + it("flags an unknown provider", () => { + const def = baseWithSources(twoLanes, [ + { + id: "jira1", + provider: "jira" as any, + connectionRef: "conn-1", + selector: {}, + destinationLane: "backlog", + closedLane: "done", + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.isTrue(errors.some((e) => e.code === "invalid_source" && /unknown provider/.test(e.message))); + }); + + it("flags duplicate source ids", () => { + const def = baseWithSources(twoLanes, [ + { + id: "gh1", + provider: "github", + connectionRef: "conn-1", + selector: { owner: "acme", repo: "api" }, + destinationLane: "backlog", + closedLane: "done", + enabled: true, + }, + { + id: "gh1", // duplicate + provider: "github", + connectionRef: "conn-2", + selector: { owner: "acme", repo: "web" }, + destinationLane: "backlog", + closedLane: "done", + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.isTrue(errors.some((e) => e.code === "duplicate_source_id")); + }); + + it("flags an asana source with sectionGid set", () => { + const def = baseWithSources(twoLanes, [ + { + id: "asana1", + provider: "asana", + connectionRef: "conn-1", + selector: { projectGid: "1234567890", sectionGid: "999", includeCompleted: false }, + destinationLane: "backlog", + closedLane: "done", + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.isTrue(errors.some((e) => e.code === "invalid_source" && /sectionGid/.test(e.message))); + }); + + it("flags an asana source with tagGid set", () => { + const def = baseWithSources(twoLanes, [ + { + id: "asana1", + provider: "asana", + connectionRef: "conn-1", + selector: { projectGid: "1234567890", tagGid: "777", includeCompleted: true }, + destinationLane: "backlog", + closedLane: "done", + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.isTrue(errors.some((e) => e.code === "invalid_source" && /tagGid/.test(e.message))); + }); + + it("accepts a valid github source and a valid asana source", () => { + const def = baseWithSources(twoLanes, [ + { + id: "gh1", + provider: "github", + connectionRef: "conn-1", + selector: { owner: "acme", repo: "api", labels: ["bug"], state: "open" }, + destinationLane: "backlog", + closedLane: "done", + enabled: true, + }, + { + id: "asana1", + provider: "asana", + connectionRef: "conn-2", + selector: { projectGid: "1234567890", includeCompleted: false }, + destinationLane: "backlog", + closedLane: "done", + enabled: true, + }, + ]); + const errors = lintWorkflowDefinition(def, selectorCtx); + assert.deepEqual(errors, []); + }); +}); diff --git a/apps/server/src/workflow/workflowFile.ts b/apps/server/src/workflow/workflowFile.ts new file mode 100644 index 00000000000..923f255d050 --- /dev/null +++ b/apps/server/src/workflow/workflowFile.ts @@ -0,0 +1,583 @@ +import { WorkflowDefinition, type WorkflowLane, type WorkflowStep } from "@t3tools/contracts"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; +import * as Exit from "effect/Exit"; +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" + | "invalid_step" + | "invalid_source" + | "duplicate_source_id"; + +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; + // Returns the pure selector schema for a given provider name, or null if the + // provider is unknown. Used for synchronous (no-network) selector validation. + // The schema must have no DecodingServices requirement (pure, sync decode). + readonly selectorSchemaFor?: (provider: string) => Schema.Decoder | 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") { + const allowed = + (step.type === "agent" && step.captureOutput === true) || + (step.type === "pullRequest" && step.action === "open"); + return allowed + ? null + : `Predicate path "${path}" can only read output from captureOutput agent steps or pullRequest open 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 === "pullRequest") { + if (step.action === "open" && (step.strategy !== undefined || step.deleteBranch !== undefined)) { + errors.push({ + code: "invalid_step", + laneKey, + stepKey, + message: `Step "${stepKey}": strategy/deleteBranch only apply to action "land"`, + }); + } + if ( + step.action === "land" && + (step.base !== undefined || + step.draft !== undefined || + step.titleTemplate !== undefined || + step.bodyTemplate !== undefined) + ) { + errors.push({ + code: "invalid_step", + laneKey, + stepKey, + message: `Step "${stepKey}": base/draft/templates only apply to action "open"`, + }); + } + for (const template of [step.titleTemplate, step.bodyTemplate]) { + if (template !== undefined) { + for (const placeholder of unknownTicketPlaceholders(template)) { + errors.push({ + code: "unknown_template_placeholder", + laneKey, + stepKey, + message: `Step "${stepKey}" references unknown placeholder "{{ticket.${placeholder}}}"`, + }); + } + } + } + } + + 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 and PR state — not pipeline state. + for (const path of inspection.variablePaths) { + if ( + path !== "event.name" && + path !== "event.payload" && + !path.startsWith("event.payload.") && + path !== "pr.ciState" && + path !== "pr.reviewDecision" + ) { + errors.push({ + code: "unknown_predicate_path", + laneKey, + message: `Lane "${laneKey}" onEvent ${eventIndex}: unknown predicate path "${path}" (event predicates may read event.name, event.payload.*, pr.ciState, pr.reviewDecision)`, + }); + } + } + } + } + + 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; + } + } + + // ── Source lint (synchronous — pure schema decode, no network) ────────── + const seenSourceIds = new Set(); + for (const source of def.sources ?? []) { + const sourceId = source.id as string; + + // Duplicate source id check + if (seenSourceIds.has(sourceId)) { + errors.push({ + code: "duplicate_source_id", + message: `Duplicate source id "${sourceId}"`, + }); + } + seenSourceIds.add(sourceId); + + // destinationLane must exist + if (!allKeys.has(source.destinationLane as string)) { + errors.push({ + code: "missing_lane_ref", + message: `Source "${sourceId}" destinationLane "${source.destinationLane}" does not exist`, + }); + } + + // closedLane must exist and be terminal + if (!allKeys.has(source.closedLane as string)) { + errors.push({ + code: "missing_lane_ref", + message: `Source "${sourceId}" closedLane "${source.closedLane}" does not exist`, + }); + } else { + const closedLaneDef = byKey.get(source.closedLane as string); + if (closedLaneDef?.terminal !== true) { + errors.push({ + code: "invalid_source", + message: `Source "${sourceId}" closedLane "${source.closedLane}" must be a terminal lane`, + }); + } + } + + // connectionRef must not be blank + const connectionRef = source.connectionRef as string; + if (!connectionRef || connectionRef.trim().length === 0) { + errors.push({ + code: "invalid_source", + message: `Source "${sourceId}" connectionRef must not be empty`, + }); + } + + // Selector schema validation (pure, synchronous) + if (ctx.selectorSchemaFor !== undefined) { + const schema = ctx.selectorSchemaFor(source.provider as string); + if (schema === null) { + errors.push({ + code: "invalid_source", + message: `Source "${sourceId}" has unknown provider "${source.provider}"`, + }); + } else { + const decodeExit = Schema.decodeUnknownExit(schema)(source.selector); + if (Exit.isFailure(decodeExit)) { + const squashed = Cause.squash(decodeExit.cause); + const message = + Schema.isSchemaError(squashed) + ? String(squashed.message) + : Cause.pretty(decodeExit.cause); + errors.push({ + code: "invalid_source", + message: `Source "${sourceId}" selector is invalid: ${message}`, + }); + } else { + // Extra check: Asana section/tag filtering is not supported yet + if ( + (source.provider as string) === "asana" && + decodeExit.value !== undefined && + decodeExit.value !== null && + typeof decodeExit.value === "object" + ) { + const selector = decodeExit.value as Record; + if (selector["sectionGid"] !== undefined || selector["tagGid"] !== undefined) { + errors.push({ + code: "invalid_source", + message: `Source "${sourceId}" Asana section/tag filtering is not supported yet; remove sectionGid/tagGid`, + }); + } + } + } + } + } + } + + 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..9a91990bac0 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,26 @@ 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 { WorkSourceConnectionStore } from "./workflow/Services/WorkSourceConnectionStore.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 +165,36 @@ 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.listNeedsAttentionTickets, 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], + [WORKFLOW_WS_METHODS.listWorkSourceConnections, AuthWorkflowReadScope], + [WORKFLOW_WS_METHODS.createWorkSourceConnection, AuthWorkflowOperateScope], + [WORKFLOW_WS_METHODS.deleteWorkSourceConnection, AuthWorkflowOperateScope], [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], @@ -173,6 +231,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 +326,42 @@ 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; + // WorkSourceConnectionStoreLive is provided by WorkflowServerRuntimeLive + // (via WorkSourceLive), so resolve it as a required service — the + // connection RPCs need a real store, not a standby no-op. + const workflowConnectionStore = yield* WorkSourceConnectionStore; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, @@ -479,7 +574,14 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => }), ); case "thread.unarchived": - return projectionSnapshotQuery.getThreadShellById(event.payload.threadId).pipe( + return projectionSnapshotQuery.isThreadHidden(event.payload.threadId).pipe( + Effect.flatMap((hidden) => + hidden + ? Effect.succeed(Option.none()) + : projectionSnapshotQuery + .getThreadShellById(event.payload.threadId) + .pipe(Effect.orElseSucceed(() => Option.none())), + ), Effect.map((thread) => Option.map(thread, (nextThread) => ({ kind: "thread-upserted" as const, @@ -493,18 +595,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 +867,74 @@ 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, + connectionStore: workflowConnectionStore, + observeRpcEffect, + observeRpcStreamEffect, + }); + return WsRpcGroup.of({ + ...workflowHandlers, [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => observeRpcEffect( ORCHESTRATION_WS_METHODS.dispatchCommand, @@ -1308,6 +1483,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/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 75fc0ad3235..2b365282ba3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2736,6 +2736,20 @@ export default function ChatView(props: ChatViewProps) { const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext, }); + + if ( + !command && + !shortcutContext.terminalFocus && + !shortcutContext.modelPickerOpen && + shouldTypeToFocusComposer(event) + ) { + if (composerRef.current?.insertTextAtEnd(event.key)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + } + if (!command) return; if (command === "terminal.toggle") { 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..4317670b097 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,27 +174,33 @@ 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 { useServerKeybindings, useServerProviders } from "../rpc/serverState"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, @@ -734,13 +755,274 @@ 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; + } else { + renameCommittedRef.current = false; + } + } 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 +1038,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 +1071,12 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( const { projectKey, projectExpanded, + renderedBoards, hasOverflowingThreads, hiddenThreadStatus, orderedProjectThreadKeys, renderedThreads, + activeRouteBoardRef, showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, @@ -806,6 +1092,8 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( confirmingArchiveThreadKey, setConfirmingArchiveThreadKey, confirmArchiveButtonRefs, + deleteBoardForProjectMember, + renameBoardForProjectMember, attachThreadListAutoAnimateRef, handleThreadClick, navigateToThread, @@ -837,6 +1125,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 +1219,7 @@ interface SidebarProjectItemProps { project: SidebarProjectSnapshot; isThreadListExpanded: boolean; activeRouteThreadKey: string | null; + activeRouteBoardRef: SidebarBoardRouteIdentity | null; newThreadShortcutLabel: string | null; handleNewThread: ReturnType["handleNewThread"]; archiveThread: ReturnType["archiveThread"]; @@ -931,6 +1240,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec project, isThreadListExpanded, activeRouteThreadKey, + activeRouteBoardRef, newThreadShortcutLabel, handleNewThread, archiveThread, @@ -964,6 +1274,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); + const serverProviders = useServerProviders(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); const toggleProject = useUiStateStore((state) => state.toggleProject); const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); @@ -1043,6 +1354,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 +1395,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 +1589,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 +2102,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 +2301,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 +2575,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ], ); + // Subscribe to provider statuses so the Add-board affordance recomputes + // when providers finish installing after startup. The composer-draft and + // recent-thread inputs are only read at event time, so a slightly stale + // value there is fine — the provider dimension is what goes stale. + const canCreateBoard = resolveRecentAgent(serverProviders) !== null; + return ( <>
@@ -2074,10 +2661,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 +3382,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( activeRouteThreadKey={ activeRouteProjectKey === project.projectKey ? routeThreadKey : null } + activeRouteBoardRef={activeRouteBoardRef} newThreadShortcutLabel={newThreadShortcutLabel} handleNewThread={handleNewThread} archiveThread={archiveThread} @@ -2782,6 +3415,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. + + +
+ +