From aa3033771a440fb0cf62422cb6fc03c0c8c2e033 Mon Sep 17 00:00:00 2001 From: chungyeong Date: Wed, 13 May 2026 08:39:19 +0900 Subject: [PATCH] feat: add temporal run engine integration --- .env.example | 1 + apps/api/package.json | 4 +- apps/api/src/index.test.ts | 375 ++++- apps/api/src/index.ts | 151 +- apps/api/tsconfig.json | 3 +- apps/cli/src/doctor.test.ts | 2 + apps/cli/src/doctor.ts | 6 +- apps/worker/package.json | 19 + apps/worker/src/index.test.ts | 275 +++ apps/worker/src/index.ts | 127 ++ apps/worker/tsconfig.json | 15 + docs/plan.md | 48 +- packages/core/src/config.test.ts | 23 + packages/core/src/config.ts | 2 +- packages/run-engine/src/engine.test.ts | 262 ++- packages/run-engine/src/engine.ts | 677 ++++++-- .../run-engine/src/fake-phase-harness.test.ts | 573 ++++++- packages/run-engine/src/fake-phase-harness.ts | 315 +++- packages/session/src/adapter.ts | 1 + packages/session/src/fake.ts | 6 +- packages/session/src/manager.ts | 61 +- packages/workflows/package.json | 27 + packages/workflows/src/activities.test.ts | 310 ++++ packages/workflows/src/activities.ts | 166 ++ packages/workflows/src/index.ts | 4 + .../workflows/src/temporal-run-engine.test.ts | 1122 +++++++++++++ packages/workflows/src/temporal-run-engine.ts | 666 ++++++++ packages/workflows/src/types.ts | 24 + .../src/workflow.integration.test.ts | 440 +++++ packages/workflows/src/workflow.test.ts | 59 + packages/workflows/src/workflow.ts | 268 +++ packages/workflows/tsconfig.build.json | 10 + packages/workflows/tsconfig.json | 15 + pnpm-lock.yaml | 1495 ++++++++++++++++- tsconfig.json | 4 +- tsconfig.typecheck.json | 3 +- vitest.workspace.ts | 3 + 37 files changed, 7338 insertions(+), 224 deletions(-) create mode 100644 apps/worker/package.json create mode 100644 apps/worker/src/index.test.ts create mode 100644 apps/worker/src/index.ts create mode 100644 apps/worker/tsconfig.json create mode 100644 packages/workflows/package.json create mode 100644 packages/workflows/src/activities.test.ts create mode 100644 packages/workflows/src/activities.ts create mode 100644 packages/workflows/src/index.ts create mode 100644 packages/workflows/src/temporal-run-engine.test.ts create mode 100644 packages/workflows/src/temporal-run-engine.ts create mode 100644 packages/workflows/src/types.ts create mode 100644 packages/workflows/src/workflow.integration.test.ts create mode 100644 packages/workflows/src/workflow.test.ts create mode 100644 packages/workflows/src/workflow.ts create mode 100644 packages/workflows/tsconfig.build.json create mode 100644 packages/workflows/tsconfig.json diff --git a/.env.example b/.env.example index 3bc656d..8fe5953 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ DATABASE_URL=postgres://devflow:devflow@127.0.0.1:55432/devflow WORKSPACE_ROOT=./data/workspace LOG_LEVEL=info +TEMPORAL_ADDRESS=localhost:7233 DEVFLOW_POSTGRES_PORT=55432 DEVFLOW_BACKENDS_JSON=[{"id":"fake","enabled":true}] diff --git a/apps/api/package.json b/apps/api/package.json index 9e99e90..1002b61 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,6 +12,8 @@ "@devflow/core": "workspace:*", "@devflow/db": "workspace:*", "@devflow/run-engine": "workspace:*", - "@devflow/session": "workspace:*" + "@devflow/session": "workspace:*", + "@devflow/workflows": "workspace:*", + "@temporalio/client": "^1.17.1" } } diff --git a/apps/api/src/index.test.ts b/apps/api/src/index.test.ts index b93e222..2b96498 100644 --- a/apps/api/src/index.test.ts +++ b/apps/api/src/index.test.ts @@ -23,10 +23,11 @@ import { } from "@devflow/db"; import { DbRunEngine } from "@devflow/run-engine"; import { FakeSessionAdapter, type SessionHandle, SessionManager } from "@devflow/session"; +import type { WorkflowClient, WorkflowHandle } from "@temporalio/client"; import { and, eq, inArray } from "drizzle-orm"; import { afterEach, describe, expect, it } from "vitest"; -import { startApi } from "./index.js"; +import { startApi, startM4Api } from "./index.js"; const databaseUrl = process.env.DATABASE_URL ?? "postgres://devflow:devflow@127.0.0.1:55432/devflow"; @@ -74,6 +75,27 @@ class DelayedSendPromptFakeSessionAdapter extends FakeSessionAdapter { } } +class FakeWorkflowClient { + started: { workflowId: string; taskQueue: string; args: unknown[] } | undefined; + + async start( + _workflow: unknown, + options: { workflowId: string; taskQueue: string; args: unknown[] }, + ) { + this.started = { + workflowId: options.workflowId, + taskQueue: options.taskQueue, + args: options.args, + }; + } + + getHandle(_workflowId: string): Pick { + return { + signal: async () => undefined, + }; + } +} + function deferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; @@ -166,8 +188,12 @@ describe("startApi", () => { return workspaceRoot; } - function startTestApi(options: Parameters[0] = {}) { - return startApi({ workspaceRoot: createApiWorkspaceRoot(), ...options }); + function startTestM4Api(options: Parameters[0] = {}) { + return startM4ApiWhenLockFree({ + workspaceRoot: createApiWorkspaceRoot(), + maxConcurrentRuns: 100, + ...options, + }); } afterEach(async () => { @@ -242,7 +268,7 @@ describe("startApi", () => { state: "READY", }); - const result = await startTestApi({ dbClient: client, recoveryRunIds: [runId] }); + const result = await startTestM4Api({ dbClient: client, recoveryRunIds: [runId] }); try { expect(result.recovery).toEqual({ failedSessionIds: [sessionId], @@ -276,9 +302,15 @@ describe("startApi", () => { it("holds the SessionManager singleton lock until stopped", async () => { client = createDbClient(databaseUrl); const recoveryRunIds = [randomUUID()]; - const first = await startTestApi({ dbClient: client, recoveryRunIds }); + const first = await startTestM4Api({ dbClient: client, recoveryRunIds }); try { - await expect(startTestApi({ dbClient: client, recoveryRunIds })).rejects.toMatchObject({ + await expect( + startM4Api({ + dbClient: client, + workspaceRoot: createApiWorkspaceRoot(), + recoveryRunIds, + }), + ).rejects.toMatchObject({ code: "session_manager_already_running", }); } finally { @@ -293,7 +325,12 @@ describe("startApi", () => { const repoPath = createGitRepo(); tempRoots.push(repoPath); - const api = await startApi({ dbClient: client, workspaceRoot, recoveryRunIds: [] }); + const api = await startM4ApiWhenLockFree({ + dbClient: client, + workspaceRoot, + recoveryRunIds: [], + maxConcurrentRuns: 100, + }); try { expect(api.engine).toBeInstanceOf(DbRunEngine); const { runId } = await api.engine.startRun({ @@ -313,6 +350,190 @@ describe("startApi", () => { } }); + it("uses the Temporal RunEngine by default without acquiring the SessionManager lock", async () => { + client = createDbClient(databaseUrl); + const first = await startM4ApiWhenLockFree({ + dbClient: client, + workspaceRoot: createApiWorkspaceRoot(), + recoveryRunIds: [], + maxConcurrentRuns: 100, + }); + const temporalClient = new FakeWorkflowClient(); + try { + const temporalApi = await startApi({ + dbClient: client, + temporalClient: temporalClient as unknown as WorkflowClient, + taskQueue: "devflow-runs-test", + workspaceRoot: createApiWorkspaceRoot(), + awaitRunStart: false, + }); + const runId = randomUUID(); + await temporalApi.engine.startRun({ + runId, + requirementsMd: "Temporal API should only dispatch workflow commands.", + repoPath: "/repo", + baseBranch: "main", + }); + expect(temporalClient.started).toMatchObject({ + taskQueue: "devflow-runs-test", + workflowId: `devflow-run:${runId}`, + }); + await temporalApi.stop(); + } finally { + await first.stop(); + } + }); + + it("wires Temporal approval replay side effects through the API boundary", async () => { + client = createDbClient(databaseUrl); + await seedDevelopmentRegistry(client.db); + const workspaceRoot = createApiWorkspaceRoot(); + const template = ( + await client.db + .select({ hash: workflowTemplates.hash, id: workflowTemplates.id }) + .from(workflowTemplates) + .where(eq(workflowTemplates.name, "development")) + .limit(1) + )[0]; + if (template === undefined) { + throw new Error("development template missing"); + } + const runId = randomUUID(); + const approvalRequestId = randomUUID(); + const clientToken = randomUUID(); + const repoPath = createGitRepo(); + const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-"))); + tempRoots.push(repoPath, worktreeRoot); + runIds.push(runId); + await client.db.insert(runs).values({ + id: runId, + templateId: template.id, + templateHash: template.hash, + state: "completed", + repoPath, + baseBranch: "main", + worktreeRoot, + endedAt: new Date(), + finalReportPath: null, + }); + await client.db.insert(approvalRequests).values({ + id: approvalRequestId, + runId, + gateKey: "spec_approved", + state: "approved", + idempotencyKey: `${runId}:spec_approved::1`, + payload: { replay: true }, + }); + await client.db.insert(approvalDecisions).values({ + approvalRequestId, + action: "approve", + idempotencyKey: `${approvalRequestId}:approve:${clientToken}`, + }); + + const temporalApi = await startApi({ + dbClient: client, + temporalClient: new FakeWorkflowClient() as unknown as WorkflowClient, + taskQueue: "devflow-runs-test", + workspaceRoot, + awaitRunStart: false, + }); + try { + await temporalApi.engine.signalApproval(runId, approvalRequestId, "approve", clientToken); + const [run] = await client.db + .select({ finalReportPath: runs.finalReportPath }) + .from(runs) + .where(eq(runs.id, runId)); + expect(run?.finalReportPath).toMatch(/\.report\.md$/); + } finally { + await temporalApi.stop(); + } + }); + + it.each([ + { action: "reject" as const, approvalState: "rejected", runState: "failed" }, + { action: "abort" as const, approvalState: "aborted", runState: "aborted" }, + ])( + "repairs $runState approval replay reports without mutating sessions through the API", + async ({ action, approvalState, runState }) => { + client = createDbClient(databaseUrl); + await seedDevelopmentRegistry(client.db); + const workspaceRoot = createApiWorkspaceRoot(); + const template = ( + await client.db + .select({ hash: workflowTemplates.hash, id: workflowTemplates.id }) + .from(workflowTemplates) + .where(eq(workflowTemplates.name, "development")) + .limit(1) + )[0]; + if (template === undefined) { + throw new Error("development template missing"); + } + const runId = randomUUID(); + const approvalRequestId = randomUUID(); + const clientToken = randomUUID(); + const sessionId = randomUUID(); + const repoPath = createGitRepo(); + const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-"))); + tempRoots.push(repoPath, worktreeRoot); + runIds.push(runId); + await client.db.insert(runs).values({ + id: runId, + templateId: template.id, + templateHash: template.hash, + state: runState, + repoPath, + baseBranch: "main", + worktreeRoot, + endedAt: new Date(), + finalReportPath: null, + }); + await client.db.insert(tuiSessions).values({ + id: sessionId, + runId, + roleId: "implementer", + backend: "fake", + cwd: worktreeRoot, + state: "READY", + }); + await client.db.insert(approvalRequests).values({ + id: approvalRequestId, + runId, + gateKey: "spec_approved", + state: approvalState, + idempotencyKey: `${runId}:spec_approved::1`, + payload: { replay: true }, + }); + await client.db.insert(approvalDecisions).values({ + approvalRequestId, + action, + idempotencyKey: `${approvalRequestId}:${action}:${clientToken}`, + }); + + const temporalApi = await startApi({ + dbClient: client, + temporalClient: new FakeWorkflowClient() as unknown as WorkflowClient, + taskQueue: "devflow-runs-test", + workspaceRoot, + awaitRunStart: false, + }); + try { + await temporalApi.engine.signalApproval(runId, approvalRequestId, action, clientToken); + const [run] = await client.db + .select({ finalReportPath: runs.finalReportPath }) + .from(runs) + .where(eq(runs.id, runId)); + expect(run?.finalReportPath).toMatch(/\.report\.md$/); + const [session] = await client.db + .select({ state: tuiSessions.state }) + .from(tuiSessions) + .where(eq(tuiSessions.id, sessionId)); + expect(session).toEqual({ state: "READY" }); + } finally { + await temporalApi.stop(); + } + }, + ); + it("repairs missing terminal final reports during API startup", async () => { client = createDbClient(databaseUrl); await seedDevelopmentRegistry(client.db); @@ -344,7 +565,12 @@ describe("startApi", () => { finalReportPath: null, }); - const api = await startApi({ dbClient: client, workspaceRoot, recoveryRunIds: [runId] }); + const api = await startM4ApiWhenLockFree({ + dbClient: client, + workspaceRoot, + recoveryRunIds: [runId], + maxConcurrentRuns: 100, + }); try { expect(api.finalReportRecovery).toEqual([runId]); const [run] = await client.db @@ -359,7 +585,7 @@ describe("startApi", () => { it("does not sweep active runs when a second API instance fails the singleton lock", async () => { client = createDbClient(databaseUrl); - const first = await startTestApi({ dbClient: client, recoveryRunIds: [] }); + const first = await startTestM4Api({ dbClient: client, recoveryRunIds: [] }); const templateId = randomUUID(); const runId = randomUUID(); const sessionId = randomUUID(); @@ -395,7 +621,11 @@ describe("startApi", () => { }); await expect( - startTestApi({ dbClient: client, recoveryRunIds: [runId] }), + startM4Api({ + dbClient: client, + workspaceRoot: createApiWorkspaceRoot(), + recoveryRunIds: [runId], + }), ).rejects.toMatchObject({ code: "session_manager_already_running", }); @@ -463,7 +693,7 @@ describe("startApi", () => { state: "READY", }); - const result = await startTestApi({ + const result = await startTestM4Api({ dbClient: client, recoveryRunIds: [runId], sessionAdapter: adapter, @@ -491,6 +721,82 @@ describe("startApi", () => { expect(events).toEqual([]); }); + it("fails CREATED session reservations during SessionManager startup recovery", async () => { + client = createDbClient(databaseUrl); + const templateId = randomUUID(); + const runId = randomUUID(); + const sessionId = randomUUID(); + const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-"))); + const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-"))); + tempRoots.push(repoPath, worktreeRoot); + templateIds.push(templateId); + runIds.push(runId); + + await client.db.insert(workflowTemplates).values({ + id: templateId, + name: `api-session-created-${templateId}`, + version: 1, + hash: "f".repeat(64), + definition: { name: "api-session-created", version: 1, roles: [], phases: [] }, + }); + await client.db.insert(runs).values({ + id: runId, + templateId, + templateHash: "f".repeat(64), + state: "executing", + repoPath, + baseBranch: "main", + worktreeRoot, + }); + await client.db.insert(tuiSessions).values({ + id: sessionId, + runId, + roleId: "spec_writer", + backend: "fake", + cwd: worktreeRoot, + state: "CREATED", + }); + + const adapter = new ResumeFailsFakeSessionAdapter(); + const manager = new SessionManager({ + dbClient: client, + adapter, + recoveryRunIds: [runId], + }); + const recovery = await initializeManagerWhenLockFree(manager); + try { + expect(adapter.resumeAttempts).toBe(3); + expect(recovery).toEqual({ failedSessionIds: [sessionId], recoveredSessionIds: [] }); + } finally { + await manager.shutdown(); + } + const [run] = await client.db + .select({ pausedFromState: runs.pausedFromState, state: runs.state }) + .from(runs) + .where(eq(runs.id, runId)); + expect(run).toEqual({ pausedFromState: "executing", state: "paused" }); + const [session] = await client.db + .select({ recoveryAttempts: tuiSessions.recoveryAttempts, state: tuiSessions.state }) + .from(tuiSessions) + .where(eq(tuiSessions.id, sessionId)); + expect(session).toEqual({ recoveryAttempts: 1, state: "FAILED_NEEDS_HUMAN" }); + const approvals = await client.db + .select({ gateKey: approvalRequests.gateKey, state: approvalRequests.state }) + .from(approvalRequests) + .where(eq(approvalRequests.runId, runId)); + expect(approvals).toEqual([{ gateKey: "session_recovery_required", state: "pending" }]); + const events = await client.db + .select({ type: runEvents.type }) + .from(runEvents) + .where(eq(runEvents.runId, runId)) + .orderBy(runEvents.seq); + expect(events.map((event) => event.type)).toEqual([ + "session.failed", + "run.paused", + "approval.requested", + ]); + }); + it("retries transient session resume failures during startup recovery", async () => { client = createDbClient(databaseUrl); const templateId = randomUUID(); @@ -542,7 +848,7 @@ describe("startApi", () => { adapter, recoveryRunIds: [runId], }); - const recovery = await manager.initialize(); + const recovery = await initializeManagerWhenLockFree(manager); try { expect(adapter.resumeAttempts).toBe(3); expect(recovery).toEqual({ failedSessionIds: [], recoveredSessionIds: [sessionId] }); @@ -598,7 +904,7 @@ describe("startApi", () => { adapter, recoveryRunIds: [runId], }); - const recovery = await manager.initialize(); + const recovery = await initializeManagerWhenLockFree(manager); try { expect(adapter.resumeAttempts).toBe(3); expect(recovery).toEqual({ failedSessionIds: [sessionId], recoveredSessionIds: [] }); @@ -649,7 +955,7 @@ describe("startApi", () => { recoveryRunIds: [], shutdownDrainMs: 5_000, }); - await manager.initialize(); + await initializeManagerWhenLockFree(manager); const runId = randomUUID(); const cwd = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-session-"))); tempRoots.push(cwd); @@ -691,7 +997,7 @@ describe("startApi", () => { adapter: new FakeSessionAdapter(), recoveryRunIds: [], }); - await expect(nextManager.initialize()).resolves.toEqual({ + await expect(initializeManagerWhenLockFree(nextManager)).resolves.toEqual({ failedSessionIds: [], recoveredSessionIds: [], }); @@ -706,10 +1012,11 @@ describe("startApi", () => { tempRoots.push(repoPath); const runId = randomUUID(); runIds.push(runId); - const api = await startApi({ + const api = await startM4ApiWhenLockFree({ dbClient: client, workspaceRoot, recoveryRunIds: [], + maxConcurrentRuns: 100, sessionAdapter: new FakeSessionAdapter({ writeDelayMs: 1_000 }), }); const startPromise = api.engine.startRun({ @@ -737,10 +1044,44 @@ describe("startApi", () => { adapter: new FakeSessionAdapter(), recoveryRunIds: [], }); - await expect(nextManager.initialize()).resolves.toEqual({ + await expect(initializeManagerWhenLockFree(nextManager)).resolves.toEqual({ failedSessionIds: [], recoveredSessionIds: [], }); await nextManager.shutdown(); }); }); + +async function startM4ApiWhenLockFree(options: Parameters[0]) { + const deadline = Date.now() + 6_000; + let lastError: unknown; + while (Date.now() < deadline) { + try { + return await startM4Api(options); + } catch (error) { + lastError = error; + if (!(error instanceof DevflowError) || error.code !== "session_manager_already_running") { + throw error; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 50)); + } + } + throw lastError; +} + +async function initializeManagerWhenLockFree(manager: SessionManager) { + const deadline = Date.now() + 6_000; + let lastError: unknown; + while (Date.now() < deadline) { + try { + return await manager.initialize(); + } catch (error) { + lastError = error; + if (!(error instanceof DevflowError) || error.code !== "session_manager_already_running") { + throw error; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 50)); + } + } + throw lastError; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ae787ec..289e83b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -4,19 +4,22 @@ import { fileURLToPath } from "node:url"; import { type BackendConfig, getConfig } from "@devflow/core"; import { DevflowError } from "@devflow/core"; import { type DbClient, createDbClient } from "@devflow/db"; -import { DbRunEngine, type RunEngine } from "@devflow/run-engine"; +import { DbRunEngine, type RunEngine, readRunStatus } from "@devflow/run-engine"; import { FakeSessionAdapter, type SessionAdapter, SessionManager, type SessionManagerRecoveryResult, + type SessionRuntime, } from "@devflow/session"; +import { TemporalRunEngine, temporalNamespace } from "@devflow/workflows"; +import { Connection, WorkflowClient } from "@temporalio/client"; import { recoverM4ApiStartup, startM4SessionManager } from "./startup.js"; export * from "./startup.js"; -export interface StartApiOptions { +export interface StartM4ApiOptions { dbClient?: DbClient; workspaceRoot?: string; availableBackends?: readonly BackendConfig[]; @@ -24,9 +27,10 @@ export interface StartApiOptions { sessionAdapter?: SessionAdapter; sessionManager?: SessionManager; runEngine?: RunEngine; + maxConcurrentRuns?: number; } -export interface StartApiResult { +export interface StartM4ApiResult { recovery: Awaited>; sessionRecovery: SessionManagerRecoveryResult; sessionManager: SessionManager; @@ -35,7 +39,32 @@ export interface StartApiResult { stop(): Promise; } +export interface StartTemporalApiOptions { + dbClient?: DbClient; + temporalClient?: WorkflowClient; + temporalAddress?: string; + taskQueue?: string; + workflowIdPrefix?: string; + awaitRunStart?: boolean; + awaitSignals?: boolean; + availableBackends?: readonly BackendConfig[]; + maxConcurrentRuns?: number; + workspaceRoot?: string; +} + +export interface StartTemporalApiResult { + engine: RunEngine; + stop(): Promise; +} + +export type StartApiOptions = StartTemporalApiOptions; +export type StartApiResult = StartTemporalApiResult; + export async function startApi(options: StartApiOptions = {}): Promise { + return startTemporalApi(options); +} + +export async function startM4Api(options: StartM4ApiOptions = {}): Promise { const ownedClient = options.dbClient === undefined; const config = ownedClient || options.workspaceRoot === undefined ? getConfig() : undefined; const dbClient = @@ -58,6 +87,9 @@ export async function startApi(options: StartApiOptions = {}): Promise { + const ownedClient = options.dbClient === undefined; + const config = + options.dbClient === undefined || options.temporalClient === undefined + ? getConfig() + : undefined; + const dbClient = + options.dbClient ?? createDbClient(config?.DATABASE_URL ?? getConfig().DATABASE_URL); + const ownedTemporalClient = options.temporalClient === undefined; + let connection: Connection | undefined; + let temporalClient: WorkflowClient; + if (options.temporalClient === undefined) { + connection = await Connection.connect({ + address: options.temporalAddress ?? config?.TEMPORAL_ADDRESS ?? getConfig().TEMPORAL_ADDRESS, + }); + temporalClient = new WorkflowClient({ connection, namespace: temporalNamespace }); + } else { + temporalClient = options.temporalClient; + } + const replayValidationWorkspaceRoot = + options.workspaceRoot ?? config?.WORKSPACE_ROOT ?? getConfig().WORKSPACE_ROOT; + const replayValidationBackends = options.availableBackends ?? config?.backends; + const replayValidationMaxConcurrentRuns = + options.maxConcurrentRuns ?? config?.MAX_CONCURRENT_RUNS; + const replayValidationEngine = new DbRunEngine({ + db: dbClient.db, + sessions: dbOnlySessionRuntime(), + workspaceRoot: replayValidationWorkspaceRoot, + ...(replayValidationBackends === undefined + ? {} + : { availableBackends: replayValidationBackends }), + ...(replayValidationMaxConcurrentRuns === undefined + ? {} + : { maxConcurrentRuns: replayValidationMaxConcurrentRuns }), + }); + const engine = new TemporalRunEngine({ + client: temporalClient, + startReplayValidator: { + validateStartReplay: (input) => replayValidationEngine.validatePreparedRunInput(input), + }, + approvalSignalReader: { + readApprovalSignalResult: (runId, approvalRequestId, action, clientToken) => + replayValidationEngine.readApprovalSignalResult( + runId, + approvalRequestId, + action, + clientToken, + ), + validateApprovalSignalInput: (runId, approvalRequestId, action, clientToken) => + replayValidationEngine.validateApprovalSignalInput( + runId, + approvalRequestId, + action, + clientToken, + ), + replayAppliedApprovalSideEffects: (runId, action) => + replayValidationEngine.replayAppliedApprovalSideEffects(runId, action, { + disposeSessions: false, + }), + }, + controlValidator: { + validateResumeSignalInput: (runId) => replayValidationEngine.validateResumeSignalInput(runId), + }, + statusReader: { + getStatus: (runId) => readRunStatus(dbClient.db, runId), + }, + ...(options.taskQueue === undefined ? {} : { taskQueue: options.taskQueue }), + ...(options.workflowIdPrefix === undefined + ? {} + : { workflowIdPrefix: options.workflowIdPrefix }), + ...(options.awaitRunStart === undefined ? {} : { awaitRunStart: options.awaitRunStart }), + ...(options.awaitSignals === undefined ? {} : { awaitSignals: options.awaitSignals }), + }); + + return { + engine, + async stop() { + if (ownedTemporalClient) { + await connection?.close(); + } + if (ownedClient) { + await dbClient.close(); + } + }, + }; +} + +function dbOnlySessionRuntime(): SessionRuntime { + const rejectMutation = (operation: string) => + Promise.reject( + new DevflowError("API replay validation cannot mutate TUI sessions", { + class: "fatal", + code: "internal_state_corruption", + recoveryHint: operation, + }), + ); + + return { + trackOperation: (operation) => operation, + start: () => rejectMutation("start"), + sendPrompt: () => rejectMutation("sendPrompt"), + probe: () => rejectMutation("probe"), + resume: () => rejectMutation("resume"), + rebootstrap: () => rejectMutation("rebootstrap"), + async *capture() { + yield await rejectMutation("capture"); + }, + dispose: () => rejectMutation("dispose"), + }; +} + if (isDirectEntry(import.meta.url, process.argv)) { startApi() .then(async (api) => { diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 3f2aeb1..3507707 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -10,6 +10,7 @@ { "path": "../../packages/core" }, { "path": "../../packages/db" }, { "path": "../../packages/run-engine" }, - { "path": "../../packages/session" } + { "path": "../../packages/session" }, + { "path": "../../packages/workflows" } ] } diff --git a/apps/cli/src/doctor.test.ts b/apps/cli/src/doctor.test.ts index 300392e..9fa9345 100644 --- a/apps/cli/src/doctor.test.ts +++ b/apps/cli/src/doctor.test.ts @@ -60,6 +60,7 @@ describe("doctor", () => { DATABASE_URL: "postgres://devflow:devflow@127.0.0.1:55432/devflow", WORKSPACE_ROOT: process.cwd(), LOG_LEVEL: "info", + TEMPORAL_ADDRESS: "localhost:7233", }, nodeVersion: "22.11.0", }); @@ -114,6 +115,7 @@ describe("doctor", () => { DATABASE_URL: "postgres://devflow:devflow@127.0.0.1:55432/devflow", WORKSPACE_ROOT: process.cwd(), LOG_LEVEL: "info", + TEMPORAL_ADDRESS: "localhost:7233", }, nodeVersion: "22.11.0", }); diff --git a/apps/cli/src/doctor.ts b/apps/cli/src/doctor.ts index 82c50f8..967443d 100644 --- a/apps/cli/src/doctor.ts +++ b/apps/cli/src/doctor.ts @@ -259,7 +259,11 @@ async function checkWorkspaceRoot(config?: Config, configError?: unknown): Promi function checkConfig(config?: Config, configError?: unknown): DoctorResult { return config ? pass("config", "valid", ".env resolved to a valid Config") - : fail("config", errorDetail(configError), "Set DATABASE_URL, WORKSPACE_ROOT, and LOG_LEVEL"); + : fail( + "config", + errorDetail(configError), + "Set DATABASE_URL, WORKSPACE_ROOT, LOG_LEVEL, and TEMPORAL_ADDRESS", + ); } async function checkOptionalBinary( diff --git a/apps/worker/package.json b/apps/worker/package.json new file mode 100644 index 0000000..766682e --- /dev/null +++ b/apps/worker/package.json @@ -0,0 +1,19 @@ +{ + "name": "@devflow/worker", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsup src/index.ts --format esm --clean --external @temporalio/worker --external @temporalio/client --external @temporalio/workflow", + "typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit", + "test": "cd ../.. && vitest run --project apps/worker" + }, + "dependencies": { + "@devflow/core": "workspace:*", + "@devflow/db": "workspace:*", + "@devflow/session": "workspace:*", + "@devflow/workflows": "workspace:*", + "@temporalio/client": "^1.17.1", + "@temporalio/worker": "^1.17.1" + } +} diff --git a/apps/worker/src/index.test.ts b/apps/worker/src/index.test.ts new file mode 100644 index 0000000..ff4ab3b --- /dev/null +++ b/apps/worker/src/index.test.ts @@ -0,0 +1,275 @@ +import { randomUUID } from "node:crypto"; +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { DevflowError } from "@devflow/core"; +import { + type DbClient, + createDbClient, + runEvents, + runs, + tuiSessions, + workflowTemplates, +} from "@devflow/db"; +import { FakeSessionAdapter, type SessionAdapter, type SessionHandle } from "@devflow/session"; +import { eq, inArray } from "drizzle-orm"; +import { afterEach, describe, expect, it } from "vitest"; + +import { startWorker } from "./index.js"; + +const databaseUrl = + process.env.DATABASE_URL ?? "postgres://devflow:devflow@127.0.0.1:55432/devflow"; + +class ResumeTrackingAdapter extends FakeSessionAdapter { + resumeAttempts = 0; + + override async resume(handle: SessionHandle): Promise { + this.resumeAttempts += 1; + return super.resume(handle); + } +} + +describe("startWorker", () => { + let client: DbClient | undefined; + const runIds: string[] = []; + const templateIds: string[] = []; + const tempRoots: string[] = []; + + afterEach(async () => { + if (client !== undefined) { + if (runIds.length > 0) { + await client.db.delete(runs).where(inArray(runs.id, [...runIds])); + } + if (templateIds.length > 0) { + await client.db + .delete(workflowTemplates) + .where(inArray(workflowTemplates.id, [...templateIds])); + } + await client.close(); + client = undefined; + } + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } + runIds.length = 0; + templateIds.length = 0; + }); + + it("initializes SessionManager recovery before accepting Temporal work", async () => { + client = createDbClient(databaseUrl); + const templateId = randomUUID(); + const runId = randomUUID(); + const sessionId = randomUUID(); + const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-repo-"))); + const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-worktree-"))); + tempRoots.push(repoPath, worktreeRoot); + templateIds.push(templateId); + runIds.push(runId); + + await client.db.insert(workflowTemplates).values({ + id: templateId, + name: `worker-recovery-${templateId}`, + version: 1, + hash: "f".repeat(64), + definition: { name: "worker-recovery", version: 1, roles: [], phases: [] }, + }); + await client.db.insert(runs).values({ + id: runId, + templateId, + templateHash: "f".repeat(64), + state: "executing", + repoPath, + baseBranch: "main", + worktreeRoot, + }); + await client.db.insert(tuiSessions).values({ + id: sessionId, + runId, + roleId: "spec_writer", + backend: "fake", + cwd: worktreeRoot, + state: "BOOTSTRAPPING", + }); + + const adapter = new ResumeTrackingAdapter({ + sessionIdFactory: () => sessionId, + writeDelayMs: 0, + }); + await adapter.start({ + runId, + roleId: "spec_writer", + backend: "fake", + cwd: worktreeRoot, + }); + + const worker = await startWorkerWhenLockFree({ + config: { + DATABASE_URL: databaseUrl, + LOG_LEVEL: "info", + TEMPORAL_ADDRESS: "localhost:7233", + WORKSPACE_ROOT: worktreeRoot, + MAX_CONCURRENT_RUNS: 4, + backends: [{ id: "fake", enabled: true }], + }, + dbClient: client, + recoveryRunIds: [runId], + sessionAdapter: adapter, + connectionFactory: async () => fakeConnection(), + workerFactory: async () => fakeWorker(), + }); + + try { + expect(worker.recovery).toEqual({ failedSessionIds: [], recoveredSessionIds: [sessionId] }); + expect(adapter.resumeAttempts).toBe(1); + const [session] = await client.db + .select({ state: tuiSessions.state }) + .from(tuiSessions) + .where(eq(tuiSessions.id, sessionId)); + expect(session).toEqual({ state: "READY" }); + const events = await client.db + .select({ type: runEvents.type }) + .from(runEvents) + .where(eq(runEvents.runId, runId)) + .orderBy(runEvents.seq); + expect(events.map((event) => event.type)).toEqual(["session.created", "session.ready"]); + } finally { + await worker.shutdown(); + } + }); + + it("releases acquired resources when SessionManager startup fails", async () => { + client = createDbClient(databaseUrl); + const adapter: SessionAdapter = new FakeSessionAdapter(); + const first = await startWorkerWhenLockFree({ + config: { + DATABASE_URL: databaseUrl, + LOG_LEVEL: "info", + TEMPORAL_ADDRESS: "localhost:7233", + WORKSPACE_ROOT: realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-workspace-"))), + MAX_CONCURRENT_RUNS: 4, + backends: [{ id: "fake", enabled: true }], + }, + dbClient: client, + recoveryRunIds: [], + sessionAdapter: adapter, + connectionFactory: async () => fakeConnection(), + workerFactory: async () => fakeWorker(), + }); + try { + await expect( + startWorker({ + config: { + DATABASE_URL: databaseUrl, + LOG_LEVEL: "info", + TEMPORAL_ADDRESS: "localhost:7233", + WORKSPACE_ROOT: realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-workspace-"))), + MAX_CONCURRENT_RUNS: 4, + backends: [{ id: "fake", enabled: true }], + }, + dbClient: client, + recoveryRunIds: [], + connectionFactory: async () => fakeConnection(), + workerFactory: async () => fakeWorker(), + }), + ).rejects.toMatchObject({ code: "session_manager_already_running" }); + } finally { + await first.shutdown(); + } + }); + + it("drains SessionManager resources when the Temporal worker run loop stops", async () => { + client = createDbClient(databaseUrl); + const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-run-"))); + tempRoots.push(workspaceRoot); + const connection = countingConnection(); + const runtime = countingWorker(); + const worker = await startWorkerWhenLockFree({ + config: { + DATABASE_URL: databaseUrl, + LOG_LEVEL: "info", + TEMPORAL_ADDRESS: "localhost:7233", + WORKSPACE_ROOT: workspaceRoot, + MAX_CONCURRENT_RUNS: 4, + backends: [{ id: "fake", enabled: true }], + }, + dbClient: client, + recoveryRunIds: [], + connectionFactory: async () => connection, + workerFactory: async () => runtime, + }); + + await worker.run(); + expect(runtime.runs).toBe(1); + expect(runtime.shutdowns).toBe(1); + expect(connection.closes).toBe(1); + + const next = await startWorkerWhenLockFree({ + config: { + DATABASE_URL: databaseUrl, + LOG_LEVEL: "info", + TEMPORAL_ADDRESS: "localhost:7233", + WORKSPACE_ROOT: workspaceRoot, + MAX_CONCURRENT_RUNS: 4, + backends: [{ id: "fake", enabled: true }], + }, + dbClient: client, + recoveryRunIds: [], + connectionFactory: async () => fakeConnection(), + workerFactory: async () => fakeWorker(), + }); + await next.shutdown(); + }); +}); + +function fakeConnection() { + return { + close: async () => undefined, + }; +} + +function fakeWorker() { + return { + run: async () => undefined, + shutdown: () => undefined, + }; +} + +function countingConnection() { + return { + closes: 0, + async close() { + this.closes += 1; + }, + }; +} + +function countingWorker() { + return { + runs: 0, + shutdowns: 0, + async run() { + this.runs += 1; + }, + shutdown() { + this.shutdowns += 1; + }, + }; +} + +async function startWorkerWhenLockFree(options: Parameters[0]) { + const deadline = Date.now() + 6_000; + let lastError: unknown; + while (Date.now() < deadline) { + try { + return await startWorker(options); + } catch (error) { + lastError = error; + if (!(error instanceof DevflowError) || error.code !== "session_manager_already_running") { + throw error; + } + await new Promise((resolveWait) => setTimeout(resolveWait, 50)); + } + } + throw lastError; +} diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts new file mode 100644 index 0000000..da8789e --- /dev/null +++ b/apps/worker/src/index.ts @@ -0,0 +1,127 @@ +import { fileURLToPath } from "node:url"; + +import { type Config, DevflowError, getConfig } from "@devflow/core"; +import { type DbClient, createDbClient } from "@devflow/db"; +import { FakeSessionAdapter, type SessionAdapter, SessionManager } from "@devflow/session"; +import { NativeConnection, Worker } from "@temporalio/worker"; + +import { createDevflowActivities, temporalTaskQueue } from "@devflow/workflows"; + +interface WorkerConnection { + close(): Promise; +} + +interface WorkerRuntime { + run(): Promise; + shutdown(): void | Promise; +} + +export interface StartWorkerOptions { + config?: Config; + dbClient?: DbClient; + sessionAdapter?: SessionAdapter; + recoveryRunIds?: readonly string[]; + temporalAddress?: string; + taskQueue?: string; + connectionFactory?: (options: { address: string }) => Promise; + workerFactory?: (options: Parameters[0]) => Promise; +} + +export async function startWorker(options: StartWorkerOptions = {}) { + const config = options.config ?? getConfig(); + const ownedClient = options.dbClient === undefined; + const dbClient = options.dbClient ?? createDbClient(config.DATABASE_URL); + const sessionManager = new SessionManager({ + dbClient, + adapter: options.sessionAdapter ?? new FakeSessionAdapter(), + ...(options.recoveryRunIds === undefined ? {} : { recoveryRunIds: options.recoveryRunIds }), + }); + let connection: WorkerConnection | undefined; + let worker: WorkerRuntime | undefined; + + try { + const recovery = await sessionManager.initialize(); + connection = await (options.connectionFactory ?? NativeConnection.connect)({ + address: options.temporalAddress ?? config.TEMPORAL_ADDRESS, + }); + worker = await (options.workerFactory ?? Worker.create)({ + activities: createDevflowActivities({ + db: dbClient.db, + sessions: sessionManager, + workspaceRoot: config.WORKSPACE_ROOT, + availableBackends: config.backends, + maxConcurrentRuns: config.MAX_CONCURRENT_RUNS, + }), + connection: connection as NativeConnection, + namespace: "devflow", + taskQueue: options.taskQueue ?? temporalTaskQueue, + workflowsPath: fileURLToPath( + new URL("../../../packages/workflows/src/workflow.ts", import.meta.url), + ), + }); + + const startedWorker = worker; + const startedConnection = connection; + if (startedWorker === undefined || startedConnection === undefined) { + throw new DevflowError("Temporal worker failed to initialize", { + class: "fatal", + code: "internal_state_corruption", + }); + } + let shutdownPromise: Promise | undefined; + const shutdown = () => { + shutdownPromise ??= (async () => { + await Promise.resolve(startedWorker.shutdown()); + await sessionManager.shutdown(); + await startedConnection.close(); + if (ownedClient) { + await dbClient.close(); + } + })(); + return shutdownPromise; + }; + return { + recovery, + async run() { + try { + await startedWorker.run(); + } finally { + await shutdown(); + } + }, + shutdown, + }; + } catch (error) { + if (worker !== undefined) { + await Promise.resolve(worker.shutdown()).catch(() => undefined); + } + if (connection !== undefined) { + await connection.close().catch(() => undefined); + } + await sessionManager.shutdown().catch(() => undefined); + if (ownedClient) { + await dbClient.close().catch(() => undefined); + } + throw error; + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + startWorker() + .then(async (worker) => { + const requestShutdown = () => { + void worker.shutdown().catch((error: unknown) => { + console.error(error); + process.exitCode = 2; + }); + }; + process.once("SIGINT", requestShutdown); + process.once("SIGTERM", requestShutdown); + await worker.run(); + }) + .catch((error: unknown) => { + console.error(error); + process.exitCode = + error instanceof DevflowError && error.code === "session_manager_already_running" ? 3 : 2; + }); +} diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json new file mode 100644 index 0000000..227d2a1 --- /dev/null +++ b/apps/worker/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node", "vitest"] + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../../packages/core" }, + { "path": "../../packages/db" }, + { "path": "../../packages/session" }, + { "path": "../../packages/workflows" } + ] +} diff --git a/docs/plan.md b/docs/plan.md index ba37e4c..f0ce057 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1,4 +1,4 @@ -# Devflow Implementation Plan v3 r9 +# Devflow Implementation Plan v3 r12 ## 0. Document Status @@ -16,6 +16,9 @@ - r7 applies CC-21 through CC-23. - r8 applies CC-24 through CC-26. - r9 applies CC-27 through CC-28. +- r10 applies CC-29 through CC-31. +- r11 applies CC-32. +- r12 applies CC-33 through CC-35. ## 1. Stack Decisions @@ -1206,6 +1209,9 @@ Replay rules: - `phase.started.payload.repair === true` marks that attempt as the single allowed repair attempt. Replaying that attempt MUST use repair instructions, `prompt.repaired`, and must not start a third attempt. - Repair replay from `running` may reuse an existing `READY` / bootstrapped session even if `last_prompt_hash` still contains the previous attempt's prompt hash; current-attempt prompt send has not happened yet. +- If phase state is `running`, existing artifact files are never accepted unless the current prompt event (`prompt.sent` or `prompt.repaired`) for the current dedup key is already recorded. Replay without prompt proof treats existing files as stale. +- If phase state is `running`, session state is `BUSY`, and `last_prompt_hash` matches the current prompt but the matching prompt event is missing, replay waits for the artifact with the current file signature as the baseline. This preserves idempotency without validating a stale pre-existing artifact. +- Baseline-protected waits must not synthesize durable prompt proof before the wait finishes. If replay crashes or is cancelled before validation, the next replay must still treat the existing artifact as baseline/stale unless real prompt proof already exists. - If phase state is `validating` and no artifact row exists yet, replay re-reads and validates the current `expectedArtifactPath` instead of treating the state as corruption. - If phase state is `validating` and artifact rows already exist for the same phase/path/schema, replay may reuse only an artifact row created at or after the current session `last_prompt_at`; older rows are treated as stale previous-attempt outputs and the file is revalidated. - Session bootstrap DB row/state changes and `session.created` / `session.ready` events are written in one DB transaction after adapter start succeeds. @@ -1328,22 +1334,31 @@ interface RunEngine { Activities: -- `lockBindings(input)` -- `generatePhasePlan(runId, phaseKey, attempt)` -- `sendPromptToSession(sessionId, envelope)` -- `waitForArtifact(sessionId, expectedPath, expectedSchema, timeoutMs)` -- `validateArtifact(artifactPath, expectedSchema)` -- `recordEvent(runId, type, payload)` -- `requestApproval(runId, gateKey, phaseId, payload, idempotencyKey)` -- `runCommand(kind, argv, cwd, env)` -- `composeFinalReport(runId)` +- M5 compatibility activity surface: + - `prepareRunActivity(input)` + - `lockBindingsActivity(runId)` + - `failRunActivity(runId, reason)` + - `advanceRunActivity(runId)` + - `signalApprovalActivity(runId, approvalRequestId, action, clientToken, comment?)` + - `pauseRunActivity(runId)` + - `resumeRunActivity(runId)` + - `abortRunActivity(runId, reason)` + - `getStatusActivity(runId)` + - `isRunTerminalActivity(runId)` + - `composeFinalReportActivity(runId)` +- `advanceRunActivity` is the M5 parity wrapper over M4 phase advancement. It may internally perform prompt send, artifact wait/validation, event recording, and approval request creation through the same DB/idempotency contracts already locked in sections 8 through 14. +- The granular activity split (`sendPromptToSession`, `waitForArtifact`, `validateArtifact`, `recordEvent`, `requestApproval`, `runCommand`) is deferred to a later hardening ADR. It is not an M5 acceptance gate. +- Prompt/session mutation still occurs only inside worker-hosted activities through SessionManager. M5+ API code never mutates `SessionAdapter` directly. Retry policy: - Default: max attempts 3, exponential backoff start 1s, max 30s. -- `requestApproval`: max attempts 1. -- `composeFinalReport`: max attempts 1. -- `sendPromptToSession`: max attempts 2; further retry belongs to engine recovery. +- `composeFinalReportActivity`: max attempts 1. +- Activity-level failures serialize `DevflowError`; non-recoverable Devflow errors are rethrown as non-retryable Temporal failures. +- `advanceRunActivity` is cancellation-aware and idempotent by DB state, event idempotency keys, prompt dedup keys, and artifact content keys. +- Already-applied approval signal replay repairs missing final reports for every terminal run state: `completed`, `failed`, and `aborted`, regardless of whether the replayed approval action was `approve`, `request_changes`, `reject`, or `abort`. +- API-side already-applied approval replay is report-repair only. It must not call `SessionAdapter` mutation methods; reject/abort session disposal belongs to the worker/session-manager path that originally applies the decision. +- If a workflow closes before the API observes an approval signal result, closed-workflow settlement must first verify the requested decision was applied, then replay approval side effects, then wait for the terminal report. ### 15.3 Hard Constraints @@ -1746,6 +1761,13 @@ M5+: | CC-26 | Session bootstrap state/events could diverge | session row/state and `session.created` / `session.ready` events are committed in one DB transaction | | CC-27 | `validating` replay could reuse stale previous-attempt artifact rows | artifact-row replay requires `artifact.created_at >= tui_sessions.last_prompt_at`; otherwise the file is revalidated | | CC-28 | repair `running` replay rejected existing READY sessions with previous attempt prompt hash | current-attempt repair prompt is considered unsent, so replay may reuse the session and send `prompt.repaired` | +| CC-29 | API Temporal approval replay omitted M4 approval side-effect repair | API approval signal reader now wires `replayAppliedApprovalSideEffects`, so already-applied terminal approval replays can repair missing final reports | +| CC-30 | `running` replay could validate stale artifacts without prompt proof | `running` replay requires matching prompt event proof; BUSY replay without prompt event uses current artifact signature as baseline and ignores stale files | +| CC-31 | M5 activity list over-specified granular activities not implemented by the M4 parity adapter | M5 locks the compatibility activity wrapper surface; granular activity split is deferred to a later hardening ADR | +| CC-32 | Already-applied `approve` / `request_changes` replay repaired missing reports for `completed` / `failed` but missed `aborted` | approval replay side-effect repair now composes missing final reports for all terminal states | +| CC-33 | API-side already-applied `reject` / `abort` replay tried to dispose sessions through DB-only replay validation runtime | API replay side effects are report-repair only; worker-side decision application owns session disposal | +| CC-34 | Closed-workflow approval settlement waited for reports but did not replay approval side effects | settlement now verifies the requested decision, replays side effects, then waits for the terminal report | +| CC-35 | Baseline-protected BUSY replay recorded synthetic prompt proof before the baseline wait was durable | baseline replay no longer records synthetic prompt events; replay without real prompt proof keeps treating existing files as stale | ### Future Open Questions diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index 3632da8..d783ffa 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -17,6 +17,7 @@ describe("config loader", () => { "DATABASE_URL=postgres://env:env@localhost:5432/env", "WORKSPACE_ROOT=workspace", "LOG_LEVEL=warn", + "TEMPORAL_ADDRESS=localhost:7233", ].join("\n"), ); writeFileSync(join(root, ".env.local"), "LOG_LEVEL=debug\n"); @@ -44,6 +45,7 @@ describe("config loader", () => { DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow", WORKSPACE_ROOT: workspace, LOG_LEVEL: "info", + TEMPORAL_ADDRESS: "localhost:7233", }, }); @@ -66,6 +68,7 @@ describe("config loader", () => { DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow", WORKSPACE_ROOT: workspace, LOG_LEVEL: "info", + TEMPORAL_ADDRESS: "localhost:7233", PATH: binDir, DEVFLOW_BACKENDS_JSON: JSON.stringify([{ id: "codex", enabled: true }]), }, @@ -90,6 +93,7 @@ describe("config loader", () => { DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow", WORKSPACE_ROOT: workspace, LOG_LEVEL: "info", + TEMPORAL_ADDRESS: "localhost:7233", PATH: emptyBin, DEVFLOW_BACKENDS_JSON: JSON.stringify([{ id: "codex", enabled: true }]), }, @@ -125,6 +129,23 @@ describe("config loader", () => { expect((caught as DevflowError).cause).toBeDefined(); }); + it("requires TEMPORAL_ADDRESS at M5", () => { + const root = mkdtempSync(join(tmpdir(), "devflow-config-")); + const workspace = join(root, "workspace"); + mkdirSync(workspace); + + expect(() => + loadConfigFromSources({ + cwd: root, + env: { + DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow", + WORKSPACE_ROOT: workspace, + LOG_LEVEL: "info", + }, + }), + ).toThrow(DevflowError); + }); + it("classifies malformed backend JSON as invalid config", () => { const root = mkdtempSync(join(tmpdir(), "devflow-config-")); const workspace = join(root, "workspace"); @@ -137,6 +158,7 @@ describe("config loader", () => { DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow", WORKSPACE_ROOT: workspace, LOG_LEVEL: "info", + TEMPORAL_ADDRESS: "localhost:7233", DEVFLOW_BACKENDS_JSON: "{", }, }), @@ -154,6 +176,7 @@ describe("config loader", () => { DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow", WORKSPACE_ROOT: workspace, LOG_LEVEL: "info", + TEMPORAL_ADDRESS: "localhost:7233", }, }); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index b134f0c..13a7532 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -20,7 +20,7 @@ const RawConfigSchema = z.object({ DATABASE_URL: z.string().min(1), WORKSPACE_ROOT: z.string().min(1), LOG_LEVEL: LogLevel, - TEMPORAL_ADDRESS: z.string().optional(), + TEMPORAL_ADDRESS: z.string().min(1), MAX_CONCURRENT_RUNS: z.coerce.number().int().positive().default(4), backends: z.array(BackendConfig).default([{ id: "fake", enabled: true }]), }); diff --git a/packages/run-engine/src/engine.test.ts b/packages/run-engine/src/engine.test.ts index 80d9269..67663a1 100644 --- a/packages/run-engine/src/engine.test.ts +++ b/packages/run-engine/src/engine.test.ts @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { existsSync, + mkdirSync, mkdtempSync, readFileSync, realpathSync, @@ -84,6 +85,15 @@ class PausesAfterPromptAcceptedFakeAdapter extends FakeSessionAdapter { } } +class DisposeCountingFakeAdapter extends FakeSessionAdapter { + disposeCalls = 0; + + override async dispose(handle: Parameters[0]): Promise { + this.disposeCalls += 1; + await super.dispose(handle); + } +} + describe("DbRunEngine", () => { let client: DbClient | undefined; const runIds: string[] = []; @@ -129,6 +139,7 @@ describe("DbRunEngine", () => { db: client.db, sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), workspaceRoot, + maxConcurrentRuns: 100, wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, }); @@ -281,6 +292,7 @@ describe("DbRunEngine", () => { db: client.db, sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), workspaceRoot, + maxConcurrentRuns: 100, wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, }); @@ -357,6 +369,118 @@ describe("DbRunEngine", () => { }); }); + it("validates a prepared run replay without accepting changed start inputs", async () => { + client = createDbClient(databaseUrl); + await seedDevelopmentRegistry(client.db); + const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-engine-workspace-"))); + const repoPath = createGitRepo(); + tempRoots.push(workspaceRoot, repoPath); + const engine = new DbRunEngine({ + db: client.db, + sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), + maxConcurrentRuns: 100, + workspaceRoot, + }); + const runId = randomUUID(); + const input = { + runId, + requirementsMd: "Validate replayed Temporal start input.", + repoPath, + baseBranch: "main", + scenarios: { spec: "ok" }, + }; + + await engine.prepareRun(input); + runIds.push(runId); + await expect(engine.validatePreparedRunInput(input)).resolves.toBeUndefined(); + await expect( + engine.validatePreparedRunInput({ + ...input, + scenarios: { spec: "timeout" }, + }), + ).rejects.toMatchObject({ code: "internal_state_corruption" }); + }); + + it("rejects prepared run replay when the persisted worktree path is only a partial directory", async () => { + client = createDbClient(databaseUrl); + await seedDevelopmentRegistry(client.db); + const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-engine-workspace-"))); + const repoPath = createGitRepo(); + tempRoots.push(workspaceRoot, repoPath); + const engine = new DbRunEngine({ + db: client.db, + sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), + maxConcurrentRuns: 100, + workspaceRoot, + }); + const runId = randomUUID(); + const input = { + runId, + requirementsMd: "Reject partial worktree replay.", + repoPath, + baseBranch: "main", + }; + + await engine.prepareRun(input); + runIds.push(runId); + const [run] = await client.db + .select({ worktreeRoot: runs.worktreeRoot }) + .from(runs) + .where(eq(runs.id, runId)); + expect(run).toBeDefined(); + if (run === undefined) { + throw new Error("prepared run missing"); + } + rmSync(run.worktreeRoot, { recursive: true, force: true }); + mkdirSync(run.worktreeRoot, { recursive: true }); + + await expect(engine.prepareRun(input)).rejects.toMatchObject({ + code: "workspace_permissions", + }); + }); + + it("rejects prepared run replay when the persisted worktree belongs to another repo", async () => { + client = createDbClient(databaseUrl); + await seedDevelopmentRegistry(client.db); + const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-engine-workspace-"))); + const repoPath = createGitRepo(); + tempRoots.push(workspaceRoot, repoPath); + const engine = new DbRunEngine({ + db: client.db, + sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), + maxConcurrentRuns: 100, + workspaceRoot, + }); + const runId = randomUUID(); + const input = { + runId, + requirementsMd: "Reject a replayed worktree that belongs to a different repo.", + repoPath, + baseBranch: "main", + }; + + await engine.prepareRun(input); + runIds.push(runId); + const [run] = await client.db + .select({ worktreeRoot: runs.worktreeRoot }) + .from(runs) + .where(eq(runs.id, runId)); + expect(run).toBeDefined(); + if (run === undefined) { + throw new Error("prepared run missing"); + } + rmSync(run.worktreeRoot, { recursive: true, force: true }); + mkdirSync(run.worktreeRoot, { recursive: true }); + execFileSync("git", ["init", "-b", `devflow/${runId}/main`], { + cwd: run.worktreeRoot, + stdio: "ignore", + }); + + await expect(engine.prepareRun(input)).rejects.toMatchObject({ + code: "workspace_permissions", + }); + }); + it("enforces the configured maximum concurrent active runs", async () => { client = createDbClient(databaseUrl); await seedDevelopmentRegistry(client.db); @@ -418,6 +542,7 @@ describe("DbRunEngine", () => { db: client.db, sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), workspaceRoot, + maxConcurrentRuns: 100, wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, }); @@ -456,7 +581,7 @@ describe("DbRunEngine", () => { expect((await engine.getStatus(runId)).run.state).toBe("awaiting_approval"); }); - it("resumes an active phase that observed a manual pause mid-mutation", async () => { + it("repairs an active phase that paused after prompt acceptance but before prompt proof", async () => { client = createDbClient(databaseUrl); await seedDevelopmentRegistry(client.db); const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-engine-workspace-"))); @@ -466,6 +591,7 @@ describe("DbRunEngine", () => { db: client.db, sessions: sessionRuntime(client.db, new PausesAfterPromptAcceptedFakeAdapter(client.db)), workspaceRoot, + maxConcurrentRuns: 100, wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, }); @@ -488,7 +614,7 @@ describe("DbRunEngine", () => { const resumed = await engine.getStatus(runId); expect(resumed.run.state).toBe("awaiting_approval"); expect(resumed.phases.find((phase) => phase.phaseKey === "spec")).toMatchObject({ - attempts: 1, + attempts: 2, state: "awaiting_approval", }); expect(pendingApproval(resumed, "spec_approved")).toBeDefined(); @@ -504,6 +630,7 @@ describe("DbRunEngine", () => { db: client.db, sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), workspaceRoot, + maxConcurrentRuns: 100, wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, }); @@ -567,6 +694,7 @@ describe("DbRunEngine", () => { db: client.db, sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), workspaceRoot, + maxConcurrentRuns: 100, wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, }); @@ -614,6 +742,7 @@ describe("DbRunEngine", () => { db: client.db, sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), workspaceRoot, + maxConcurrentRuns: 100, wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, }); @@ -650,6 +779,7 @@ describe("DbRunEngine", () => { db: client.db, sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), workspaceRoot, + maxConcurrentRuns: 100, wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, }); @@ -686,6 +816,7 @@ describe("DbRunEngine", () => { db: client.db, sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), workspaceRoot, + maxConcurrentRuns: 100, wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, }); @@ -736,6 +867,7 @@ describe("DbRunEngine", () => { db: client.db, sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), workspaceRoot, + maxConcurrentRuns: 100, wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, }); @@ -815,6 +947,7 @@ describe("DbRunEngine", () => { db: client.db, sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), workspaceRoot, + maxConcurrentRuns: 100, wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, }); @@ -871,6 +1004,7 @@ describe("DbRunEngine", () => { db: client.db, sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), workspaceRoot, + maxConcurrentRuns: 100, wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, }); @@ -937,6 +1071,7 @@ describe("DbRunEngine", () => { db: client.db, sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), workspaceRoot, + maxConcurrentRuns: 100, wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, }); @@ -983,6 +1118,7 @@ describe("DbRunEngine", () => { db: client.db, sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), workspaceRoot, + maxConcurrentRuns: 100, wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, }); @@ -1025,6 +1161,7 @@ describe("DbRunEngine", () => { db: client.db, sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), workspaceRoot, + maxConcurrentRuns: 100, wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, }); @@ -1051,6 +1188,127 @@ describe("DbRunEngine", () => { code: "approval_conflict", }); }); + + it("does not treat a client token suffix as an approval replay", async () => { + client = createDbClient(databaseUrl); + await seedDevelopmentRegistry(client.db); + const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-engine-workspace-"))); + const repoPath = createGitRepo(); + tempRoots.push(workspaceRoot, repoPath); + const engine = new DbRunEngine({ + db: client.db, + sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), + workspaceRoot, + maxConcurrentRuns: 100, + wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, + }); + + const { runId } = await engine.startRun({ + requirementsMd: "Check approval token suffix handling.", + repoPath, + baseBranch: "main", + }); + runIds.push(runId); + const [request] = await client.db + .select({ id: approvalRequests.id }) + .from(approvalRequests) + .where(and(eq(approvalRequests.runId, runId), eq(approvalRequests.state, "pending"))); + expect(request).toBeDefined(); + if (request === undefined) { + throw new Error("approval request missing"); + } + + await engine.signalApproval(runId, request.id, "approve", "prefix:shared-token"); + await expect( + engine.signalApproval(runId, request.id, "approve", "shared-token"), + ).rejects.toMatchObject({ + code: "approval_conflict", + }); + }); + + it("replays terminal approval disposal side effects for duplicate decisions", async () => { + client = createDbClient(databaseUrl); + await seedDevelopmentRegistry(client.db); + const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-engine-workspace-"))); + const repoPath = createGitRepo(); + tempRoots.push(workspaceRoot, repoPath); + const adapter = new DisposeCountingFakeAdapter({ writeDelayMs: 0 }); + const engine = new DbRunEngine({ + db: client.db, + sessions: sessionRuntime(client.db, adapter), + workspaceRoot, + maxConcurrentRuns: 100, + wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, + }); + + const { runId } = await engine.startRun({ + requirementsMd: "Reject and replay disposal.", + repoPath, + baseBranch: "main", + }); + runIds.push(runId); + const request = pendingApproval(await engine.getStatus(runId), "spec_approved"); + const clientToken = randomUUID(); + + await engine.signalApproval(runId, request.id, "reject", clientToken); + expect(adapter.disposeCalls).toBe(1); + await engine.signalApproval(runId, request.id, "reject", clientToken); + expect(adapter.disposeCalls).toBe(2); + await engine.replayAppliedApprovalSideEffects(runId, "reject"); + expect(adapter.disposeCalls).toBe(3); + }); + + it("repairs missing aborted final reports during applied approval replay", async () => { + client = createDbClient(databaseUrl); + await seedDevelopmentRegistry(client.db); + const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-engine-workspace-"))); + const repoPath = createGitRepo(); + const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-engine-worktree-"))); + tempRoots.push(workspaceRoot, repoPath, worktreeRoot); + const [template] = await client.db + .select({ hash: workflowTemplates.hash, id: workflowTemplates.id }) + .from(workflowTemplates) + .where(eq(workflowTemplates.name, "development")) + .limit(1); + if (template === undefined) { + throw new Error("development template missing"); + } + const runId = randomUUID(); + runIds.push(runId); + await client.db.insert(runs).values({ + id: runId, + templateId: template.id, + templateHash: template.hash, + state: "aborted", + repoPath, + baseBranch: "main", + worktreeRoot, + endedAt: new Date(), + finalReportPath: null, + }); + const engine = new DbRunEngine({ + db: client.db, + sessions: sessionRuntime(client.db, new FakeSessionAdapter({ writeDelayMs: 0 })), + workspaceRoot, + maxConcurrentRuns: 100, + }); + + await engine.replayAppliedApprovalSideEffects(runId, "approve"); + + const [run] = await client.db + .select({ finalReportPath: runs.finalReportPath }) + .from(runs) + .where(eq(runs.id, runId)); + expect(run?.finalReportPath).toMatch(/\.report\.md$/); + if (run?.finalReportPath === null || run?.finalReportPath === undefined) { + throw new Error("final report was not repaired"); + } + expect( + JSON.parse( + readFileSync(run.finalReportPath.replace(/\.report\.md$/, ".report.json"), "utf8"), + ), + ).toMatchObject({ runId, status: "aborted" }); + }); }); function pendingApproval(status: Awaited>, gateKey: string) { diff --git a/packages/run-engine/src/engine.ts b/packages/run-engine/src/engine.ts index 1588b91..c4c7b16 100644 --- a/packages/run-engine/src/engine.ts +++ b/packages/run-engine/src/engine.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; -import { realpathSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { dirname, join, relative, resolve } from "node:path"; import { promisify } from "node:util"; @@ -14,6 +14,7 @@ import { Persona, Template, bindTemplatePersonas, + canonicalize, hash, validateArtifact, } from "@devflow/core"; @@ -92,6 +93,8 @@ export interface DbRunEngineOptions { timeoutMs?: number; pollIntervalMs?: number; stableMs?: number; + signal?: AbortSignal; + onPoll?: () => void; }; } @@ -183,16 +186,48 @@ export class DbRunEngine implements RunEngine { } async startRun(input: RunStartInput): Promise<{ runId: string }> { + const runId = input.runId ?? randomUUID(); + const runInput = { ...input, runId }; + + await this.prepareRun(runInput); + try { + await this.lockBindingsForRun(runInput); + await this.advanceRunUntilBlocked(runId, { failureReason: "start_run_failed" }); + } catch (error) { + if (await this.shouldPreserveHumanGateRun(runId, error)) { + return { runId }; + } + await this.markRunFailedIfActive(runId, "start_run_failed"); + throw error; + } + + return { runId }; + } + + async prepareRun(input: RunStartInput): Promise<{ runId: string }> { const runId = input.runId ?? randomUUID(); const templateName = input.templateName ?? "development"; const templateVersion = input.templateVersion ?? 1; const repoPath = canonicalExistingPath(input.repoPath); - const worktreeRoot = await this.resolveWorktreeRoot(runId, input.worktreeRoot); const templateRecord = await this.loadTemplate(templateName, templateVersion); + const worktreeRoot = await this.resolveWorktreeRoot(runId, input.worktreeRoot); + const inputExtra = storeEngineMetadata(input.extra, input.scenarios, input.overrides); + const existing = await this.existingRunForPrepare(runId); + if (existing !== undefined) { + this.assertPreparedRunMatches(runId, existing, { + repoPath, + baseBranch: input.baseBranch, + templateHash: templateRecord.hash, + worktreeRoot, + requirementsMd: input.requirementsMd, + objective: input.objective ?? null, + extra: inputExtra, + }); + await this.ensureGitWorktree(repoPath, input.baseBranch, runId, existing.worktreeRoot); + return { runId }; + } + const template = Template.parse(templateRecord.definition); - const personaRecords = await this.loadPersonas(); - const personas = personaRecords.map((row) => Persona.parse(row.definition)); - const inputExtra = storeEngineMetadata(input.extra, input.scenarios); const inputHash = hash({ templateHash: templateRecord.hash, bindings: [], @@ -262,25 +297,100 @@ export class DbRunEngine implements RunEngine { throw error; } - try { - await this.lockBindings( + return { runId }; + } + + async validatePreparedRunInput(input: RunStartInput): Promise { + const runId = input.runId; + if (runId === undefined) { + throw new DevflowError("Run id is required to validate a prepared run", { + class: "fatal", + code: "internal_state_corruption", + }); + } + + const templateName = input.templateName ?? "development"; + const templateVersion = input.templateVersion ?? 1; + const existing = await this.existingRunForPrepare(runId); + if (existing === undefined) { + throw runNotFound(runId); + } + const templateRecord = await this.loadTemplate(templateName, templateVersion); + this.assertPreparedRunMatches(runId, existing, { + repoPath: canonicalExistingPath(input.repoPath), + baseBranch: input.baseBranch, + templateHash: templateRecord.hash, + worktreeRoot: this.expectedWorktreeRoot(runId, input.worktreeRoot), + requirementsMd: input.requirementsMd, + objective: input.objective ?? null, + extra: storeEngineMetadata(input.extra, input.scenarios, input.overrides), + }); + } + + async lockBindingsForRun(input: RunStartInput): Promise { + const runId = input.runId; + if (runId === undefined) { + throw new DevflowError("Run id is required to lock bindings", { + class: "fatal", + code: "internal_state_corruption", + }); + } + + const [run] = await this.db + .select({ state: runs.state, templateHash: runs.templateHash }) + .from(runs) + .where(eq(runs.id, runId)) + .limit(1); + if (run === undefined) { + throw runNotFound(runId); + } + if (run.state !== "created") { + return; + } + + const templateName = input.templateName ?? "development"; + const templateVersion = input.templateVersion ?? 1; + const templateRecord = await this.loadTemplate(templateName, templateVersion); + if (templateRecord.hash !== run.templateHash) { + throw new DevflowError("Run template hash does not match binding input", { + class: "fatal", + code: "internal_state_corruption", runId, - template, - templateRecord.hash, - personaRecords, - personas, - input, + }); + } + const template = Template.parse(templateRecord.definition); + const personaRecords = await this.loadPersonas(); + const personas = personaRecords.map((row) => Persona.parse(row.definition)); + await this.lockBindings(runId, template, templateRecord.hash, personaRecords, personas, input); + } + + async failRunIfActive(runId: string, reason: string): Promise { + await this.markRunFailedIfActive(runId, reason); + } + + async advanceRunUntilBlocked( + runId: string, + options: { resumeActivePhase?: boolean; failureReason?: string } = {}, + ): Promise { + try { + await this.advanceRun( + runId, + options.resumeActivePhase === undefined + ? {} + : { resumeActivePhase: options.resumeActivePhase }, ); - await this.advanceRun(runId); } catch (error) { - if (await this.shouldPreserveHumanGateRun(runId, error)) { - return { runId }; + if (error instanceof DevflowError && error.code === "activity_cancelled") { + throw error; } - await this.markRunFailedIfActive(runId, "start_run_failed"); + if (await this.shouldPreserveHumanGateRun(runId, error)) { + return this.getStatus(runId); + } + await this.markRunFailedIfActive(runId, options.failureReason ?? "advance_run_failed"); throw error; } - return { runId }; + return this.getStatus(runId); } private async lockStartAttempt( @@ -359,6 +469,66 @@ export class DbRunEngine implements RunEngine { await this.composeFinalReportBestEffort(runId, "aborted"); } + async signalApprovalForWorkflow( + runId: string, + approvalRequestId: string, + action: ApprovalDecisionActionValue, + clientToken: string, + comment?: string, + ): Promise { + const parsedAction = ApprovalDecisionAction.parse(action); + await this.recordApprovalDecision(runId, approvalRequestId, parsedAction, clientToken, comment); + } + + async validateApprovalSignalInput( + runId: string, + approvalRequestId: string, + action: ApprovalDecisionActionValue, + clientToken: string, + ): Promise<"pending" | "applied"> { + const parsedAction = ApprovalDecisionAction.parse(action); + return this.readApprovalSignalState(runId, approvalRequestId, parsedAction, clientToken, { + allowPending: true, + allowReplayBeforeStateChecks: true, + }); + } + + async readApprovalSignalResult( + runId: string, + approvalRequestId: string, + action: ApprovalDecisionActionValue, + clientToken: string, + ): Promise<"pending" | "applied"> { + const parsedAction = ApprovalDecisionAction.parse(action); + return this.readApprovalSignalState(runId, approvalRequestId, parsedAction, clientToken, { + allowPending: true, + allowReplayBeforeStateChecks: true, + requireOwnDecisionWhenResolved: true, + }); + } + + async replayAppliedApprovalSideEffects( + runId: string, + action: ApprovalDecisionActionValue, + options: { disposeSessions?: boolean } = {}, + ): Promise { + const parsedAction = ApprovalDecisionAction.parse(action); + const shouldDisposeSessions = options.disposeSessions ?? true; + if (shouldDisposeSessions && parsedAction === "reject") { + await this.disposeSessions(await this.sessionIdsForRun(runId)); + } else if (shouldDisposeSessions && parsedAction === "abort") { + await this.disposeSessions(await this.sessionIdsForRun(runId)); + } + + const status = await this.getStatus(runId); + if (isTerminalRunState(status.run.state)) { + await this.composeFinalReportBestEffort( + runId, + status.run.state as "completed" | "failed" | "aborted", + ); + } + } + async pauseRun(runId: string): Promise { const eventRepository = new RunEventRepository(this.db); await this.db.transaction(async (tx) => { @@ -389,6 +559,45 @@ export class DbRunEngine implements RunEngine { async resumeRun(runId: string): Promise { const eventRepository = new RunEventRepository(this.db); + const shouldAdvance = await this.resumeRunState(runId, eventRepository); + + if (shouldAdvance) { + try { + await this.advanceRun(runId, { resumeActivePhase: true }); + } catch (error) { + if (await this.shouldPreserveHumanGateRun(runId, error)) { + return; + } + await this.markRunFailedIfActive(runId, "resume_advance_failed"); + throw error; + } + } + } + + async validateResumeSignalInput(runId: string): Promise { + await this.db.transaction(async (tx) => { + const [run] = await lockRun(tx, runId); + if (run === undefined) { + throw runNotFound(runId); + } + if (run.state !== "paused") { + return; + } + if (await hasPendingHumanRequiredGate(tx, runId)) { + throw approvalConflict(runId, "pending human-required gate must be resolved first"); + } + }); + } + + async resumeRunForWorkflow(runId: string): Promise { + const eventRepository = new RunEventRepository(this.db); + await this.resumeRunState(runId, eventRepository); + } + + private async resumeRunState( + runId: string, + eventRepository: RunEventRepository, + ): Promise { let shouldAdvance = false; await this.db.transaction(async (tx) => { const [run] = await lockRun(tx, runId); @@ -413,17 +622,7 @@ export class DbRunEngine implements RunEngine { shouldAdvance = nextState === "executing" || nextState === "planning"; }); - if (shouldAdvance) { - try { - await this.advanceRun(runId, { resumeActivePhase: true }); - } catch (error) { - if (await this.shouldPreserveHumanGateRun(runId, error)) { - return; - } - await this.markRunFailedIfActive(runId, "resume_advance_failed"); - throw error; - } - } + return shouldAdvance; } async abortRun(runId: string, reason: string): Promise { @@ -464,73 +663,7 @@ export class DbRunEngine implements RunEngine { } async getStatus(runId: string): Promise { - const [run] = await this.db - .select({ - id: runs.id, - state: runs.state, - repoPath: runs.repoPath, - baseBranch: runs.baseBranch, - worktreeRoot: runs.worktreeRoot, - currentPhaseId: runs.currentPhaseId, - finalReportPath: runs.finalReportPath, - startedAt: runs.startedAt, - endedAt: runs.endedAt, - }) - .from(runs) - .where(eq(runs.id, runId)) - .limit(1); - if (run === undefined) { - throw runNotFound(runId); - } - - const [phases, approvals, eventsTail] = await Promise.all([ - this.db - .select({ - id: runPhases.id, - phaseKey: runPhases.phaseKey, - seq: runPhases.seq, - state: runPhases.state, - attempts: runPhases.attempts, - }) - .from(runPhases) - .where(eq(runPhases.runId, runId)) - .orderBy(asc(runPhases.seq)), - this.db - .select({ - id: approvalRequests.id, - phaseId: approvalRequests.phaseId, - gateKey: approvalRequests.gateKey, - state: approvalRequests.state, - }) - .from(approvalRequests) - .where(eq(approvalRequests.runId, runId)) - .orderBy(asc(approvalRequests.createdAt)), - this.db - .select({ - id: runEvents.id, - seq: runEvents.seq, - type: runEvents.type, - payload: runEvents.payload, - ts: runEvents.ts, - }) - .from(runEvents) - .where(eq(runEvents.runId, runId)) - .orderBy(desc(runEvents.seq)) - .limit(20), - ]); - - return { - run, - phases, - approvals, - eventsTail: eventsTail.reverse().map((event) => ({ - id: event.id.toString(), - seq: event.seq.toString(), - type: event.type, - payload: event.payload, - ts: event.ts, - })), - }; + return readRunStatus(this.db, runId); } private async lockBindings( @@ -563,7 +696,7 @@ export class DbRunEngine implements RunEngine { objective: input.objective ?? null, repoPath: canonicalExistingPath(input.repoPath), baseBranch: input.baseBranch, - extra: storeEngineMetadata(input.extra, input.scenarios), + extra: storeEngineMetadata(input.extra, input.scenarios, input.overrides), }); await this.db.transaction(async (tx) => { @@ -872,6 +1005,9 @@ export class DbRunEngine implements RunEngine { if (existingDecision.action !== action) { throw approvalConflict(runId, "client token already used for a different action"); } + if (action === "abort" || action === "reject") { + sessionsToDispose = await sessionIdsForRun(tx, runId); + } return { replayed: true }; } if (isTerminalRunState(run.state)) { @@ -989,6 +1125,81 @@ export class DbRunEngine implements RunEngine { return result; } + private async readApprovalSignalState( + runId: string, + approvalRequestId: string, + action: ApprovalDecisionActionValue, + clientToken: string, + options: { + allowPending: boolean; + allowReplayBeforeStateChecks: boolean; + requireOwnDecisionWhenResolved?: boolean; + }, + ): Promise<"pending" | "applied"> { + return this.db.transaction(async (tx) => { + const [run] = await lockRun(tx, runId); + if (run === undefined) { + throw runNotFound(runId); + } + await tx.execute( + sql`SELECT 1 FROM ${approvalRequests} WHERE ${approvalRequests.id} = ${approvalRequestId} FOR UPDATE`, + ); + const [request] = await tx + .select({ + id: approvalRequests.id, + phaseId: approvalRequests.phaseId, + state: approvalRequests.state, + }) + .from(approvalRequests) + .where(and(eq(approvalRequests.id, approvalRequestId), eq(approvalRequests.runId, runId))) + .limit(1); + if (request === undefined) { + throw new DevflowError("Approval request does not exist", { + class: "human_required", + code: "approval_not_found", + runId, + }); + } + + const existingDecision = await existingDecisionForToken(tx, approvalRequestId, clientToken); + if (existingDecision !== undefined) { + if (existingDecision.action !== action) { + throw approvalConflict(runId, "client token already used for a different action"); + } + if (options.allowReplayBeforeStateChecks) { + return "applied"; + } + } + + if (request.state !== "pending") { + if (options.requireOwnDecisionWhenResolved === true) { + throw approvalConflict(runId, `approval_state=${request.state}`); + } + throw approvalConflict(runId, `approval_state=${request.state}`); + } + if (!options.allowPending) { + throw approvalConflict(runId, "approval decision has not been applied"); + } + if (isTerminalRunState(run.state)) { + throw approvalConflict(runId, `run_state=${run.state}`); + } + if (run.state !== "awaiting_approval" && run.state !== "paused") { + throw approvalConflict(runId, `run_state=${run.state}`); + } + if (run.state === "paused") { + const resolvesHumanRequiredGate = + (action === "reject" || action === "abort") && + (request.phaseId === null || + (await isHumanRequiredApprovalPhase(tx, runId, request.phaseId))); + if (!resolvesHumanRequiredGate) { + throw approvalConflict(runId, "paused runs must be resumed before approval decisions"); + } + } + + return "pending"; + }); + } + private async composeFinalReport( runId: string, status: "completed" | "failed" | "aborted", @@ -1553,6 +1764,28 @@ export class DbRunEngine implements RunEngine { runId: string, requestedWorktreeRoot?: string, ): Promise { + const { runRoot, worktreeRoot } = this.expectedWorktreeRootParts(runId, requestedWorktreeRoot); + await mkdir(runRoot, { recursive: true }); + const canonicalRunRoot = realpathSync(runRoot); + await mkdir(dirname(worktreeRoot), { recursive: true }); + if (!isPathInsideOrEqual(worktreeRoot, canonicalRunRoot)) { + throw new DevflowError("Resolved worktree root escaped the run workspace root", { + class: "fatal", + code: "workspace_permissions", + recoveryHint: worktreeRoot, + }); + } + return worktreeRoot; + } + + private expectedWorktreeRoot(runId: string, requestedWorktreeRoot?: string): string { + return this.expectedWorktreeRootParts(runId, requestedWorktreeRoot).worktreeRoot; + } + + private expectedWorktreeRootParts( + runId: string, + requestedWorktreeRoot?: string, + ): { runRoot: string; worktreeRoot: string } { const runRoot = join(this.workspaceRoot, runId); const worktreeRoot = requestedWorktreeRoot ?? join(runRoot, "main"); if (!isPathInsideOrEqual(resolve(worktreeRoot), resolve(runRoot))) { @@ -1562,18 +1795,8 @@ export class DbRunEngine implements RunEngine { recoveryHint: worktreeRoot, }); } - await mkdir(runRoot, { recursive: true }); - const canonicalRunRoot = realpathSync(runRoot); const resolvedWorktreeRoot = resolve(worktreeRoot); - await mkdir(dirname(resolvedWorktreeRoot), { recursive: true }); - if (!isPathInsideOrEqual(resolvedWorktreeRoot, canonicalRunRoot)) { - throw new DevflowError("Resolved worktree root escaped the run workspace root", { - class: "fatal", - code: "workspace_permissions", - recoveryHint: resolvedWorktreeRoot, - }); - } - return resolvedWorktreeRoot; + return { runRoot: resolve(runRoot), worktreeRoot: resolvedWorktreeRoot }; } private async createGitWorktree( @@ -1601,11 +1824,165 @@ export class DbRunEngine implements RunEngine { } } + private async ensureGitWorktree( + repoPath: string, + baseBranch: string, + runId: string, + worktreeRoot: string, + ): Promise { + if (existsSync(worktreeRoot)) { + return validateExistingGitWorktree(repoPath, baseBranch, runId, worktreeRoot); + } + + return this.createGitWorktree(repoPath, baseBranch, runId, worktreeRoot); + } + + private async existingRunForPrepare(runId: string): Promise< + | { + repoPath: string; + baseBranch: string; + templateHash: string; + worktreeRoot: string; + requirementsMd: string; + objective: unknown; + extra: unknown; + } + | undefined + > { + const [run] = await this.db + .select({ + repoPath: runs.repoPath, + baseBranch: runs.baseBranch, + templateHash: runs.templateHash, + worktreeRoot: runs.worktreeRoot, + requirementsMd: runInputs.requirementsMd, + objective: runInputs.objective, + extra: runInputs.extra, + }) + .from(runs) + .innerJoin(runInputs, eq(runInputs.runId, runs.id)) + .where(eq(runs.id, runId)) + .limit(1); + return run; + } + + private assertPreparedRunMatches( + runId: string, + existing: { + repoPath: string; + baseBranch: string; + templateHash: string; + worktreeRoot: string; + requirementsMd: string; + objective: unknown; + extra: unknown; + }, + expected: { + repoPath: string; + baseBranch: string; + templateHash: string; + worktreeRoot: string; + requirementsMd: string; + objective: unknown; + extra: unknown; + }, + ): void { + if ( + existing.repoPath !== expected.repoPath || + existing.baseBranch !== expected.baseBranch || + existing.templateHash !== expected.templateHash || + existing.worktreeRoot !== expected.worktreeRoot || + existing.requirementsMd !== expected.requirementsMd || + canonicalize(existing.objective ?? null) !== canonicalize(expected.objective ?? null) || + canonicalize(existing.extra ?? {}) !== canonicalize(expected.extra ?? {}) + ) { + throw new DevflowError("Existing run does not match replayed start input", { + class: "fatal", + code: "internal_state_corruption", + runId, + }); + } + } + private async disposeSessions(sessionIds: readonly string[]): Promise { await Promise.all( sessionIds.map((sessionId) => this.sessions.dispose({ sessionId }).catch(() => undefined)), ); } + + private async sessionIdsForRun(runId: string): Promise { + return sessionIdsForRun(this.db, runId); + } +} + +export async function readRunStatus(db: Database, runId: string): Promise { + const [run] = await db + .select({ + id: runs.id, + state: runs.state, + repoPath: runs.repoPath, + baseBranch: runs.baseBranch, + worktreeRoot: runs.worktreeRoot, + currentPhaseId: runs.currentPhaseId, + finalReportPath: runs.finalReportPath, + startedAt: runs.startedAt, + endedAt: runs.endedAt, + }) + .from(runs) + .where(eq(runs.id, runId)) + .limit(1); + if (run === undefined) { + throw runNotFound(runId); + } + + const [phases, approvals, eventsTail] = await Promise.all([ + db + .select({ + id: runPhases.id, + phaseKey: runPhases.phaseKey, + seq: runPhases.seq, + state: runPhases.state, + attempts: runPhases.attempts, + }) + .from(runPhases) + .where(eq(runPhases.runId, runId)) + .orderBy(asc(runPhases.seq)), + db + .select({ + id: approvalRequests.id, + phaseId: approvalRequests.phaseId, + gateKey: approvalRequests.gateKey, + state: approvalRequests.state, + }) + .from(approvalRequests) + .where(eq(approvalRequests.runId, runId)) + .orderBy(asc(approvalRequests.createdAt)), + db + .select({ + id: runEvents.id, + seq: runEvents.seq, + type: runEvents.type, + payload: runEvents.payload, + ts: runEvents.ts, + }) + .from(runEvents) + .where(eq(runEvents.runId, runId)) + .orderBy(desc(runEvents.seq)) + .limit(20), + ]); + + return { + run, + phases, + approvals, + eventsTail: eventsTail.reverse().map((event) => ({ + id: event.id.toString(), + seq: event.seq.toString(), + type: event.type, + payload: event.payload, + ts: event.ts, + })), + }; } export interface M4ProcessRestartSweepOptions { @@ -2023,7 +2400,21 @@ async function existingDecisionForToken( }) .from(approvalDecisions) .where(eq(approvalDecisions.approvalRequestId, approvalRequestId)); - return decisions.find((decision) => decision.idempotencyKey.endsWith(`:${clientToken}`)); + return decisions.find((decision) => { + const prefix = `${approvalRequestId}:${decision.action}:`; + if (!decision.idempotencyKey.startsWith(prefix)) { + return false; + } + return decision.idempotencyKey.slice(prefix.length) === clientToken; + }); +} + +async function sessionIdsForRun(db: TransactionDb | Database, runId: string): Promise { + const sessions = await db + .select({ id: tuiSessions.id }) + .from(tuiSessions) + .where(eq(tuiSessions.runId, runId)); + return sessions.map((session) => session.id); } function approvalStateForAction(action: ApprovalDecisionActionValue) { @@ -2167,10 +2558,12 @@ function invalidPhasePlan(runId: string, index: number): DevflowError { function storeEngineMetadata( extra: Record | undefined, scenarios: Record | undefined, + overrides?: Partial, ): Record { return { ...(extra ?? {}), devflowM4: { + overrides: overrides ?? {}, scenarios: scenarios ?? {}, }, }; @@ -2251,6 +2644,74 @@ function gitChildEnv(): NodeJS.ProcessEnv { return env; } +async function validateExistingGitWorktree( + repoPath: string, + baseBranch: string, + runId: string, + worktreeRoot: string, +): Promise { + try { + const canonicalWorktreeRoot = realpathSync(worktreeRoot); + const { stdout: topLevelStdout } = await execFileAsync( + "git", + ["-C", canonicalWorktreeRoot, "rev-parse", "--show-toplevel"], + { env: gitChildEnv(), maxBuffer: 1024 * 1024 }, + ); + const gitTopLevel = realpathSync(topLevelStdout.trim()); + if (gitTopLevel !== canonicalWorktreeRoot) { + throw new Error(`expected ${canonicalWorktreeRoot}; got ${gitTopLevel}`); + } + const expectedBranch = `devflow/${runId}/main`; + const { stdout: branchStdout } = await execFileAsync( + "git", + ["-C", canonicalWorktreeRoot, "branch", "--show-current"], + { env: gitChildEnv(), maxBuffer: 1024 * 1024 }, + ); + const branch = branchStdout.trim(); + if (branch !== expectedBranch) { + throw new Error(`expected branch ${expectedBranch}; got ${branch}`); + } + const { stdout: commonDirStdout } = await execFileAsync( + "git", + ["-C", canonicalWorktreeRoot, "rev-parse", "--git-common-dir"], + { env: gitChildEnv(), maxBuffer: 1024 * 1024 }, + ); + const { stdout: repoCommonDirStdout } = await execFileAsync( + "git", + ["-C", repoPath, "rev-parse", "--git-common-dir"], + { env: gitChildEnv(), maxBuffer: 1024 * 1024 }, + ); + const canonicalRepoGitDir = realpathSync(resolve(repoPath, repoCommonDirStdout.trim())); + const canonicalCommonDir = realpathSync(resolve(canonicalWorktreeRoot, commonDirStdout.trim())); + if (!isPathInsideOrEqual(canonicalCommonDir, canonicalRepoGitDir)) { + throw new Error( + `expected git common dir under ${canonicalRepoGitDir}; got ${canonicalCommonDir}`, + ); + } + const { stdout: worktreeListStdout } = await execFileAsync( + "git", + ["-C", repoPath, "worktree", "list", "--porcelain"], + { env: gitChildEnv(), maxBuffer: 1024 * 1024 }, + ); + const registeredWorktrees = worktreeListStdout + .split("\n") + .filter((line) => line.startsWith("worktree ")) + .map((line) => realpathSync(line.slice("worktree ".length))); + if (!registeredWorktrees.includes(canonicalWorktreeRoot)) { + throw new Error(`${canonicalWorktreeRoot} is not registered to ${repoPath}`); + } + return canonicalWorktreeRoot; + } catch (cause) { + throw new DevflowError("Existing worktree root is not a valid git worktree", { + class: "human_required", + code: "workspace_permissions", + runId, + recoveryHint: `worktree=${worktreeRoot};repo=${repoPath};base=${baseBranch}`, + cause, + }); + } +} + const gitLocalEnvKeys = [ "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", diff --git a/packages/run-engine/src/fake-phase-harness.test.ts b/packages/run-engine/src/fake-phase-harness.test.ts index 6fca84e..b4ded20 100644 --- a/packages/run-engine/src/fake-phase-harness.test.ts +++ b/packages/run-engine/src/fake-phase-harness.test.ts @@ -1,8 +1,16 @@ import { randomUUID } from "node:crypto"; -import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { eq, inArray } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { afterEach, describe, expect, it } from "vitest"; import { DevflowError, hash } from "@devflow/core"; @@ -190,6 +198,35 @@ class AcceptedThenTransientFakeAdapter extends FakeSessionAdapter { } } +class SendCountingFakeAdapter extends FakeSessionAdapter { + sendAttempts = 0; + + override async sendPrompt( + handle: SessionHandle, + envelope: Parameters[1], + ): Promise<{ promptId: string }> { + this.sendAttempts += 1; + return super.sendPrompt(handle, envelope); + } +} + +class StartObservesPersistedSessionFakeAdapter extends FakeSessionAdapter { + observedSessionRowsBeforeStart: number | undefined; + + constructor(private readonly db: DbClient["db"]) { + super({ writeDelayMs: 0 }); + } + + override async start(input: StartInput): Promise { + const sessions = await this.db + .select({ id: tuiSessions.id }) + .from(tuiSessions) + .where(and(eq(tuiSessions.runId, input.runId), eq(tuiSessions.roleId, input.roleId))); + this.observedSessionRowsBeforeStart = sessions.length; + return super.start(input); + } +} + class CaptureCursorFakeAdapter extends FakeSessionAdapter { capturedFromSeq: bigint | undefined; @@ -793,7 +830,7 @@ describe("runSingleFakePhase", () => { ]); }); - it("resumes a running phase when prompt delivery succeeded before prompt.sent was recorded", async () => { + it("does not trust a running phase artifact when prompt.sent was not recorded", async () => { const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 1); await recordPhaseStarted(db, runId, phaseId); const worktreeRoot = realpathSync( @@ -803,7 +840,7 @@ describe("runSingleFakePhase", () => { const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json"); const instructions = "Scenario: ok\nWrite the development specification."; const sessionId = randomUUID(); - const adapter = new FakeSessionAdapter({ + const adapter = new SendCountingFakeAdapter({ sessionIdFactory: () => sessionId, writeDelayMs: 0, }); @@ -864,6 +901,7 @@ describe("runSingleFakePhase", () => { }); expect(result.artifactValid).toBe(true); + expect(adapter.sendAttempts).toBe(2); await expectRunCompleted(db, runId); const events = await db @@ -871,12 +909,448 @@ describe("runSingleFakePhase", () => { .from(runEvents) .where(eq(runEvents.runId, runId)) .orderBy(runEvents.seq); - expect(events.map((event) => event.type)).toContain("prompt.sent"); + expect(events.map((event) => event.type)).not.toContain("prompt.sent"); + expect(events.map((event) => event.type)).toContain("prompt.repaired"); expect(events.map((event) => event.type).filter((type) => type === "phase.started")).toEqual([ "phase.started", + "phase.started", ]); }); + it("waits on a BUSY prompt with no prompt event instead of resending it", async () => { + const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 1); + await recordPhaseStarted(db, runId, phaseId); + const worktreeRoot = realpathSync( + mkdtempSync(join(tmpdir(), "devflow-fake-phase-pre-send-replay-")), + ); + tempRoots.push(worktreeRoot); + const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json"); + const instructions = "Scenario: ok\nWrite the development specification."; + const sessionId = randomUUID(); + const adapter = new SendCountingFakeAdapter({ + sessionIdFactory: () => sessionId, + writeDelayMs: 0, + }); + await adapter.start({ + runId, + roleId: "implementer", + backend: "fake", + cwd: worktreeRoot, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + }); + const dedupKey = hash({ + attempt: 1, + expectedArtifact: expectedArtifactPath, + expectedSchema: "dev/spec@1", + instructions, + phaseKey: "implement", + roleId: "implementer", + runId, + }); + await db.insert(tuiSessions).values({ + id: sessionId, + runId, + roleId: "implementer", + backend: "fake", + cwd: worktreeRoot, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + lastPromptHash: dedupKey, + lastPromptAt: new Date(), + state: "BUSY", + }); + + const result = await runSingleFakePhase({ + adapter, + db, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + instructions, + phaseId, + phaseKey: "implement", + roleId: "implementer", + runId, + worktreeRoot, + wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, + uuidFactory: () => "00000000-0000-4000-8000-000000000041", + }); + + expect(result.artifactValid).toBe(true); + expect(adapter.sendAttempts).toBe(1); + await expectRunCompleted(db, runId); + const events = await db + .select({ type: runEvents.type }) + .from(runEvents) + .where(eq(runEvents.runId, runId)) + .orderBy(runEvents.seq); + expect(events.map((event) => event.type)).not.toContain("prompt.sent"); + expect(events.map((event) => event.type)).toContain("prompt.repaired"); + }); + + it("restarts a bootstrapping phantom session instead of sending to it", async () => { + const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 1); + await recordPhaseStarted(db, runId, phaseId); + const worktreeRoot = realpathSync( + mkdtempSync(join(tmpdir(), "devflow-fake-phase-bootstrapping-replay-")), + ); + tempRoots.push(worktreeRoot); + const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json"); + const sessionId = randomUUID(); + await db.insert(tuiSessions).values({ + id: sessionId, + runId, + roleId: "implementer", + backend: "fake", + cwd: worktreeRoot, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + state: "BOOTSTRAPPING", + }); + const adapter = new SendCountingFakeAdapter({ writeDelayMs: 0 }); + + const result = await runSingleFakePhase({ + adapter, + db, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + instructions: "Scenario: ok\nWrite the development specification.", + phaseId, + phaseKey: "implement", + roleId: "implementer", + runId, + worktreeRoot, + wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, + uuidFactory: () => "00000000-0000-4000-8000-000000000042", + }); + + expect(result).toMatchObject({ artifactValid: true, sessionId }); + expect(adapter.sendAttempts).toBe(1); + await expectRunCompleted(db, runId); + }); + + it("persists the session row only after adapter start succeeds", async () => { + const { db, phaseId, runId } = await createRunAndPhase(); + const worktreeRoot = realpathSync( + mkdtempSync(join(tmpdir(), "devflow-fake-phase-session-post-start-")), + ); + tempRoots.push(worktreeRoot); + const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json"); + const adapter = new StartObservesPersistedSessionFakeAdapter(db); + + const result = await runSingleFakePhase({ + adapter, + db, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + instructions: "Scenario: ok\nWrite the development specification.", + phaseId, + phaseKey: "implement", + roleId: "implementer", + runId, + worktreeRoot, + wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, + }); + + expect(result.artifactValid).toBe(true); + expect(adapter.observedSessionRowsBeforeStart).toBe(0); + + const sessions = await db + .select({ id: tuiSessions.id, state: tuiSessions.state }) + .from(tuiSessions) + .where(eq(tuiSessions.runId, runId)); + expect(sessions).toEqual([{ id: result.sessionId, state: "READY" }]); + }); + + it("does not validate a stale artifact from a running READY replay without prompt proof", async () => { + const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 1); + await recordPhaseStarted(db, runId, phaseId); + const worktreeRoot = realpathSync( + mkdtempSync(join(tmpdir(), "devflow-fake-phase-ready-stale-")), + ); + tempRoots.push(worktreeRoot); + const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json"); + const sessionId = randomUUID(); + const adapter = new SendCountingFakeAdapter({ + sessionIdFactory: () => sessionId, + writeDelayMs: 0, + }); + await adapter.start({ + sessionId, + runId, + roleId: "implementer", + backend: "fake", + cwd: worktreeRoot, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + }); + await db.insert(tuiSessions).values({ + id: sessionId, + runId, + roleId: "implementer", + backend: "fake", + cwd: worktreeRoot, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + state: "READY", + }); + mkdirSync(dirname(expectedArtifactPath), { recursive: true }); + writeFileSync( + expectedArtifactPath, + JSON.stringify({ + summary: "Stale development specification", + requirements: [{ id: "REQ-STALE", description: "This file predates prompt proof" }], + acceptanceCriteria: ["This artifact must not be accepted"], + risks: [], + }), + ); + + const result = await runSingleFakePhase({ + adapter, + db, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + instructions: "Scenario: ok\nWrite the development specification.", + phaseId, + phaseKey: "implement", + roleId: "implementer", + runId, + worktreeRoot, + wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, + }); + + expect(result.artifactValid).toBe(true); + expect(adapter.sendAttempts).toBe(1); + const artifact = JSON.parse(readFileSync(expectedArtifactPath, "utf8")) as { summary: string }; + expect(artifact.summary).toBe("Fake development specification"); + }); + + it("does not validate a stale artifact from a running BUSY replay without prompt proof", async () => { + const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 1); + await recordPhaseStarted(db, runId, phaseId); + const worktreeRoot = realpathSync( + mkdtempSync(join(tmpdir(), "devflow-fake-phase-busy-stale-")), + ); + tempRoots.push(worktreeRoot); + const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json"); + const instructions = + "Scenario: timeout\nRepair-Scenario: timeout\nDo not accept stale artifact content."; + const sessionId = randomUUID(); + const dedupKey = hash({ + attempt: 1, + expectedArtifact: expectedArtifactPath, + expectedSchema: "dev/spec@1", + instructions, + phaseKey: "implement", + roleId: "implementer", + runId, + }); + const adapter = new FakeSessionAdapter({ + sessionIdFactory: () => sessionId, + writeDelayMs: 0, + }); + await adapter.start({ + sessionId, + runId, + roleId: "implementer", + backend: "fake", + cwd: worktreeRoot, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + }); + await db.insert(tuiSessions).values({ + id: sessionId, + runId, + roleId: "implementer", + backend: "fake", + cwd: worktreeRoot, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + lastPromptHash: dedupKey, + lastPromptAt: new Date(), + state: "BUSY", + }); + mkdirSync(dirname(expectedArtifactPath), { recursive: true }); + writeFileSync( + expectedArtifactPath, + JSON.stringify({ + summary: "Stale development specification", + requirements: [{ id: "REQ-STALE", description: "This file predates prompt proof" }], + acceptanceCriteria: ["This artifact must not be accepted"], + risks: [], + }), + ); + + await expect( + runSingleFakePhase({ + adapter, + db, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + instructions, + phaseId, + phaseKey: "implement", + roleId: "implementer", + runId, + worktreeRoot, + wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 10 }, + }), + ).rejects.toMatchObject({ code: "artifact_timeout_exhausted" }); + + await expectRunPaused(db, runId); + const artifactRows = await db.select().from(artifacts).where(eq(artifacts.runId, runId)); + expect(artifactRows).toEqual([]); + }); + + it("does not turn a baseline-protected BUSY replay into durable prompt proof", async () => { + const { db, phaseId, runId } = await createRunAndPhase("executing", "awaiting_artifact", 1); + await recordPhaseStarted(db, runId, phaseId); + const worktreeRoot = realpathSync( + mkdtempSync(join(tmpdir(), "devflow-fake-phase-busy-baseline-durable-")), + ); + tempRoots.push(worktreeRoot); + const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json"); + const instructions = + "Scenario: timeout\nRepair-Scenario: timeout\nDo not persist synthetic prompt proof."; + const sessionId = randomUUID(); + const dedupKey = hash({ + attempt: 1, + expectedArtifact: expectedArtifactPath, + expectedSchema: "dev/spec@1", + instructions, + phaseKey: "implement", + roleId: "implementer", + runId, + }); + const adapter = new FakeSessionAdapter({ + sessionIdFactory: () => sessionId, + writeDelayMs: 0, + }); + await adapter.start({ + sessionId, + runId, + roleId: "implementer", + backend: "fake", + cwd: worktreeRoot, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + }); + await db.insert(tuiSessions).values({ + id: sessionId, + runId, + roleId: "implementer", + backend: "fake", + cwd: worktreeRoot, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + lastPromptHash: dedupKey, + lastPromptAt: new Date(), + state: "BUSY", + }); + mkdirSync(dirname(expectedArtifactPath), { recursive: true }); + writeFileSync( + expectedArtifactPath, + JSON.stringify({ + summary: "STALE accepted by replay", + requirements: [{ id: "REQ-STALE", description: "This file predates prompt proof" }], + acceptanceCriteria: ["This artifact must not be accepted"], + risks: [], + }), + ); + await db.insert(runEvents).values({ + runId, + phaseId, + seq: 2n, + type: "artifact.expected", + payload: { path: expectedArtifactPath, schemaId: "dev/spec@1", attempt: 1 }, + idempotencyKey: `artifact.expected:${phaseId}:1:${expectedArtifactPath}`, + }); + + await expect( + runSingleFakePhase({ + adapter, + db, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + instructions, + phaseId, + phaseKey: "implement", + roleId: "implementer", + runId, + worktreeRoot, + wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 10 }, + }), + ).rejects.toMatchObject({ code: "artifact_timeout_exhausted" }); + + await expectRunPaused(db, runId); + const artifactRows = await db.select().from(artifacts).where(eq(artifacts.runId, runId)); + expect(artifactRows).toEqual([]); + const promptEvents = await db + .select({ type: runEvents.type }) + .from(runEvents) + .where(eq(runEvents.runId, runId)); + expect(promptEvents.map((event) => event.type)).not.toContain("prompt.sent"); + }); + + it("does not fail the run when artifact wait is cancelled for workflow signal handling", async () => { + const { db, phaseId, runId } = await createRunAndPhase(); + const worktreeRoot = realpathSync( + mkdtempSync(join(tmpdir(), "devflow-fake-phase-cancelled-wait-")), + ); + tempRoots.push(worktreeRoot); + const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json"); + const controller = new AbortController(); + let abortScheduled = false; + + await expect( + runSingleFakePhase({ + adapter: new FakeSessionAdapter({ writeDelayMs: 0 }), + db, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + instructions: "Scenario: timeout\nWait until the workflow signal cancels this activity.", + phaseId, + phaseKey: "implement", + roleId: "implementer", + runId, + worktreeRoot, + wait: { + pollIntervalMs: 1, + stableMs: 0, + timeoutMs: 500, + signal: controller.signal, + onPoll: () => { + if (!abortScheduled) { + abortScheduled = true; + setTimeout(() => controller.abort(new Error("workflow signal arrived")), 0); + } + }, + }, + }), + ).rejects.toMatchObject({ code: "activity_cancelled" }); + + const [run] = await db.select({ state: runs.state }).from(runs).where(eq(runs.id, runId)); + const [phase] = await db + .select({ state: runPhases.state }) + .from(runPhases) + .where(eq(runPhases.id, phaseId)); + const [session] = await db + .select({ lastCaptureSeq: tuiSessions.lastCaptureSeq }) + .from(tuiSessions) + .where(eq(tuiSessions.runId, runId)); + expect(run?.state).toBe("executing"); + expect(phase?.state).toBe("awaiting_artifact"); + expect(session?.lastCaptureSeq).toBeGreaterThan(0n); + + const events = await db + .select({ type: runEvents.type }) + .from(runEvents) + .where(eq(runEvents.runId, runId)) + .orderBy(runEvents.seq); + expect(events.map((event) => event.type)).not.toContain("phase.failed"); + expect(events.map((event) => event.type)).not.toContain("run.failed"); + }); + it("requests a human gate when existing session resume exhausts retries", async () => { const { db, phaseId, runId } = await createRunAndPhase(); const worktreeRoot = realpathSync( @@ -933,8 +1407,9 @@ describe("runSingleFakePhase", () => { tempRoots.push(worktreeRoot); const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json"); + const adapter = new SendCountingFakeAdapter({ writeDelayMs: 0 }); const result = await runSingleFakePhase({ - adapter: new FakeSessionAdapter({ writeDelayMs: 0 }), + adapter, db, expectedArtifactPath, expectedSchema: "dev/spec@1", @@ -1001,8 +1476,9 @@ describe("runSingleFakePhase", () => { }), ); + const adapter = new SendCountingFakeAdapter({ writeDelayMs: 0 }); const result = await runSingleFakePhase({ - adapter: new FakeSessionAdapter({ writeDelayMs: 0 }), + adapter, db, expectedArtifactPath, expectedSchema: "dev/spec@1", @@ -1016,6 +1492,7 @@ describe("runSingleFakePhase", () => { }); expect(result).toMatchObject({ artifactValid: true, promptId, sessionId }); + expect(adapter.sendAttempts).toBe(0); await expectRunCompleted(db, runId); const events = await db @@ -1023,7 +1500,7 @@ describe("runSingleFakePhase", () => { .from(runEvents) .where(eq(runEvents.runId, runId)) .orderBy(runEvents.seq); - expect(events.map((event) => event.type)).not.toContain("prompt.sent"); + expect(events.map((event) => event.type)).toContain("prompt.sent"); expect(events.map((event) => event.type)).toContain("artifact.expected"); expect(events.map((event) => event.type)).toContain("artifact.validated"); }); @@ -1384,6 +1861,77 @@ describe("runSingleFakePhase", () => { expect(events.filter((event) => event.type === "phase.started")).toHaveLength(1); }); + it("does not validate a stale prior artifact before a repair prompt is sent", async () => { + const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 2); + await recordPhaseStarted(db, runId, phaseId, 2, true); + const worktreeRoot = realpathSync( + mkdtempSync(join(tmpdir(), "devflow-fake-phase-repair-stale-running-")), + ); + tempRoots.push(worktreeRoot); + const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json"); + mkdirSync(dirname(expectedArtifactPath), { recursive: true }); + writeFileSync(expectedArtifactPath, JSON.stringify({ fake: "stale-invalid" })); + const instructions = + "Scenario: invalid\nRepair-Scenario: ok\nWrite the development specification."; + const priorPromptId = hash({ + attempt: 1, + expectedArtifact: expectedArtifactPath, + expectedSchema: "dev/spec@1", + instructions, + phaseKey: "implement", + roleId: "implementer", + runId, + }); + const sessionId = randomUUID(); + const adapter = new SendCountingFakeAdapter({ + sessionIdFactory: () => sessionId, + writeDelayMs: 0, + }); + await adapter.start({ + runId, + roleId: "implementer", + backend: "fake", + cwd: worktreeRoot, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + }); + await db.insert(tuiSessions).values({ + id: sessionId, + runId, + roleId: "implementer", + backend: "fake", + cwd: worktreeRoot, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + lastPromptHash: priorPromptId, + lastPromptAt: new Date(), + state: "READY", + }); + + const result = await runSingleFakePhase({ + adapter, + db, + expectedArtifactPath, + expectedSchema: "dev/spec@1", + instructions, + phaseId, + phaseKey: "implement", + roleId: "implementer", + runId, + worktreeRoot, + wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, + uuidFactory: () => "00000000-0000-4000-8000-000000000043", + }); + + expect(result.artifactValid).toBe(true); + expect(adapter.sendAttempts).toBe(1); + const invalidArtifacts = await db + .select({ valid: artifacts.valid }) + .from(artifacts) + .where(and(eq(artifacts.runId, runId), eq(artifacts.valid, false))); + expect(invalidArtifacts).toEqual([]); + }); + it("resumes a repair attempt while awaiting its artifact", async () => { const { db, phaseId, runId } = await createRunAndPhase("executing", "awaiting_artifact", 2); await recordPhaseStarted(db, runId, phaseId, 2, true); @@ -1758,8 +2306,11 @@ describe("runSingleFakePhase", () => { .where(eq(approvalRequests.runId, runId)); expect(approval).toEqual({ gateKey: "backend_unavailable", state: "pending" }); - const sessions = await db.select().from(tuiSessions).where(eq(tuiSessions.runId, runId)); - expect(sessions).toEqual([]); + const sessions = await db + .select({ state: tuiSessions.state }) + .from(tuiSessions) + .where(eq(tuiSessions.runId, runId)); + expect(sessions).toEqual([{ state: "FAILED_NEEDS_HUMAN" }]); const events = await db .select({ type: runEvents.type }) @@ -1770,6 +2321,7 @@ describe("runSingleFakePhase", () => { "phase.started", "phase.failed", "run.paused", + "session.failed", "approval.requested", ]); }); @@ -1829,6 +2381,7 @@ describe("runSingleFakePhase", () => { "phase.started", "phase.failed", "run.failed", + "session.failed", ]); }); diff --git a/packages/run-engine/src/fake-phase-harness.ts b/packages/run-engine/src/fake-phase-harness.ts index da671ad..70203e7 100644 --- a/packages/run-engine/src/fake-phase-harness.ts +++ b/packages/run-engine/src/fake-phase-harness.ts @@ -35,6 +35,8 @@ export interface FakePhaseWaitOptions { timeoutMs?: number; pollIntervalMs?: number; stableMs?: number; + signal?: AbortSignal; + onPoll?: () => void; } interface ArtifactWaitOptions extends FakePhaseWaitOptions { @@ -63,6 +65,7 @@ export type RunSingleFakePhaseInput = RunSingleFakePhaseBaseInput & ({ sessions: SessionRuntime; adapter?: never } | { adapter: SessionAdapter; sessions?: never }); type CanonicalRunSingleFakePhaseInput = RunSingleFakePhaseBaseInput & { + reserveSessionId?: () => string; sessions: SessionRuntime; }; @@ -81,11 +84,17 @@ const sendPromptRetryBudget = 2; const terminalRunStates = ["completed", "failed", "aborted"] as const; const phaseMutationRunStates = ["executing", "planning"] as const; +interface SessionIdReservable { + reserveSessionId(): string; +} + interface PhaseEntry { attempt: number; continueArtifactWait: boolean; continueValidation: boolean; + artifactBaselineSignature?: string | undefined; promptId?: string; + recordPromptEventOnReplay?: boolean; repairAttemptUsed: boolean; replayedOutcome?: ArtifactOutcome; resumedPrompt: boolean; @@ -106,8 +115,19 @@ function canonicalizeRunSingleFakePhaseInput( "sessions" in input && input.sessions !== undefined ? input.sessions : new SessionManager({ db: input.db, adapter: input.adapter }); + const adapter = "adapter" in input ? input.adapter : undefined; + const reserveSessionId = + adapter !== undefined && isSessionIdReservable(adapter) + ? () => adapter.reserveSessionId() + : undefined; - return { ...input, expectedArtifactPath, sessions, worktreeRoot }; + return { + ...input, + expectedArtifactPath, + ...(reserveSessionId === undefined ? {} : { reserveSessionId }), + sessions, + worktreeRoot, + }; } function canonicalizePathAgainstWorktree( @@ -140,6 +160,15 @@ function canonicalizePossiblyMissingPath(path: string): string { return resolve(realpathSync(current), ...missingSegments); } +function isSessionIdReservable( + adapter: SessionAdapter, +): adapter is SessionAdapter & SessionIdReservable { + return ( + "reserveSessionId" in adapter && + typeof (adapter as Partial).reserveSessionId === "function" + ); +} + export async function runSingleFakePhase( rawInput: RunSingleFakePhaseInput, ): Promise { @@ -184,10 +213,14 @@ export async function runSingleFakePhase( } else if (phaseEntry.continueArtifactWait) { promptId = requirePhaseEntryPromptId(input, phaseEntry, "Artifact wait replay"); promptDedupKeyForIdle = promptId; - promptSend = { promptId, artifactBaselineSignature: undefined }; + if (phaseEntry.recordPromptEventOnReplay === true) { + await recordPromptEventIfMissing(input, eventRepository, promptEventType, envelope); + } + promptSend = { promptId, artifactBaselineSignature: phaseEntry.artifactBaselineSignature }; } else if (phaseEntry.continueValidation) { promptId = requirePhaseEntryPromptId(input, phaseEntry, "Artifact validation replay"); promptDedupKeyForIdle = promptId; + await recordPromptEventIfMissing(input, eventRepository, promptEventType, envelope); } else { try { promptSend = await sendPromptAndRecord( @@ -250,6 +283,10 @@ export async function runSingleFakePhase( await captureTranscript(input, handle); throw error; } + if (isActivityCancelled(error)) { + await captureTranscript(input, handle); + throw error; + } if (!isDevflowErrorWithCode(error, "artifact_timeout_exhausted")) { await failRunAndDisposeSession( input, @@ -415,6 +452,10 @@ export async function runSingleFakePhase( await captureTranscript(input, handle); throw repairError; } + if (isActivityCancelled(repairError)) { + await captureTranscript(input, handle); + throw repairError; + } if (!isDevflowErrorWithCode(repairError, "artifact_timeout_exhausted")) { await failRunAndDisposeSession( input, @@ -565,6 +606,10 @@ export async function runSingleFakePhase( await captureTranscript(input, handle); throw error; } + if (isActivityCancelled(error)) { + await captureTranscript(input, handle); + throw error; + } if (!isDevflowErrorWithCode(error, "artifact_timeout_exhausted")) { await failRunAndDisposeSession( input, @@ -711,13 +756,31 @@ async function enterInitialPhase( }; } if (["CREATED", "BOOTSTRAPPING", "READY"].includes(session.state)) { + const promptEventAlreadyRecorded = await promptEventExists( + input, + phaseStart.repairAttemptUsed ? "prompt.repaired" : "prompt.sent", + envelope.dedupKey, + ); + if ( + promptEventAlreadyRecorded && + (await artifactSignature(input.expectedArtifactPath)) !== undefined + ) { + return { + attempt: phase.attempts, + continueArtifactWait: false, + continueValidation: true, + promptId: envelope.dedupKey, + repairAttemptUsed: phaseStart.repairAttemptUsed, + resumedPrompt: true, + handle: { sessionId: session.id }, + }; + } return { attempt: phase.attempts, continueArtifactWait: false, continueValidation: false, repairAttemptUsed: phaseStart.repairAttemptUsed, resumedPrompt: false, - handle: { sessionId: session.id }, }; } if ( @@ -726,10 +789,29 @@ async function enterInitialPhase( session.expectedArtifactPath === input.expectedArtifactPath && session.expectedSchema === input.expectedSchema ) { + if ( + !(await promptEventExists( + input, + phaseStart.repairAttemptUsed ? "prompt.repaired" : "prompt.sent", + envelope.dedupKey, + )) + ) { + return { + attempt: phase.attempts, + continueArtifactWait: true, + continueValidation: false, + artifactBaselineSignature: await artifactSignature(input.expectedArtifactPath), + promptId: envelope.dedupKey, + repairAttemptUsed: phaseStart.repairAttemptUsed, + resumedPrompt: true, + handle: { sessionId: session.id }, + }; + } return { attempt: phase.attempts, - continueArtifactWait: false, + continueArtifactWait: true, continueValidation: false, + promptId: session.lastPromptHash, repairAttemptUsed: phaseStart.repairAttemptUsed, resumedPrompt: true, handle: { sessionId: session.id }, @@ -764,11 +846,21 @@ async function enterInitialPhase( session.expectedArtifactPath === input.expectedArtifactPath && session.expectedSchema === input.expectedSchema ) { + const currentPromptEventExists = await promptEventExists( + input, + phaseStart.repairAttemptUsed ? "prompt.repaired" : "prompt.sent", + envelope.dedupKey, + ); + const artifactWaitEventExists = await artifactExpectedEventExists(input, phase.attempts); return { attempt: phase.attempts, continueArtifactWait: true, continueValidation: false, + ...(currentPromptEventExists || !artifactWaitEventExists + ? {} + : { artifactBaselineSignature: await artifactSignature(input.expectedArtifactPath) }), promptId: session.lastPromptHash, + recordPromptEventOnReplay: !currentPromptEventExists && !artifactWaitEventExists, repairAttemptUsed: phaseStart.repairAttemptUsed, resumedPrompt: true, handle: { sessionId: session.id }, @@ -1166,6 +1258,19 @@ async function failPhaseAndRequestGate( } if (sessionId !== undefined) { + await tx + .insert(tuiSessions) + .values({ + id: sessionId, + runId: input.runId, + roleId: input.roleId, + backend: "fake", + cwd: input.worktreeRoot, + expectedArtifactPath: input.expectedArtifactPath, + expectedSchema: input.expectedSchema, + state: "FAILED_NEEDS_HUMAN", + }) + .onConflictDoNothing({ target: tuiSessions.id }); await tx .update(tuiSessions) .set({ state: "FAILED_NEEDS_HUMAN" }) @@ -1437,15 +1542,22 @@ async function startSessionAndRecord( eventRepository: RunEventRepository, attempt: number, ): Promise { - const existingHandle = await resumeExistingSessionAndRecord(input, eventRepository, attempt); - if (existingHandle !== undefined) { - return existingHandle; + const existingSession = await sessionForRole(input); + if ( + existingSession !== undefined && + !["CREATED", "BOOTSTRAPPING"].includes(existingSession.state) + ) { + const existingHandle = await resumeExistingSessionAndRecord(input, eventRepository, attempt); + if (existingHandle !== undefined) { + return existingHandle; + } } + const sessionId = existingSession?.id ?? input.reserveSessionId?.() ?? randomUUID(); let handle: SessionHandle | undefined; - let sessionRowPersisted = false; try { handle = await input.sessions.start({ + sessionId, runId: input.runId, roleId: input.roleId, backend: "fake", @@ -1454,10 +1566,18 @@ async function startSessionAndRecord( expectedSchema: input.expectedSchema, }); const startedHandle = handle; - let sessionInsertConflicted = false; + if (startedHandle.sessionId !== sessionId) { + throw new DevflowError("Session adapter did not honor reserved session id", { + class: "fatal", + code: "internal_state_corruption", + runId: input.runId, + phaseId: input.phaseId, + recoveryHint: `expected=${sessionId};actual=${startedHandle.sessionId}`, + }); + } await input.db.transaction(async (tx) => { await assertRunCanMutatePhaseInTransaction(input, tx); - const insertedSession = await tx + await tx .insert(tuiSessions) .values({ id: startedHandle.sessionId, @@ -1467,14 +1587,9 @@ async function startSessionAndRecord( cwd: input.worktreeRoot, expectedArtifactPath: input.expectedArtifactPath, expectedSchema: input.expectedSchema, - state: "CREATED", + state: "BOOTSTRAPPING", }) - .onConflictDoNothing({ target: [tuiSessions.runId, tuiSessions.roleId] }) - .returning({ id: tuiSessions.id }); - if (insertedSession[0] === undefined) { - sessionInsertConflicted = true; - return; - } + .onConflictDoNothing({ target: tuiSessions.id }); await eventRepository.appendInTransaction(tx, { runId: input.runId, phaseId: input.phaseId, @@ -1498,21 +1613,6 @@ async function startSessionAndRecord( idempotencyKey: `session.ready:${startedHandle.sessionId}:0`, }); }); - if (sessionInsertConflicted) { - await input.sessions.dispose(startedHandle).catch(() => undefined); - handle = undefined; - const existingHandle = await resumeExistingSessionAndRecord(input, eventRepository, attempt); - if (existingHandle !== undefined) { - return existingHandle; - } - throw new DevflowError("Concurrent fake session insert conflicted without an existing row", { - class: "fatal", - code: "internal_state_corruption", - runId: input.runId, - phaseId: input.phaseId, - }); - } - sessionRowPersisted = true; return startedHandle; } catch (error) { if (handle !== undefined) { @@ -1531,19 +1631,35 @@ async function startSessionAndRecord( "session_start_failed", gateError.code, { errorCode: error.code, recoveryHint: gateError.recoveryHint }, - sessionRowPersisted ? handle?.sessionId : undefined, + sessionId, ); throw gateError; } await failPhaseAndRun(input, eventRepository, attempt, "session_start_failed"); - if (sessionRowPersisted && handle !== undefined) { - await markSessionFailedNeedsHuman(input, eventRepository, handle.sessionId); + await markSessionFailedNeedsHuman(input, eventRepository, sessionId); + if (handle !== undefined) { + await input.sessions.dispose(handle).catch(() => undefined); } throw error; } } +async function sessionForRole(input: CanonicalRunSingleFakePhaseInput): Promise< + | { + id: string; + state: string; + } + | undefined +> { + const [session] = await input.db + .select({ id: tuiSessions.id, state: tuiSessions.state }) + .from(tuiSessions) + .where(and(eq(tuiSessions.runId, input.runId), eq(tuiSessions.roleId, input.roleId))) + .limit(1); + return session; +} + async function resumeExistingSessionAndRecord( input: CanonicalRunSingleFakePhaseInput, eventRepository: RunEventRepository, @@ -1709,6 +1825,14 @@ async function sendPromptAndRecord( type: "prompt.sent" | "prompt.repaired", options: SendPromptAndRecordOptions = {}, ): Promise { + await input.db.transaction(async (tx) => { + await assertRunCanMutatePhaseInTransaction(input, tx); + }); + + const artifactBaselineSignature = + options.captureArtifactBaseline === false + ? undefined + : await artifactSignature(input.expectedArtifactPath); await input.db.transaction(async (tx) => { await assertRunCanMutatePhaseInTransaction(input, tx); await tx @@ -1730,11 +1854,6 @@ async function sendPromptAndRecord( idempotencyKey: `session.busy:${handle.sessionId}:${envelope.dedupKey}`, }); }); - - const artifactBaselineSignature = - options.captureArtifactBaseline === false - ? undefined - : await artifactSignature(input.expectedArtifactPath); const prompt = await sendPromptWithRetry(input.sessions, handle, envelope); await input.db.transaction(async (tx) => { await assertRunCanMutatePhaseInTransaction(input, tx); @@ -1750,6 +1869,66 @@ async function sendPromptAndRecord( return { promptId: prompt.promptId, artifactBaselineSignature }; } +async function recordPromptEventIfMissing( + input: CanonicalRunSingleFakePhaseInput, + eventRepository: RunEventRepository, + type: "prompt.sent" | "prompt.repaired", + envelope: PromptEnvelope, +): Promise { + await input.db.transaction(async (tx) => { + await assertRunCanMutatePhaseInTransaction(input, tx); + await eventRepository.appendInTransaction(tx, { + runId: input.runId, + phaseId: input.phaseId, + type, + payload: { roleId: input.roleId, dedupKey: envelope.dedupKey }, + idempotencyKey: `${type}:${envelope.dedupKey}`, + }); + }); +} + +async function promptEventExists( + input: CanonicalRunSingleFakePhaseInput, + type: "prompt.sent" | "prompt.repaired", + dedupKey: string, +): Promise { + const [event] = await input.db + .select({ id: runEvents.id }) + .from(runEvents) + .where( + and( + eq(runEvents.runId, input.runId), + eq(runEvents.phaseId, input.phaseId), + eq(runEvents.type, type), + eq(runEvents.idempotencyKey, `${type}:${dedupKey}`), + ), + ) + .limit(1); + return event !== undefined; +} + +async function artifactExpectedEventExists( + input: CanonicalRunSingleFakePhaseInput, + attempt: number, +): Promise { + const [event] = await input.db + .select({ id: runEvents.id }) + .from(runEvents) + .where( + and( + eq(runEvents.runId, input.runId), + eq(runEvents.phaseId, input.phaseId), + eq(runEvents.type, "artifact.expected"), + eq( + runEvents.idempotencyKey, + `artifact.expected:${input.phaseId}:${attempt}:${input.expectedArtifactPath}`, + ), + ), + ) + .limit(1); + return event !== undefined; +} + async function sendPromptWithRetry( sessions: SessionRuntime, handle: { sessionId: string }, @@ -2163,6 +2342,19 @@ async function markSessionFailedNeedsHuman( eventRepository: RunEventRepository, sessionId: string, ) { + await input.db + .insert(tuiSessions) + .values({ + id: sessionId, + runId: input.runId, + roleId: input.roleId, + backend: "fake", + cwd: input.worktreeRoot, + expectedArtifactPath: input.expectedArtifactPath, + expectedSchema: input.expectedSchema, + state: "FAILED_NEEDS_HUMAN", + }) + .onConflictDoNothing({ target: tuiSessions.id }); await input.db .update(tuiSessions) .set({ state: "FAILED_NEEDS_HUMAN" }) @@ -2223,12 +2415,14 @@ async function waitForArtifact(path: string, options: ArtifactWaitOptions = {}): let stableSince: number | undefined; while (Date.now() <= deadline) { + throwIfAborted(options.signal); + options.onPoll?.(); try { const signature = await artifactSignature(path); if (signature === undefined || signature === ignoreInitialSignature) { lastSignature = undefined; stableSince = undefined; - await sleep(pollIntervalMs); + await sleep(pollIntervalMs, options.signal); continue; } if (lastSignature === signature) { @@ -2259,7 +2453,7 @@ async function waitForArtifact(path: string, options: ArtifactWaitOptions = {}): }); } } - await sleep(pollIntervalMs); + await sleep(pollIntervalMs, options.signal); } throw new DevflowError("Timed out waiting for fake phase artifact", { @@ -2427,6 +2621,10 @@ function isDevflowErrorWithCode(error: unknown, code: string): error is DevflowE return error instanceof DevflowError && error.code === code; } +function isActivityCancelled(error: unknown): error is DevflowError { + return isDevflowErrorWithCode(error, "activity_cancelled"); +} + function isRunStateChanged(error: unknown): error is DevflowError { return isDevflowErrorWithCode(error, "run_state_changed"); } @@ -2523,8 +2721,37 @@ async function captureTranscript( }); } -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); +function sleep(ms: number, signal?: AbortSignal): Promise { + if (signal === undefined) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + throwIfAborted(signal); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timeout); + reject(activityCancelledError(signal.reason)); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw activityCancelledError(signal.reason); + } +} + +function activityCancelledError(cause: unknown): DevflowError { + return new DevflowError("Activity was cancelled before artifact wait completed", { + class: "recoverable", + code: "activity_cancelled", + cause, + }); } function isNodeError(error: unknown): error is NodeJS.ErrnoException { diff --git a/packages/session/src/adapter.ts b/packages/session/src/adapter.ts index cc216c6..0203958 100644 --- a/packages/session/src/adapter.ts +++ b/packages/session/src/adapter.ts @@ -11,6 +11,7 @@ export interface SessionAdapter { } export interface StartInput { + sessionId?: string; runId: string; roleId: string; backend: Backend; diff --git a/packages/session/src/fake.ts b/packages/session/src/fake.ts index 2701191..4422ff3 100644 --- a/packages/session/src/fake.ts +++ b/packages/session/src/fake.ts @@ -54,6 +54,10 @@ export class FakeSessionAdapter implements SessionAdapter { this.now = options.now ?? (() => new Date()); } + reserveSessionId(): string { + return this.sessionIdFactory(); + } + async start(input: StartInput): Promise { if (input.backend !== "fake") { throw new DevflowError("FakeSessionAdapter only supports the fake backend", { @@ -63,7 +67,7 @@ export class FakeSessionAdapter implements SessionAdapter { }); } - const handle: SessionHandle = { sessionId: this.sessionIdFactory() }; + const handle: SessionHandle = { sessionId: input.sessionId ?? this.sessionIdFactory() }; const record: FakeSessionRecord = { handle, runId: input.runId, diff --git a/packages/session/src/manager.ts b/packages/session/src/manager.ts index 69f6771..e415853 100644 --- a/packages/session/src/manager.ts +++ b/packages/session/src/manager.ts @@ -6,7 +6,7 @@ import { runs, tuiSessions, } from "@devflow/db"; -import { and, eq, inArray, ne, notInArray, sql } from "drizzle-orm"; +import { and, eq, inArray, notInArray, sql } from "drizzle-orm"; import type { ProbeResult, @@ -196,11 +196,11 @@ export class SessionManager implements SessionRuntime { .where( this.recoveryRunIds === undefined ? and( - ne(tuiSessions.state, "FAILED_NEEDS_HUMAN"), + notInArray(tuiSessions.state, [...nonRecoverableSessionStates]), notInArray(runs.state, [...terminalRunStates]), ) : and( - ne(tuiSessions.state, "FAILED_NEEDS_HUMAN"), + notInArray(tuiSessions.state, [...nonRecoverableSessionStates]), notInArray(runs.state, [...terminalRunStates]), inArray(tuiSessions.runId, [...this.recoveryRunIds]), ), @@ -218,6 +218,7 @@ export class SessionManager implements SessionRuntime { try { const resumed = await this.resumeWithRetry(handle); this.handles.set(resumed.sessionId, resumed); + await this.markStartupRecoverySucceeded(session, resumed); recoveredSessionIds.push(resumed.sessionId); } catch (error) { await this.markRecoveryFailed(session, error); @@ -228,6 +229,59 @@ export class SessionManager implements SessionRuntime { return { recoveredSessionIds, failedSessionIds }; } + private async markStartupRecoverySucceeded( + session: { + id: string; + runId: string; + roleId: string; + backend: string; + recoveryAttempts: number; + state: string; + }, + handle: SessionHandle, + ): Promise { + if (this.db === undefined || !["CREATED", "BOOTSTRAPPING"].includes(session.state)) { + return; + } + + const eventRepository = new RunEventRepository(this.db); + const sessionUpdate: { + state: "READY"; + lastKnownPanePid?: number; + tmuxSession?: string; + tmuxWindow?: string; + } = { state: "READY" }; + if (handle.pid !== undefined) { + sessionUpdate.lastKnownPanePid = handle.pid; + } + if (handle.tmuxSession !== undefined) { + sessionUpdate.tmuxSession = handle.tmuxSession; + } + if (handle.tmuxWindow !== undefined) { + sessionUpdate.tmuxWindow = handle.tmuxWindow; + } + + await this.db.transaction(async (tx) => { + await tx.update(tuiSessions).set(sessionUpdate).where(eq(tuiSessions.id, session.id)); + await eventRepository.appendInTransaction(tx, { + runId: session.runId, + type: "session.created", + payload: { sessionId: session.id, roleId: session.roleId, backend: session.backend }, + idempotencyKey: `session.created:${session.id}`, + }); + await eventRepository.appendInTransaction(tx, { + runId: session.runId, + type: "session.ready", + payload: { + sessionId: session.id, + roleId: session.roleId, + recoveryAttempts: session.recoveryAttempts, + }, + idempotencyKey: `session.ready:${session.id}:${session.recoveryAttempts}`, + }); + }); + } + private async markRecoveryFailed( session: { id: string; @@ -380,6 +434,7 @@ export class SessionManager implements SessionRuntime { } const terminalRunStates = ["completed", "failed", "aborted"] as const; +const nonRecoverableSessionStates = ["FAILED_NEEDS_HUMAN"] as const; function isTerminalRunState(state: string): state is (typeof terminalRunStates)[number] { return terminalRunStates.includes(state as (typeof terminalRunStates)[number]); diff --git a/packages/workflows/package.json b/packages/workflows/package.json new file mode 100644 index 0000000..c7fd482 --- /dev/null +++ b/packages/workflows/package.json @@ -0,0 +1,27 @@ +{ + "name": "@devflow/workflows", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsup src/index.ts --format esm,cjs --clean && tsc -p tsconfig.build.json", + "typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit", + "test": "cd ../.. && vitest run --project packages/workflows" + }, + "dependencies": { + "@devflow/core": "workspace:*", + "@devflow/db": "workspace:*", + "@devflow/run-engine": "workspace:*", + "@devflow/session": "workspace:*", + "@temporalio/activity": "^1.17.1", + "@temporalio/client": "^1.17.1", + "@temporalio/worker": "^1.17.1", + "@temporalio/workflow": "^1.17.1" + }, + "devDependencies": { + "@temporalio/testing": "^1.17.1" + } +} diff --git a/packages/workflows/src/activities.test.ts b/packages/workflows/src/activities.test.ts new file mode 100644 index 0000000..763a33a --- /dev/null +++ b/packages/workflows/src/activities.test.ts @@ -0,0 +1,310 @@ +import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { loadPersonaFiles, loadTemplateFiles } from "@devflow/core"; +import { + type DbClient, + agentPersonas, + approvalDecisions, + approvalRequests, + createDbClient, + runs, + workflowTemplates, +} from "@devflow/db"; +import { FakeSessionAdapter, SessionManager } from "@devflow/session"; +import { ApplicationFailure } from "@temporalio/activity"; +import { eq, inArray } from "drizzle-orm"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createDevflowActivities } from "./activities.js"; + +const databaseUrl = + process.env.DATABASE_URL ?? "postgres://devflow:devflow@127.0.0.1:55432/devflow"; + +describe("createDevflowActivities", () => { + let client: DbClient | undefined; + const runIds: string[] = []; + const tempRoots: string[] = []; + + afterEach(async () => { + if (client !== undefined) { + if (runIds.length > 0) { + const requests = await client.db + .select({ id: approvalRequests.id }) + .from(approvalRequests) + .where(inArray(approvalRequests.runId, [...runIds])); + if (requests.length > 0) { + await client.db.delete(approvalDecisions).where( + inArray( + approvalDecisions.approvalRequestId, + requests.map((request) => request.id), + ), + ); + } + await client.db + .delete(approvalRequests) + .where(inArray(approvalRequests.runId, [...runIds])); + await client.db.delete(runs).where(inArray(runs.id, [...runIds])); + } + await client.close(); + client = undefined; + } + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } + runIds.length = 0; + }); + + it("preserves M4 fake development run behavior through worker activities", async () => { + client = createDbClient(databaseUrl); + await seedDevelopmentRegistry(client.db); + const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-workflows-workspace-"))); + const repoPath = createGitRepo(); + tempRoots.push(workspaceRoot, repoPath); + const activities = createDevflowActivities({ + db: client.db, + sessions: new SessionManager({ + db: client.db, + adapter: new FakeSessionAdapter({ writeDelayMs: 0 }), + }), + workspaceRoot, + maxConcurrentRuns: 100, + wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 }, + }); + + const input = { + requirementsMd: "Run through the M5 worker activity surface.", + repoPath, + baseBranch: "main", + scenarios: { + spec: "ok", + phase_plan: "ok", + }, + }; + const { runId } = await activities.prepareRunActivity(input); + runIds.push(runId); + await activities.lockBindingsActivity({ ...input, runId }); + await activities.advanceRunActivity({ runId }); + + let status = await activities.getStatusActivity(runId); + expect(status.run.state).toBe("awaiting_approval"); + expect(status.approvals).toMatchObject([{ gateKey: "spec_approved", state: "pending" }]); + await activities.signalApprovalActivity({ + runId, + approvalRequestId: pendingApprovalId(status, "spec_approved"), + action: "approve", + clientToken: randomUUID(), + }); + await activities.advanceRunActivity({ runId }); + + status = await activities.getStatusActivity(runId); + await activities.signalApprovalActivity({ + runId, + approvalRequestId: pendingApprovalId(status, "phase_plan_approved"), + action: "approve", + clientToken: randomUUID(), + }); + await activities.advanceRunActivity({ runId }); + + status = await activities.getStatusActivity(runId); + expect(status.run.state).toBe("completed"); + expect(status.run.finalReportPath).toMatch(/\.report\.md$/); + expect(existsSync(status.run.finalReportPath ?? "")).toBe(true); + }); + + it("prepares a run idempotently when Temporal replays the same activity", async () => { + client = createDbClient(databaseUrl); + await seedDevelopmentRegistry(client.db); + const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-workflows-workspace-"))); + const repoPath = createGitRepo(); + tempRoots.push(workspaceRoot, repoPath); + const activities = createDevflowActivities({ + db: client.db, + sessions: new SessionManager({ + db: client.db, + adapter: new FakeSessionAdapter({ writeDelayMs: 0 }), + }), + workspaceRoot, + maxConcurrentRuns: 100, + }); + const runId = randomUUID(); + const input = { + runId, + requirementsMd: "Replay-safe prepare should return the same run.", + repoPath, + baseBranch: "main", + }; + + await expect(activities.prepareRunActivity(input)).resolves.toEqual({ runId }); + await expect(activities.prepareRunActivity(input)).resolves.toEqual({ runId }); + + const rows = await client.db.select({ id: runs.id }).from(runs).where(eq(runs.id, runId)); + expect(rows).toEqual([{ id: runId }]); + runIds.push(runId); + }); + + it("rejects a prepare replay with the same run id but different inputs", async () => { + client = createDbClient(databaseUrl); + await seedDevelopmentRegistry(client.db); + const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-workflows-workspace-"))); + const repoPath = createGitRepo(); + tempRoots.push(workspaceRoot, repoPath); + const activities = createDevflowActivities({ + db: client.db, + sessions: new SessionManager({ + db: client.db, + adapter: new FakeSessionAdapter({ writeDelayMs: 0 }), + }), + workspaceRoot, + maxConcurrentRuns: 100, + }); + const runId = randomUUID(); + const input = { + runId, + requirementsMd: "Original run requirements.", + repoPath, + baseBranch: "main", + scenarios: { spec: "ok" }, + }; + + await expect(activities.prepareRunActivity(input)).resolves.toEqual({ runId }); + await expectDevflowActivityFailure( + activities.prepareRunActivity({ + ...input, + requirementsMd: "Changed requirements must not be accepted as replay.", + }), + "internal_state_corruption", + ); + await expectDevflowActivityFailure( + activities.prepareRunActivity({ + ...input, + scenarios: { spec: "timeout" }, + }), + "internal_state_corruption", + ); + + runIds.push(runId); + }); + + it("can fail an active prepared run when lock binding cannot complete", async () => { + client = createDbClient(databaseUrl); + await seedDevelopmentRegistry(client.db); + const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-workflows-workspace-"))); + const repoPath = createGitRepo(); + tempRoots.push(workspaceRoot, repoPath); + const activities = createDevflowActivities({ + db: client.db, + sessions: new SessionManager({ + db: client.db, + adapter: new FakeSessionAdapter({ writeDelayMs: 0 }), + }), + workspaceRoot, + maxConcurrentRuns: 100, + }); + const input = { + requirementsMd: "Binding should fail when no backend is enabled.", + repoPath, + baseBranch: "main", + overrides: { roles: { spec_writer: { persona: "missing-persona" } } }, + }; + + const { runId } = await activities.prepareRunActivity(input); + runIds.push(runId); + await expectDevflowActivityFailure( + activities.lockBindingsActivity({ ...input, runId }), + "no_eligible_persona", + ); + await activities.failRunActivity({ runId, reason: "lock_bindings_failed" }); + + const [run] = await client.db + .select({ state: runs.state }) + .from(runs) + .where(eq(runs.id, runId)); + expect(run).toEqual({ state: "failed" }); + }); +}); + +function pendingApprovalId( + status: Awaited["getStatusActivity"]>>, + gateKey: string, +) { + const approval = status.approvals.find( + (candidate) => candidate.gateKey === gateKey && candidate.state === "pending", + ); + expect(approval).toBeDefined(); + if (approval === undefined) { + throw new Error(`${gateKey} approval missing`); + } + return approval.id; +} + +function createGitRepo(): string { + const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-workflows-repo-"))); + execFileSync("git", ["init", "-b", "main"], { cwd: repoPath, stdio: "ignore" }); + writeFileSync(join(repoPath, "README.md"), "# Workflows fixture\n"); + execFileSync("git", ["add", "README.md"], { cwd: repoPath, stdio: "ignore" }); + execFileSync( + "git", + [ + "-c", + "user.name=Devflow Test", + "-c", + "user.email=devflow@example.test", + "commit", + "-m", + "initial", + ], + { cwd: repoPath, stdio: "ignore" }, + ); + return repoPath; +} + +async function expectDevflowActivityFailure(operation: Promise, code: string) { + try { + await operation; + } catch (error) { + expect(error).toBeInstanceOf(ApplicationFailure); + const failure = error as ApplicationFailure; + expect(failure.type).toBe("DevflowError"); + expect(failure.nonRetryable).toBe(true); + expect(failure.details?.[0]).toMatchObject({ code }); + return; + } + throw new Error(`Expected Devflow activity failure ${code}`); +} + +async function seedDevelopmentRegistry(db: DbClient["db"]) { + const [templateEntry] = loadTemplateFiles(resolve("docs/schemas/templates")).filter( + (entry) => entry.name === "development" && entry.version === 1, + ); + if (templateEntry === undefined) { + throw new Error("development@1 template fixture is missing"); + } + await db + .insert(workflowTemplates) + .values({ + name: templateEntry.name, + version: templateEntry.version, + hash: templateEntry.hash, + definition: templateEntry.definition, + }) + .onConflictDoUpdate({ + target: [workflowTemplates.name, workflowTemplates.version], + set: { hash: templateEntry.hash, definition: templateEntry.definition }, + }); + + for (const personaEntry of loadPersonaFiles(resolve("docs/schemas/personas"))) { + await db + .insert(agentPersonas) + .values({ + name: personaEntry.name, + version: personaEntry.version, + hash: personaEntry.hash, + definition: personaEntry.definition, + }) + .onConflictDoNothing({ target: [agentPersonas.name, agentPersonas.version] }); + } +} diff --git a/packages/workflows/src/activities.ts b/packages/workflows/src/activities.ts new file mode 100644 index 0000000..64fedc3 --- /dev/null +++ b/packages/workflows/src/activities.ts @@ -0,0 +1,166 @@ +import { type BackendConfig, DevflowError } from "@devflow/core"; +import type { DbClient } from "@devflow/db"; +import { DbRunEngine, type RunStartInput, type RunStatus } from "@devflow/run-engine"; +import type { SessionRuntime } from "@devflow/session"; +import { ApplicationFailure, CancelledFailure, Context } from "@temporalio/activity"; + +import type { AbortSignalPayload, ApprovalSignalPayload, RunSignalPayload } from "./types.js"; + +type Database = DbClient["db"]; + +export interface DevflowActivityDependencies { + db: Database; + sessions: SessionRuntime; + workspaceRoot: string; + availableBackends?: readonly BackendConfig[]; + maxConcurrentRuns?: number; + wait?: { + timeoutMs?: number; + pollIntervalMs?: number; + stableMs?: number; + }; +} + +export interface DevflowActivities { + prepareRunActivity(input: RunStartInput): Promise<{ runId: string }>; + lockBindingsActivity(input: RunStartInput): Promise; + failRunActivity(input: { runId: string; reason: string }): Promise; + advanceRunActivity(input: { runId: string; resumeActivePhase?: boolean }): Promise; + signalApprovalActivity(payload: ApprovalSignalPayload): Promise; + pauseRunActivity(payload: RunSignalPayload): Promise; + resumeRunActivity(payload: RunSignalPayload): Promise; + abortRunActivity(payload: AbortSignalPayload): Promise; + getStatusActivity(runId: string): Promise; + isRunTerminalActivity(runId: string): Promise; + composeFinalReportActivity(runId: string): Promise; +} + +export function createDevflowActivities( + dependencies: DevflowActivityDependencies, +): DevflowActivities { + const makeEngine = () => { + const activityWait = withTemporalActivityCancellation(dependencies.wait); + return new DbRunEngine({ + db: dependencies.db, + sessions: dependencies.sessions, + workspaceRoot: dependencies.workspaceRoot, + ...(dependencies.availableBackends === undefined + ? {} + : { availableBackends: dependencies.availableBackends }), + ...(dependencies.maxConcurrentRuns === undefined + ? {} + : { maxConcurrentRuns: dependencies.maxConcurrentRuns }), + ...(activityWait === undefined ? {} : { wait: activityWait }), + }); + }; + + return { + prepareRunActivity(input) { + return runActivity(makeEngine().prepareRun(input)); + }, + lockBindingsActivity(input) { + return runActivity(makeEngine().lockBindingsForRun(input)); + }, + failRunActivity(input) { + return runActivity(makeEngine().failRunIfActive(input.runId, input.reason)); + }, + advanceRunActivity(input) { + return runActivity( + makeEngine().advanceRunUntilBlocked(input.runId, { + ...(input.resumeActivePhase === undefined + ? {} + : { resumeActivePhase: input.resumeActivePhase }), + failureReason: "temporal_advance_failed", + }), + ); + }, + signalApprovalActivity(payload) { + return runActivity( + makeEngine().signalApprovalForWorkflow( + payload.runId, + payload.approvalRequestId, + payload.action, + payload.clientToken, + payload.comment, + ), + ); + }, + pauseRunActivity(payload) { + return runActivity(makeEngine().pauseRun(payload.runId)); + }, + resumeRunActivity(payload) { + return runActivity(makeEngine().resumeRunForWorkflow(payload.runId)); + }, + abortRunActivity(payload) { + return runActivity(makeEngine().abortRun(payload.runId, payload.reason)); + }, + getStatusActivity(runId) { + return runActivity(makeEngine().getStatus(runId)); + }, + async isRunTerminalActivity(runId) { + const status = await runActivity(makeEngine().getStatus(runId)); + return ["completed", "failed", "aborted"].includes(status.run.state); + }, + async composeFinalReportActivity(runId) { + await runActivity(makeEngine().recoverMissingFinalReports({ runIds: [runId] })); + }, + }; +} + +async function runActivity(operation: Promise): Promise { + try { + return await operation; + } catch (error) { + throw toTemporalActivityFailure(error); + } +} + +function toTemporalActivityFailure(error: unknown): unknown { + if (isActivityCancelled(error)) { + return new CancelledFailure("activity_cancelled", [], error as Error); + } + if (error instanceof DevflowError) { + return ApplicationFailure.create({ + message: error.message, + type: "DevflowError", + nonRetryable: error.class !== "recoverable", + details: [ + { + class: error.class, + code: error.code, + ...(error.runId === undefined ? {} : { runId: error.runId }), + ...(error.phaseId === undefined ? {} : { phaseId: error.phaseId }), + ...(error.recoveryHint === undefined ? {} : { recoveryHint: error.recoveryHint }), + }, + ], + }); + } + return error; +} + +function withTemporalActivityCancellation(wait: DevflowActivityDependencies["wait"]) { + const context = currentActivityContext(); + if (context === undefined) { + return wait; + } + + return { + ...wait, + signal: context.cancellationSignal, + onPoll: () => { + context.heartbeat({ operation: "advance_run" }); + }, + }; +} + +function currentActivityContext(): Context | undefined { + try { + return Context.current(); + } catch { + return undefined; + } +} + +function isActivityCancelled(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "activity_cancelled"; +} diff --git a/packages/workflows/src/index.ts b/packages/workflows/src/index.ts new file mode 100644 index 0000000..1116eec --- /dev/null +++ b/packages/workflows/src/index.ts @@ -0,0 +1,4 @@ +export * from "./activities.js"; +export * from "./temporal-run-engine.js"; +export * from "./types.js"; +export * from "./workflow.js"; diff --git a/packages/workflows/src/temporal-run-engine.test.ts b/packages/workflows/src/temporal-run-engine.test.ts new file mode 100644 index 0000000..2dd8852 --- /dev/null +++ b/packages/workflows/src/temporal-run-engine.test.ts @@ -0,0 +1,1122 @@ +import { randomUUID } from "node:crypto"; + +import { + ApplicationFailure, + type WorkflowClient, + WorkflowExecutionAlreadyStartedError, + type WorkflowHandle, + WorkflowNotFoundError, +} from "@temporalio/client"; +import { describe, expect, it } from "vitest"; + +import { type ApprovalDecisionAction, DevflowError } from "@devflow/core"; +import type { RunStartInput, RunStatus } from "@devflow/run-engine"; + +import { TemporalRunEngine, temporalTaskQueue } from "./temporal-run-engine.js"; + +describe("TemporalRunEngine", () => { + it("starts runWorkflow with the frozen RunEngine startRun input", async () => { + const client = new FakeWorkflowClient(); + const status = sequenceStatusReader(["created", "executing", "awaiting_approval"]); + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + statusReader: status, + }); + const runId = randomUUID(); + + await expect( + engine.startRun({ + runId, + requirementsMd: "Build the feature through Temporal.", + repoPath: "/repo", + baseBranch: "main", + }), + ).resolves.toEqual({ runId }); + + expect(client.started).toMatchObject({ + taskQueue: temporalTaskQueue, + workflowId: `devflow-run:${runId}`, + workflowIdConflictPolicy: "FAIL", + workflowIdReusePolicy: "REJECT_DUPLICATE", + }); + expect(client.started?.args[0]).toMatchObject({ + runId, + requirementsMd: "Build the feature through Temporal.", + repoPath: "/repo", + baseBranch: "main", + }); + expect(status.calls).toBe(3); + }); + + it("waits for final report materialization when startRun reaches a terminal state", async () => { + const client = new FakeWorkflowClient(); + const runId = randomUUID(); + let statusReads = 0; + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + statusReader: { + async getStatus() { + statusReads += 1; + if (statusReads === 1) { + return runStatus(runId, "executing", []); + } + return runStatus(runId, "completed", [], { + finalReportPath: statusReads < 3 ? null : "/workspace/run/run.report.md", + }); + }, + }, + }); + + await expect( + engine.startRun({ + runId, + requirementsMd: "Terminal start must wait for report materialization.", + repoPath: "/repo", + baseBranch: "main", + }), + ).resolves.toEqual({ runId }); + expect(statusReads).toBe(3); + }); + + it("routes RunEngine controls to Temporal signals and reads status from DB-backed reader", async () => { + const client = new FakeWorkflowClient(); + const status = statusReader("awaiting_approval"); + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + awaitSignals: false, + statusReader: status, + }); + const runId = randomUUID(); + const approvalRequestId = randomUUID(); + const clientToken = randomUUID(); + + await engine.signalApproval(runId, approvalRequestId, "approve", clientToken, "ok"); + await engine.pauseRun(runId); + await engine.resumeRun(runId); + await engine.abortRun(runId, "stop"); + + expect(client.signals.map((signal) => signal.name)).toEqual(["approve", "pause", "abort"]); + expect(client.signals[0]?.payload).toMatchObject({ + runId, + approvalRequestId, + action: "approve", + clientToken, + comment: "ok", + }); + await expect(engine.getStatus(runId)).resolves.toMatchObject({ + run: { id: runId, state: "awaiting_approval" }, + }); + }); + + it("treats an already-started workflow id as an idempotent start replay", async () => { + const client = new FakeWorkflowClient(); + const runId = randomUUID(); + client.startError = new WorkflowExecutionAlreadyStartedError( + "Workflow execution already started", + `devflow-run:${runId}`, + "runWorkflow", + ); + const status = sequenceStatusReader(["executing", "awaiting_approval"]); + const replayValidator = new FakeStartReplayValidator(); + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + startReplayValidator: replayValidator, + startRunPollMs: 1, + statusReader: status, + }); + + await expect( + engine.startRun({ + runId, + requirementsMd: "Retry the start call.", + repoPath: "/repo", + baseBranch: "main", + }), + ).resolves.toEqual({ runId }); + expect(status.calls).toBe(2); + expect(replayValidator.inputs).toMatchObject([ + { + runId, + requirementsMd: "Retry the start call.", + repoPath: "/repo", + baseBranch: "main", + }, + ]); + }); + + it("treats an already-started terminal DB run as an idempotent start replay", async () => { + const client = new FakeWorkflowClient(); + const runId = randomUUID(); + let statusReads = 0; + client.startError = new WorkflowExecutionAlreadyStartedError( + "Workflow execution already started", + `devflow-run:${runId}`, + "runWorkflow", + ); + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + startReplayValidator: new FakeStartReplayValidator(), + startRunPollMs: 1, + statusReader: { + async getStatus() { + statusReads += 1; + return runStatus(runId, "completed", [], { + finalReportPath: statusReads < 2 ? null : "/workspace/run/run.report.md", + }); + }, + }, + }); + + await expect( + engine.startRun({ + runId, + requirementsMd: "Closed workflow id can be replayed with matching input.", + repoPath: "/repo", + baseBranch: "main", + }), + ).resolves.toEqual({ runId }); + expect(statusReads).toBe(2); + }); + + it("rejects an already-started workflow replay when the persisted input does not match", async () => { + const client = new FakeWorkflowClient(); + const runId = randomUUID(); + client.startError = new WorkflowExecutionAlreadyStartedError( + "Workflow execution already started", + `devflow-run:${runId}`, + "runWorkflow", + ); + const replayValidator = new FakeStartReplayValidator(new Error("start input mismatch")); + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + startReplayValidator: replayValidator, + startRunPollMs: 1, + statusReader: statusReader("executing"), + }); + + await expect( + engine.startRun({ + runId, + requirementsMd: "Changed start input must not be accepted.", + repoPath: "/repo", + baseBranch: "main", + }), + ).rejects.toThrow("start input mismatch"); + expect(replayValidator.inputs).toHaveLength(1); + }); + + it("fails an already-started workflow replay when no validator is configured", async () => { + const client = new FakeWorkflowClient(); + const runId = randomUUID(); + client.startError = new WorkflowExecutionAlreadyStartedError( + "Workflow execution already started", + `devflow-run:${runId}`, + "runWorkflow", + ); + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + statusReader: statusReader("executing"), + }); + + await expect( + engine.startRun({ + runId, + requirementsMd: "Unsafe replay.", + repoPath: "/repo", + baseBranch: "main", + }), + ).rejects.toMatchObject({ code: "internal_state_corruption" }); + }); + + it("propagates non-idempotent start failures", async () => { + const client = new FakeWorkflowClient(); + const runId = randomUUID(); + client.startError = new Error("temporal unavailable"); + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + statusReader: statusReader("created"), + }); + + await expect( + engine.startRun({ + runId, + requirementsMd: "Start should fail.", + repoPath: "/repo", + baseBranch: "main", + }), + ).rejects.toThrow("temporal unavailable"); + }); + + it("rejects startRun when an accepted workflow does not materialize in DB", async () => { + const client = new FakeWorkflowClient(); + const runId = randomUUID(); + let statusReads = 0; + client.startHandle = { + result: () => new Promise(() => undefined), + }; + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + startRunWaitMs: 1, + statusReader: { + async getStatus() { + statusReads += 1; + throw new Error("run not materialized yet"); + }, + }, + }); + + await expect( + engine.startRun({ + runId, + requirementsMd: "Workflow is accepted before a worker materializes the DB run.", + repoPath: "/repo", + baseBranch: "main", + }), + ).rejects.toMatchObject({ code: "temporal_start_timeout" }); + expect(statusReads).toBeGreaterThan(0); + expect(client.started?.workflowId).toBe(`devflow-run:${runId}`); + }); + + it("rejects startRun when startup reaches a failed DB state", async () => { + const client = new FakeWorkflowClient(); + const runId = randomUUID(); + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + statusReader: statusReader("failed"), + }); + + await expect( + engine.startRun({ + runId, + requirementsMd: "Startup failure must reject.", + repoPath: "/repo", + baseBranch: "main", + }), + ).rejects.toMatchObject({ code: "temporal_start_failed" }); + }); + + it("preserves a workflow startup DevflowError when status polling observes failure first", async () => { + const client = new FakeWorkflowClient(); + const runId = randomUUID(); + client.startHandle = { + result: async () => { + throw { + cause: new DevflowError("No eligible persona", { + class: "human_required", + code: "no_eligible_persona", + runId, + }), + }; + }, + }; + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + statusReader: statusReader("failed"), + }); + + await expect( + engine.startRun({ + runId, + requirementsMd: "Startup failure should preserve the engine error.", + repoPath: "/repo", + baseBranch: "main", + }), + ).rejects.toMatchObject({ code: "no_eligible_persona" }); + }); + + it("preserves serialized workflow startup DevflowErrors from Temporal failures", async () => { + const client = new FakeWorkflowClient(); + const runId = randomUUID(); + client.startHandle = { + result: async () => { + throw { + cause: { + cause: ApplicationFailure.create({ + message: "No eligible persona", + type: "DevflowError", + nonRetryable: true, + details: [{ class: "human_required", code: "no_eligible_persona", runId }], + }), + }, + }; + }, + }; + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + statusReader: statusReader("failed"), + }); + + await expect( + engine.startRun({ + runId, + requirementsMd: "Startup failure should preserve serialized engine errors.", + repoPath: "/repo", + baseBranch: "main", + }), + ).rejects.toMatchObject({ code: "no_eligible_persona" }); + }); + + it("waits for approval signals to reach the decision-applied DB boundary", async () => { + const runId = randomUUID(); + const approvalRequestId = randomUUID(); + const client = new FakeWorkflowClient(); + let readAttempts = 0; + const status = mutableStatusReader({ + runId, + state: "awaiting_approval", + approvals: [ + { id: approvalRequestId, phaseId: null, gateKey: "spec_approved", state: "pending" }, + ], + }); + client.onSignal = async () => { + status.set({ + runId, + state: "awaiting_approval", + approvals: [ + { id: approvalRequestId, phaseId: null, gateKey: "spec_approved", state: "approved" }, + { id: randomUUID(), phaseId: null, gateKey: "phase_plan_approved", state: "pending" }, + ], + }); + }; + const engine = new TemporalRunEngine({ + approvalSignalReader: { + async validateApprovalSignalInput() { + return "pending"; + }, + async readApprovalSignalResult() { + readAttempts += 1; + return "applied"; + }, + }, + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + statusReader: status, + }); + + await engine.signalApproval(runId, approvalRequestId, "approve", randomUUID()); + + expect(readAttempts).toBe(1); + expect(client.signals).toHaveLength(1); + }); + + it("waits for approval advancement beyond the materialization timeout until blocked", async () => { + const runId = randomUUID(); + const approvalRequestId = randomUUID(); + const client = new FakeWorkflowClient(); + const status = sequenceStatusReader(["executing", "executing", "awaiting_approval"]); + const engine = new TemporalRunEngine({ + approvalSignalReader: { + async validateApprovalSignalInput() { + return "pending"; + }, + async readApprovalSignalResult() { + return "applied"; + }, + }, + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + startRunWaitMs: 1, + statusReader: status, + }); + + await expect( + engine.signalApproval(runId, approvalRequestId, "approve", randomUUID()), + ).resolves.toBeUndefined(); + expect(client.signals).toHaveLength(1); + expect(status.calls).toBe(3); + }); + + it("waits for final report materialization after a terminal approval advancement", async () => { + const runId = randomUUID(); + const approvalRequestId = randomUUID(); + const client = new FakeWorkflowClient(); + let statusReads = 0; + const engine = new TemporalRunEngine({ + approvalSignalReader: { + async validateApprovalSignalInput() { + return "pending"; + }, + async readApprovalSignalResult() { + return "applied"; + }, + }, + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + statusReader: { + async getStatus() { + statusReads += 1; + return runStatus(runId, "completed", [], { + finalReportPath: statusReads < 2 ? null : "/workspace/run/run.report.md", + }); + }, + }, + }); + + await expect( + engine.signalApproval(runId, approvalRequestId, "approve", randomUUID()), + ).resolves.toBeUndefined(); + expect(client.signals.map((signal) => signal.name)).toEqual(["approve"]); + expect(statusReads).toBe(2); + }); + + it("preserves serialized DevflowErrors when approval advancement fails the workflow", async () => { + const runId = randomUUID(); + const approvalRequestId = randomUUID(); + const client = new FakeWorkflowClient(); + client.resultError = { + cause: { + cause: ApplicationFailure.create({ + message: "No eligible persona", + type: "DevflowError", + nonRetryable: true, + details: [{ class: "human_required", code: "no_eligible_persona", runId }], + }), + }, + }; + const engine = new TemporalRunEngine({ + approvalSignalReader: { + async validateApprovalSignalInput() { + return "pending"; + }, + async readApprovalSignalResult() { + return "applied"; + }, + }, + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + statusReader: statusReader("failed", { + finalReportPath: "/workspace/run/run.report.md", + }), + }); + + await expect( + engine.signalApproval(runId, approvalRequestId, "approve", randomUUID()), + ).rejects.toMatchObject({ code: "no_eligible_persona" }); + }); + + it("rejects when terminal final report repair cannot materialize", async () => { + const runId = randomUUID(); + const approvalRequestId = randomUUID(); + const client = new FakeWorkflowClient(); + let statusReads = 0; + const engine = new TemporalRunEngine({ + approvalSignalReader: { + async validateApprovalSignalInput() { + return "pending"; + }, + async readApprovalSignalResult() { + return "applied"; + }, + }, + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + startRunWaitMs: 1, + terminalReportWaitMs: 1, + statusReader: { + async getStatus() { + statusReads += 1; + return runStatus(runId, "completed", [], { finalReportPath: null }); + }, + }, + }); + + await expect( + engine.signalApproval(runId, approvalRequestId, "approve", randomUUID()), + ).rejects.toMatchObject({ code: "final_report_timeout" }); + expect(client.signals.map((signal) => signal.name)).toEqual(["approve"]); + expect(statusReads).toBeGreaterThan(0); + }); + + it("waits for resume advancement beyond active execution until blocked", async () => { + const runId = randomUUID(); + const client = new FakeWorkflowClient(); + const status = sequenceStatusReader(["paused", "executing", "awaiting_approval"]); + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + startRunWaitMs: 1, + statusReader: status, + }); + + await expect(engine.resumeRun(runId)).resolves.toBeUndefined(); + expect(client.signals.map((signal) => signal.name)).toEqual(["resume"]); + expect(status.calls).toBe(3); + }); + + it("waits for final report materialization when resume reaches completion", async () => { + const runId = randomUUID(); + const client = new FakeWorkflowClient(); + let statusReads = 0; + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + statusReader: { + async getStatus() { + statusReads += 1; + if (statusReads === 1) { + return runStatus(runId, "paused", []); + } + return runStatus(runId, "completed", [], { + finalReportPath: statusReads < 3 ? null : "/workspace/run/run.report.md", + }); + }, + }, + }); + + await expect(engine.resumeRun(runId)).resolves.toBeUndefined(); + expect(client.signals.map((signal) => signal.name)).toEqual(["resume"]); + expect(statusReads).toBe(3); + }); + + it("surfaces resume conflicts instead of converting them to signal timeouts", async () => { + const runId = randomUUID(); + const client = new FakeWorkflowClient(); + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + controlValidator: { + async validateResumeSignalInput() { + throw new DevflowError("Approval decision conflicts with the current request state", { + class: "human_required", + code: "approval_conflict", + runId, + }); + }, + }, + startRunPollMs: 1, + startRunWaitMs: 5, + statusReader: statusReader("paused"), + }); + + await expect(engine.resumeRun(runId)).rejects.toThrow( + "Approval decision conflicts with the current request state", + ); + expect(client.signals).toEqual([]); + }); + + it("surfaces resume conflicts that appear after the Temporal signal is accepted", async () => { + const runId = randomUUID(); + const client = new FakeWorkflowClient(); + let validationCalls = 0; + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + controlValidator: { + async validateResumeSignalInput() { + validationCalls += 1; + if (validationCalls > 1) { + throw new DevflowError("Approval decision conflicts with the current request state", { + class: "human_required", + code: "approval_conflict", + runId, + }); + } + }, + }, + startRunPollMs: 1, + startRunWaitMs: 5, + statusReader: statusReader("paused"), + }); + + await expect(engine.resumeRun(runId)).rejects.toMatchObject({ code: "approval_conflict" }); + expect(client.signals.map((signal) => signal.name)).toEqual(["resume"]); + expect(validationCalls).toBe(2); + }); + + it("waits for pause beyond the materialization timeout until the DB boundary is reached", async () => { + const runId = randomUUID(); + const client = new FakeWorkflowClient(); + const status = sequenceStatusReader(["executing", "executing", "paused"]); + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + startRunWaitMs: 1, + statusReader: status, + }); + + await expect(engine.pauseRun(runId)).resolves.toBeUndefined(); + expect(client.signals.map((signal) => signal.name)).toEqual(["pause"]); + expect(status.calls).toBe(3); + }); + + it("rejects when abort reaches terminal state without a report path", async () => { + const runId = randomUUID(); + const client = new FakeWorkflowClient(); + let statusReads = 0; + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + startRunWaitMs: 1, + terminalReportWaitMs: 1, + statusReader: { + async getStatus() { + statusReads += 1; + return runStatus(runId, statusReads === 1 ? "executing" : "aborted", [], { + finalReportPath: null, + }); + }, + }, + }); + + await expect(engine.abortRun(runId, "stop")).rejects.toMatchObject({ + code: "final_report_timeout", + }); + expect(client.signals.map((signal) => signal.name)).toEqual(["abort"]); + expect(statusReads).toBeGreaterThan(1); + }); + + it("rejects when abort races with another terminal state instead of being applied", async () => { + const runId = randomUUID(); + const client = new FakeWorkflowClient(); + let statusReads = 0; + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + startRunWaitMs: 1, + statusReader: { + async getStatus() { + statusReads += 1; + return runStatus(runId, statusReads === 1 ? "executing" : "completed", [], { + finalReportPath: "/workspace/run/run.report.md", + }); + }, + }, + }); + + await expect(engine.abortRun(runId, "stop")).rejects.toMatchObject({ + code: "temporal_signal_failed", + }); + expect(client.signals.map((signal) => signal.name)).toEqual(["abort"]); + expect(statusReads).toBe(2); + }); + + it("rejects abort when the workflow closes before the signal is accepted without aborting", async () => { + const runId = randomUUID(); + const client = new FakeWorkflowClient(); + let statusReads = 0; + client.signalError = new WorkflowNotFoundError( + "Completed workflow", + `devflow-run:${runId}`, + undefined, + ); + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + startRunWaitMs: 1, + statusReader: { + async getStatus() { + statusReads += 1; + return runStatus(runId, statusReads === 1 ? "executing" : "completed", [], { + finalReportPath: "/workspace/run/run.report.md", + }); + }, + }, + }); + + await expect(engine.abortRun(runId, "stop")).rejects.toMatchObject({ + code: "temporal_signal_failed", + }); + expect(client.signals.map((signal) => signal.name)).toEqual(["abort"]); + expect(statusReads).toBe(2); + }); + + it("settles approval when the workflow closes before the signal is accepted", async () => { + const runId = randomUUID(); + const approvalRequestId = randomUUID(); + const client = new FakeWorkflowClient(); + let statusReads = 0; + let resultReads = 0; + client.signalError = closedWorkflowError(runId); + const engine = new TemporalRunEngine({ + approvalSignalReader: { + async validateApprovalSignalInput() { + return "pending"; + }, + async readApprovalSignalResult() { + resultReads += 1; + return "applied"; + }, + }, + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + startRunWaitMs: 1, + statusReader: { + async getStatus() { + statusReads += 1; + return runStatus(runId, "completed", [], { + finalReportPath: "/workspace/run/run.report.md", + }); + }, + }, + }); + + await expect( + engine.signalApproval(runId, approvalRequestId, "approve", randomUUID()), + ).resolves.toBeUndefined(); + expect(client.signals.map((signal) => signal.name)).toEqual(["approve"]); + expect(statusReads).toBe(1); + expect(resultReads).toBe(1); + }); + + it("repairs side effects when a closed workflow already applied the approval signal", async () => { + const runId = randomUUID(); + const approvalRequestId = randomUUID(); + const client = new FakeWorkflowClient(); + let resultReads = 0; + let sideEffectReplays = 0; + let statusReads = 0; + client.signalError = closedWorkflowError(runId); + const engine = new TemporalRunEngine({ + approvalSignalReader: { + async validateApprovalSignalInput() { + return "pending"; + }, + async readApprovalSignalResult() { + resultReads += 1; + return "applied"; + }, + async replayAppliedApprovalSideEffects() { + sideEffectReplays += 1; + }, + }, + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + startRunWaitMs: 1, + statusReader: { + async getStatus() { + statusReads += 1; + return runStatus(runId, "completed", [], { + finalReportPath: sideEffectReplays === 0 ? null : "/workspace/run/run.report.md", + }); + }, + }, + }); + + await expect( + engine.signalApproval(runId, approvalRequestId, "approve", randomUUID()), + ).resolves.toBeUndefined(); + expect(client.signals.map((signal) => signal.name)).toEqual(["approve"]); + expect(resultReads).toBe(1); + expect(sideEffectReplays).toBe(1); + expect(statusReads).toBe(2); + }); + + it("rejects closed workflow approval when the requested decision was not applied", async () => { + const runId = randomUUID(); + const approvalRequestId = randomUUID(); + const client = new FakeWorkflowClient(); + let resultReads = 0; + client.signalError = closedWorkflowError(runId); + const engine = new TemporalRunEngine({ + approvalSignalReader: { + async validateApprovalSignalInput() { + return "pending"; + }, + async readApprovalSignalResult() { + resultReads += 1; + throw new Error("approval_state=rejected"); + }, + }, + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + startRunWaitMs: 1, + statusReader: statusReader("failed", { finalReportPath: "/workspace/run/run.report.md" }), + }); + + await expect( + engine.signalApproval(runId, approvalRequestId, "approve", randomUUID()), + ).rejects.toThrow("approval_state=rejected"); + expect(client.signals.map((signal) => signal.name)).toEqual(["approve"]); + expect(resultReads).toBe(1); + }); + + it("rejects pause when the workflow closes before the signal is accepted", async () => { + const runId = randomUUID(); + const client = new FakeWorkflowClient(); + let statusReads = 0; + client.signalError = closedWorkflowError(runId); + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + startRunWaitMs: 1, + statusReader: { + async getStatus() { + statusReads += 1; + return runStatus(runId, statusReads === 1 ? "executing" : "completed", [], { + finalReportPath: "/workspace/run/run.report.md", + }); + }, + }, + }); + + await expect(engine.pauseRun(runId)).rejects.toMatchObject({ + code: "temporal_signal_failed", + }); + expect(client.signals.map((signal) => signal.name)).toEqual(["pause"]); + expect(statusReads).toBe(2); + }); + + it("settles resume when the workflow closes before the signal is accepted", async () => { + const runId = randomUUID(); + const client = new FakeWorkflowClient(); + let statusReads = 0; + client.signalError = closedWorkflowError(runId); + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + controlValidator: { + async validateResumeSignalInput() { + return undefined; + }, + }, + startRunPollMs: 1, + startRunWaitMs: 1, + statusReader: { + async getStatus() { + statusReads += 1; + return runStatus(runId, statusReads === 1 ? "paused" : "completed", [], { + finalReportPath: "/workspace/run/run.report.md", + }); + }, + }, + }); + + await expect(engine.resumeRun(runId)).resolves.toBeUndefined(); + expect(client.signals.map((signal) => signal.name)).toEqual(["resume"]); + expect(statusReads).toBe(2); + }); + + it("rejects approval signals when the requested decision is not applied", async () => { + const runId = randomUUID(); + const approvalRequestId = randomUUID(); + const client = new FakeWorkflowClient(); + const engine = new TemporalRunEngine({ + approvalSignalReader: { + async validateApprovalSignalInput() { + return "pending"; + }, + async readApprovalSignalResult() { + throw new Error("approval conflict"); + }, + }, + client: client as unknown as WorkflowClient, + startRunPollMs: 1, + statusReader: statusReader("awaiting_approval"), + }); + + await expect( + engine.signalApproval(runId, approvalRequestId, "approve", randomUUID()), + ).rejects.toThrow("approval conflict"); + expect(client.signals).toHaveLength(1); + }); + + it("replays applied terminal approval side effects instead of signaling a closed workflow", async () => { + const runId = randomUUID(); + const client = new FakeWorkflowClient(); + const sideEffects: Array<{ runId: string; action: ApprovalDecisionAction }> = []; + const engine = new TemporalRunEngine({ + approvalSignalReader: { + async validateApprovalSignalInput() { + return "applied"; + }, + async readApprovalSignalResult() { + return "applied"; + }, + async replayAppliedApprovalSideEffects(replayRunId, action) { + sideEffects.push({ runId: replayRunId, action }); + }, + }, + client: client as unknown as WorkflowClient, + statusReader: statusReader("completed", { + finalReportPath: "/workspace/run/run.report.md", + }), + }); + + await engine.signalApproval(runId, randomUUID(), "approve", randomUUID()); + + expect(client.signals).toEqual([]); + expect(sideEffects).toEqual([{ runId, action: "approve" }]); + }); + + it("requires an approval signal reader when waiting for signal completion", async () => { + const runId = randomUUID(); + const client = new FakeWorkflowClient(); + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + statusReader: statusReader("awaiting_approval"), + }); + + await expect( + engine.signalApproval(runId, randomUUID(), "approve", randomUUID()), + ).rejects.toMatchObject({ code: "internal_state_corruption" }); + expect(client.signals).toEqual([]); + }); + + it("treats terminal pause and abort controls as no-ops for M4 parity", async () => { + const client = new FakeWorkflowClient(); + const runId = randomUUID(); + const engine = new TemporalRunEngine({ + client: client as unknown as WorkflowClient, + statusReader: statusReader("completed"), + }); + + await engine.pauseRun(runId); + await engine.abortRun(runId, "already done"); + + expect(client.signals).toEqual([]); + }); +}); + +class FakeWorkflowClient { + started: + | { + workflowId: string; + workflowIdConflictPolicy?: string; + workflowIdReusePolicy?: string; + taskQueue: string; + args: unknown[]; + } + | undefined; + signals: Array<{ workflowId: string; name: string; payload: unknown }> = []; + onSignal: (() => Promise) | undefined; + signalError: unknown; + startHandle: Pick | undefined; + startError: unknown; + resultError: unknown; + + async start( + _workflow: unknown, + options: { + workflowId: string; + workflowIdConflictPolicy?: string; + workflowIdReusePolicy?: string; + taskQueue: string; + args: unknown[]; + }, + ) { + if (this.startError !== undefined) { + throw this.startError; + } + this.started = { + workflowId: options.workflowId, + taskQueue: options.taskQueue, + args: options.args, + ...(options.workflowIdConflictPolicy === undefined + ? {} + : { workflowIdConflictPolicy: options.workflowIdConflictPolicy }), + ...(options.workflowIdReusePolicy === undefined + ? {} + : { workflowIdReusePolicy: options.workflowIdReusePolicy }), + }; + return this.startHandle; + } + + getHandle(workflowId: string): Pick { + return { + result: async () => { + if (this.resultError !== undefined) { + throw this.resultError; + } + return undefined; + }, + signal: async (definition, ...args) => { + const name = typeof definition === "string" ? definition : definition.name; + const payload = args[0] as unknown; + this.signals.push({ workflowId, name, payload }); + if (this.signalError !== undefined) { + throw this.signalError; + } + await this.onSignal?.(); + }, + }; + } +} + +class FakeStartReplayValidator { + readonly inputs: RunStartInput[] = []; + + constructor(private readonly error?: Error) {} + + async validateStartReplay(input: RunStartInput): Promise { + this.inputs.push(input); + if (this.error !== undefined) { + throw this.error; + } + } +} + +function statusReader(state: string, overrides: Partial = {}) { + return { + async getStatus(runId: string): Promise { + return runStatus(runId, state, [], overrides); + }, + }; +} + +function sequenceStatusReader(states: string[]) { + let calls = 0; + return { + get calls() { + return calls; + }, + async getStatus(runId: string): Promise { + const state = states[Math.min(calls, states.length - 1)] ?? "failed"; + calls += 1; + return runStatus(runId, state, []); + }, + }; +} + +function mutableStatusReader(initial: { + runId: string; + state: string; + approvals: RunStatus["approvals"]; +}) { + let current = initial; + let calls = 0; + return { + get calls() { + return calls; + }, + set(next: typeof initial) { + current = next; + }, + async getStatus(runId: string): Promise { + calls += 1; + return runStatus(runId, current.state, current.approvals); + }, + }; +} + +function runStatus( + runId: string, + state: string, + approvals: RunStatus["approvals"], + overrides: Partial = {}, +): RunStatus { + return { + run: { + id: runId, + state, + repoPath: "/repo", + baseBranch: "main", + worktreeRoot: "/workspace/run/main", + currentPhaseId: null, + finalReportPath: null, + startedAt: null, + endedAt: null, + ...overrides, + }, + approvals, + eventsTail: [], + phases: [], + }; +} + +function closedWorkflowError(runId: string): WorkflowNotFoundError { + return new WorkflowNotFoundError("Completed workflow", `devflow-run:${runId}`, undefined); +} diff --git a/packages/workflows/src/temporal-run-engine.ts b/packages/workflows/src/temporal-run-engine.ts new file mode 100644 index 0000000..71903e6 --- /dev/null +++ b/packages/workflows/src/temporal-run-engine.ts @@ -0,0 +1,666 @@ +import { randomUUID } from "node:crypto"; + +import { + ApplicationFailure, + type WorkflowClient, + WorkflowExecutionAlreadyStartedError, + type WorkflowHandle, + WorkflowNotFoundError, +} from "@temporalio/client"; + +import { type ApprovalDecisionAction, DevflowError } from "@devflow/core"; +import type { RunEngine, RunStartInput, RunStatus } from "@devflow/run-engine"; + +import type { AbortSignalPayload } from "./types.js"; +import { abortSignal, approveSignal, pauseSignal, resumeSignal, runWorkflow } from "./workflow.js"; + +export const temporalNamespace = "devflow"; +export const temporalTaskQueue = "devflow-runs"; + +export interface TemporalRunEngineOptions { + client: WorkflowClient; + taskQueue?: string; + workflowIdPrefix?: string; + statusReader: Pick; + controlValidator?: { + validateResumeSignalInput(runId: string): Promise; + }; + startReplayValidator?: { validateStartReplay(input: RunStartInput): Promise }; + approvalSignalReader?: { + validateApprovalSignalInput( + runId: string, + approvalRequestId: string, + action: ApprovalDecisionAction, + clientToken: string, + ): Promise<"pending" | "applied">; + readApprovalSignalResult( + runId: string, + approvalRequestId: string, + action: ApprovalDecisionAction, + clientToken: string, + ): Promise<"pending" | "applied">; + replayAppliedApprovalSideEffects?(runId: string, action: ApprovalDecisionAction): Promise; + }; + awaitRunStart?: boolean; + awaitSignals?: boolean; + startRunWaitMs?: number; + startRunPollMs?: number; + terminalReportWaitMs?: number; +} + +export class TemporalRunEngine implements RunEngine { + private readonly client: WorkflowClient; + private readonly taskQueue: string; + private readonly workflowIdPrefix: string; + private readonly statusReader: Pick; + private readonly controlValidator: + | { + validateResumeSignalInput(runId: string): Promise; + } + | undefined; + private readonly startReplayValidator: + | { validateStartReplay(input: RunStartInput): Promise } + | undefined; + private readonly approvalSignalReader: TemporalRunEngineOptions["approvalSignalReader"]; + private readonly awaitRunStart: boolean; + private readonly awaitSignals: boolean; + private readonly startRunWaitMs: number; + private readonly startRunPollMs: number; + private readonly terminalReportWaitMs: number; + + constructor(options: TemporalRunEngineOptions) { + this.client = options.client; + this.taskQueue = options.taskQueue ?? temporalTaskQueue; + this.workflowIdPrefix = options.workflowIdPrefix ?? "devflow-run"; + this.statusReader = options.statusReader; + this.controlValidator = options.controlValidator; + this.startReplayValidator = options.startReplayValidator; + this.approvalSignalReader = options.approvalSignalReader; + this.awaitRunStart = options.awaitRunStart ?? true; + this.awaitSignals = options.awaitSignals ?? true; + this.startRunWaitMs = options.startRunWaitMs ?? 30_000; + this.startRunPollMs = options.startRunPollMs ?? 50; + this.terminalReportWaitMs = options.terminalReportWaitMs ?? 90_000; + } + + async startRun(input: RunStartInput): Promise<{ runId: string }> { + const runId = input.runId ?? randomUUID(); + let handle: Pick, "result"> | undefined; + try { + handle = await this.client.start(runWorkflow, { + args: [{ ...input, runId }], + taskQueue: this.taskQueue, + workflowId: this.workflowId(runId), + workflowIdConflictPolicy: "FAIL", + workflowIdReusePolicy: "REJECT_DUPLICATE", + }); + } catch (error) { + if (!(error instanceof WorkflowExecutionAlreadyStartedError)) { + throw error; + } + const replayStatus = await this.validateAlreadyStartedReplay({ ...input, runId }); + if (isTerminalRunState(replayStatus.run.state)) { + await this.waitForTerminalReportIfNeeded(runId, replayStatus); + return { runId }; + } + } + if (this.awaitRunStart) { + await this.waitForRunStart(runId, handle); + } + return { runId }; + } + + async signalApproval( + runId: string, + approvalRequestId: string, + action: ApprovalDecisionAction, + clientToken: string, + comment?: string, + ): Promise { + if (this.awaitSignals && this.approvalSignalReader === undefined) { + throw new DevflowError("Temporal approval signal reader is not configured", { + class: "fatal", + code: "internal_state_corruption", + runId, + }); + } + const initialDecision = await this.validateApprovalSignalInput( + runId, + approvalRequestId, + action, + clientToken, + ); + if (initialDecision === "applied") { + const status = await this.getStatus(runId); + if (isTerminalRunState(status.run.state)) { + await this.approvalSignalReader?.replayAppliedApprovalSideEffects?.(runId, action); + await this.waitForTerminalReportIfNeeded(runId, status); + return; + } + } + const workflowHandle = this.handle(runId); + try { + await workflowHandle.signal(approveSignal, { + runId, + approvalRequestId, + action, + clientToken, + ...(comment === undefined ? {} : { comment }), + }); + } catch (error) { + if ( + await this.settleClosedApprovalSignal(runId, approvalRequestId, action, clientToken, error) + ) { + return; + } + throw error; + } + if (this.awaitSignals) { + await this.waitForApprovalSignalResult(runId, approvalRequestId, action, clientToken); + if (action === "approve" || action === "request_changes") { + const status = await this.waitForStatusWithoutTimeout( + runId, + (candidate) => !isActiveRunState(candidate.run.state), + ); + if (status.run.state === "failed" || status.run.state === "aborted") { + await this.throwWorkflowFailureOrGeneric( + runId, + workflowHandle, + "Temporal approval signal failed during advancement", + ); + } + await this.waitForTerminalReportIfNeeded(runId, status); + } else { + await this.waitForTerminalReportIfNeeded( + runId, + await this.waitForStatusWithoutTimeout(runId, (candidate) => + isTerminalRunState(candidate.run.state), + ), + ); + } + } + } + + async pauseRun(runId: string): Promise { + const before = await this.getStatus(runId); + if ( + isTerminalRunState(before.run.state) || + !["planning", "executing", "awaiting_approval"].includes(before.run.state) + ) { + return; + } + try { + await this.handle(runId).signal(pauseSignal, { runId, clientToken: randomUUID() }); + } catch (error) { + const settled = await this.settleClosedWorkflowSignal(runId, error); + if (settled !== undefined) { + await this.throwControlNotApplied(runId, "pause", "paused", settled); + } + throw error; + } + if (this.awaitSignals) { + const status = await this.waitForStatusWithoutTimeout( + runId, + (status) => status.run.state === "paused" || isTerminalRunState(status.run.state), + ); + if (status.run.state !== "paused") { + await this.throwControlNotApplied(runId, "pause", "paused", status); + } + } + } + + async resumeRun(runId: string): Promise { + const before = await this.getStatus(runId); + if (before.run.state !== "paused") { + return; + } + await this.controlValidator?.validateResumeSignalInput(runId); + try { + await this.handle(runId).signal(resumeSignal, { runId, clientToken: randomUUID() }); + } catch (error) { + if ((await this.settleClosedWorkflowSignal(runId, error)) !== undefined) { + return; + } + throw error; + } + if (this.awaitSignals) { + const status = await this.waitForResumeSignalResult(runId); + if (status.run.state === "failed" || status.run.state === "aborted") { + throw new DevflowError("Temporal resume failed", { + class: "human_required", + code: "temporal_signal_failed", + runId, + recoveryHint: `run_state=${status.run.state}`, + }); + } + await this.waitForTerminalReportIfNeeded(runId, status); + } + } + + async abortRun(runId: string, reason: string): Promise { + const before = await this.getStatus(runId); + if (isTerminalRunState(before.run.state)) { + return; + } + const payload: AbortSignalPayload = { + runId, + reason, + clientToken: randomUUID(), + }; + try { + await this.handle(runId).signal(abortSignal, payload); + } catch (error) { + const settled = await this.settleClosedWorkflowSignal(runId, error); + if (settled !== undefined) { + if (settled.run.state === "aborted") { + return; + } + await this.throwControlNotApplied(runId, "abort", "aborted", settled); + } + throw error; + } + if (this.awaitSignals) { + const status = await this.waitForTerminalReportIfNeeded( + runId, + await this.waitForStatusWithoutTimeout(runId, (status) => + isTerminalRunState(status.run.state), + ), + ); + if (status.run.state !== "aborted") { + await this.throwControlNotApplied(runId, "abort", "aborted", status); + } + } + } + + getStatus(runId: string): Promise { + return this.statusReader.getStatus(runId); + } + + workflowId(runId: string): string { + return `${this.workflowIdPrefix}:${runId}`; + } + + private handle(runId: string) { + return this.client.getHandle(this.workflowId(runId)); + } + + private async settleClosedWorkflowSignal( + runId: string, + error: unknown, + ): Promise { + if (!(error instanceof WorkflowNotFoundError)) { + return undefined; + } + const latest = await this.getStatus(runId); + if (!isTerminalRunState(latest.run.state)) { + return undefined; + } + return this.waitForTerminalReportIfNeeded(runId, latest); + } + + private async settleClosedApprovalSignal( + runId: string, + approvalRequestId: string, + action: ApprovalDecisionAction, + clientToken: string, + error: unknown, + ): Promise { + if (!(error instanceof WorkflowNotFoundError)) { + return false; + } + await this.waitForApprovalSignalResult(runId, approvalRequestId, action, clientToken); + const latest = await this.getStatus(runId); + if (!isTerminalRunState(latest.run.state)) { + return false; + } + await this.approvalSignalReader?.replayAppliedApprovalSideEffects?.(runId, action); + await this.waitForTerminalReportIfNeeded(runId, latest); + return true; + } + + private async waitForRunStart( + runId: string, + handle?: Pick, "result">, + ): Promise { + const workflowPromise = handle?.result().then( + () => this.statusReader.getStatus(runId), + (cause: unknown) => { + throw unwrapTemporalStartFailure(runId, cause); + }, + ); + const materializedStatus = await (workflowPromise === undefined + ? this.waitForStatus(runId, () => true) + : Promise.race([this.waitForStatus(runId, () => true), workflowPromise])); + if (!isActiveRunState(materializedStatus.run.state)) { + await this.throwIfStartupFailed(runId, materializedStatus, handle); + return this.waitForTerminalReportIfNeeded(runId, materializedStatus); + } + const status = await (workflowPromise === undefined + ? this.waitForStatusWithoutTimeout( + runId, + (candidate) => !isActiveRunState(candidate.run.state), + ) + : Promise.race([ + this.waitForStatusWithoutTimeout( + runId, + (candidate) => !isActiveRunState(candidate.run.state), + ), + workflowPromise, + ])); + await this.throwIfStartupFailed(runId, status, handle); + return this.waitForTerminalReportIfNeeded(runId, status); + } + + private async throwIfStartupFailed( + runId: string, + status: RunStatus, + handle?: Pick, "result">, + ): Promise { + if (status.run.state === "failed" || status.run.state === "aborted") { + if (handle !== undefined) { + try { + await handle.result(); + } catch (cause) { + throw unwrapTemporalStartFailure(runId, cause); + } + } + throw new DevflowError("Temporal run failed during startup", { + class: "human_required", + code: "temporal_start_failed", + runId, + recoveryHint: `run_state=${status.run.state}`, + }); + } + } + + private async validateAlreadyStartedReplay( + input: RunStartInput & { runId: string }, + ): Promise { + if (this.startReplayValidator === undefined) { + throw new DevflowError("Temporal start replay validation is not configured", { + class: "fatal", + code: "internal_state_corruption", + runId: input.runId, + }); + } + const status = await this.waitForStatus(input.runId, () => true); + await this.startReplayValidator.validateStartReplay(input); + return status; + } + + private async waitForStatus( + runId: string, + isReady: (status: RunStatus) => boolean, + ): Promise { + const deadline = Date.now() + this.startRunWaitMs; + let lastError: unknown; + + do { + try { + const status = await this.statusReader.getStatus(runId); + if (isReady(status)) { + return status; + } + } catch (error) { + lastError = error; + } + await sleep(this.startRunPollMs); + } while (Date.now() < deadline); + + throw new DevflowError("Temporal run did not materialize before timeout", { + class: "human_required", + code: "temporal_start_timeout", + runId, + recoveryHint: "Check the Temporal worker process and task queue configuration.", + cause: lastError, + }); + } + + private async waitForStatusWithoutTimeout( + runId: string, + isReady: (status: RunStatus) => boolean, + ): Promise { + for (;;) { + const status = await this.statusReader.getStatus(runId); + if (isReady(status)) { + return status; + } + await sleep(this.startRunPollMs); + } + } + + private async waitForResumeSignalResult(runId: string): Promise { + for (;;) { + const status = await this.statusReader.getStatus(runId); + if (status.run.state !== "paused" && !isActiveRunState(status.run.state)) { + return status; + } + if (status.run.state === "paused") { + await this.controlValidator?.validateResumeSignalInput(runId); + } + await sleep(this.startRunPollMs); + } + } + + private async waitForTerminalReportIfNeeded( + runId: string, + status: RunStatus, + ): Promise { + if (!isTerminalRunState(status.run.state) || status.run.finalReportPath !== null) { + return status; + } + const deadline = Date.now() + this.terminalReportWaitMs; + let latest = status; + do { + await sleep(this.startRunPollMs); + latest = await this.statusReader.getStatus(runId); + if (isTerminalRunState(latest.run.state) && latest.run.finalReportPath !== null) { + return latest; + } + if (!isTerminalRunState(latest.run.state)) { + return latest; + } + } while (Date.now() < deadline); + + throw new DevflowError("Temporal terminal run report did not materialize before timeout", { + class: "human_required", + code: "final_report_timeout", + runId, + recoveryHint: `run_state=${latest.run.state}`, + }); + } + + private async throwControlNotApplied( + runId: string, + control: "abort" | "pause", + expectedState: "aborted" | "paused", + status: RunStatus, + ): Promise { + const latest = await this.waitForTerminalReportIfNeeded(runId, status); + throw new DevflowError(`Temporal ${control} signal was not applied`, { + class: "human_required", + code: "temporal_signal_failed", + runId, + recoveryHint: `expected_run_state=${expectedState};actual_run_state=${latest.run.state}`, + }); + } + + private async validateApprovalSignalInput( + runId: string, + approvalRequestId: string, + action: ApprovalDecisionAction, + clientToken: string, + ): Promise<"pending" | "applied"> { + if (this.approvalSignalReader === undefined) { + return "pending"; + } + return this.approvalSignalReader.validateApprovalSignalInput( + runId, + approvalRequestId, + action, + clientToken, + ); + } + + private async waitForApprovalSignalResult( + runId: string, + approvalRequestId: string, + action: ApprovalDecisionAction, + clientToken: string, + ): Promise { + const deadline = Date.now() + this.startRunWaitMs; + const reader = this.approvalSignalReader; + if (reader === undefined) { + throw new DevflowError("Temporal approval signal reader is not configured", { + class: "fatal", + code: "internal_state_corruption", + runId, + }); + } + + do { + const result = await reader.readApprovalSignalResult( + runId, + approvalRequestId, + action, + clientToken, + ); + if (result === "applied") { + return; + } + await sleep(this.startRunPollMs); + } while (Date.now() < deadline); + + throw new DevflowError("Temporal approval signal did not apply before timeout", { + class: "human_required", + code: "temporal_signal_timeout", + runId, + recoveryHint: "Check the Temporal worker process and approval request state.", + }); + } + + private async throwWorkflowFailureOrGeneric( + runId: string, + handle: Pick, "result">, + message: string, + ): Promise { + try { + await handle.result(); + } catch (error) { + throw unwrapTemporalFailure(runId, error, "temporal_signal_failed"); + } + throw new DevflowError(message, { + class: "human_required", + code: "temporal_signal_failed", + runId, + recoveryHint: "run_state=failed", + }); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); +} + +function isActiveRunState(state: string): boolean { + return state === "created" || state === "bound" || state === "executing" || state === "planning"; +} + +function isTerminalRunState(state: string): boolean { + return state === "completed" || state === "failed" || state === "aborted"; +} + +function unwrapTemporalStartFailure(runId: string, cause: unknown): unknown { + return unwrapTemporalFailure(runId, cause, "temporal_start_failed"); +} + +function unwrapTemporalFailure(runId: string, cause: unknown, fallbackCode: string): unknown { + const maybeCause = nestedCause(cause); + if (maybeCause instanceof DevflowError) { + return maybeCause; + } + return new DevflowError("Temporal workflow failed", { + class: "human_required", + code: fallbackCode, + runId, + recoveryHint: "Inspect the Temporal workflow failure and run events.", + cause, + }); +} + +function nestedCause(error: unknown): unknown { + let current = error; + const seen = new Set(); + while (current !== null && typeof current === "object" && !seen.has(current)) { + seen.add(current); + if (current instanceof DevflowError) { + return current; + } + if (isApplicationFailureLike(current)) { + const devflowError = devflowErrorFromApplicationFailure(current); + if (devflowError !== undefined) { + return devflowError; + } + } + current = (current as { cause?: unknown }).cause; + } + return undefined; +} + +function devflowErrorFromApplicationFailure( + error: ApplicationFailureLike, +): DevflowError | undefined { + if (error.type !== "DevflowError") { + return undefined; + } + const details = error.details?.[0]; + if (!isSerializedDevflowError(details)) { + return undefined; + } + return new DevflowError(error.message ?? "Temporal activity failed with DevflowError", { + class: details.class, + code: details.code, + ...(details.runId === undefined ? {} : { runId: details.runId }), + ...(details.phaseId === undefined ? {} : { phaseId: details.phaseId }), + ...(details.recoveryHint === undefined ? {} : { recoveryHint: details.recoveryHint }), + cause: error, + }); +} + +interface ApplicationFailureLike { + message?: string; + type?: string | null; + details?: unknown[] | null; +} + +function isApplicationFailureLike(value: unknown): value is ApplicationFailureLike { + return ( + value instanceof ApplicationFailure || + (value !== null && + typeof value === "object" && + "type" in value && + (value as { type?: unknown }).type === "DevflowError") + ); +} + +function isSerializedDevflowError(value: unknown): value is { + class: "recoverable" | "human_required" | "fatal"; + code: string; + runId?: string; + phaseId?: string; + recoveryHint?: string; +} { + if (value === null || typeof value !== "object") { + return false; + } + const candidate = value as Record; + return ( + (candidate.class === "recoverable" || + candidate.class === "human_required" || + candidate.class === "fatal") && + typeof candidate.code === "string" && + (candidate.runId === undefined || typeof candidate.runId === "string") && + (candidate.phaseId === undefined || typeof candidate.phaseId === "string") && + (candidate.recoveryHint === undefined || typeof candidate.recoveryHint === "string") + ); +} diff --git a/packages/workflows/src/types.ts b/packages/workflows/src/types.ts new file mode 100644 index 0000000..e84fee4 --- /dev/null +++ b/packages/workflows/src/types.ts @@ -0,0 +1,24 @@ +import type { ApprovalDecisionAction } from "@devflow/core"; + +export interface ApprovalSignalPayload { + runId: string; + approvalRequestId: string; + action: ApprovalDecisionAction; + clientToken: string; + comment?: string; + idempotencyKey?: string; +} + +export interface RunSignalPayload { + runId: string; + clientToken?: string; + idempotencyKey?: string; +} + +export interface AbortSignalPayload extends RunSignalPayload { + reason: string; +} + +export interface RunWorkflowResult { + runId: string; +} diff --git a/packages/workflows/src/workflow.integration.test.ts b/packages/workflows/src/workflow.integration.test.ts new file mode 100644 index 0000000..4968541 --- /dev/null +++ b/packages/workflows/src/workflow.integration.test.ts @@ -0,0 +1,440 @@ +import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; + +import type { RunStatus } from "@devflow/run-engine"; +import { ApplicationFailure } from "@temporalio/activity"; +import { TestWorkflowEnvironment } from "@temporalio/testing"; +import { Worker } from "@temporalio/worker"; +import { describe, expect, it } from "vitest"; + +import type { DevflowActivities } from "./activities.js"; +import { TemporalRunEngine } from "./temporal-run-engine.js"; +import { abortSignal, runWorkflow } from "./workflow.js"; + +describe("runWorkflow Temporal integration", () => { + it("orchestrates a fake M4-style run through a real Temporal worker", async () => { + const testEnv = await TestWorkflowEnvironment.createTimeSkipping(); + try { + const runId = randomUUID(); + const taskQueue = `devflow-workflow-test-${runId}`; + const workflowId = `devflow-run:${runId}`; + let status = runStatus(runId, "created", []); + let advanceCalls = 0; + let reportComposed = false; + + const activities: DevflowActivities = { + async prepareRunActivity(input) { + const preparedRunId = input.runId ?? runId; + status = runStatus(preparedRunId, "created", []); + return { runId: preparedRunId }; + }, + async lockBindingsActivity(input) { + status = runStatus(input.runId ?? runId, "executing", []); + }, + async failRunActivity(input) { + status = runStatus(input.runId, "failed", []); + }, + async advanceRunActivity(input) { + advanceCalls += 1; + status = runStatus(input.runId, "completed", []); + return status; + }, + async signalApprovalActivity() { + throw new Error("approval signal should not be needed for this workflow path"); + }, + async pauseRunActivity(payload) { + status = runStatus(payload.runId, "paused", []); + }, + async resumeRunActivity(payload) { + status = runStatus(payload.runId, "executing", []); + }, + async abortRunActivity(payload) { + status = runStatus(payload.runId, "aborted", []); + }, + async getStatusActivity() { + return status; + }, + async isRunTerminalActivity() { + return ["completed", "failed", "aborted"].includes(status.run.state); + }, + async composeFinalReportActivity(runIdToReport) { + reportComposed = true; + status = runStatus(runIdToReport, "completed", [], { + finalReportPath: "/workspace/run/run.report.md", + }); + }, + }; + + const worker = await Worker.create({ + activities, + connection: testEnv.nativeConnection, + ...(testEnv.namespace === undefined ? {} : { namespace: testEnv.namespace }), + taskQueue, + workflowsPath: fileURLToPath(new URL("./workflow.ts", import.meta.url)), + }); + + await expect( + worker.runUntil(async () => { + const handle = await testEnv.client.workflow.start(runWorkflow, { + args: [ + { + runId, + requirementsMd: "Run the workflow integration parity path.", + repoPath: "/repo", + baseBranch: "main", + }, + ], + taskQueue, + workflowId, + }); + return handle.result(); + }), + ).resolves.toEqual({ runId }); + + expect(advanceCalls).toBe(1); + expect(reportComposed).toBe(true); + } finally { + await testEnv.teardown(); + } + }, 120_000); + + it("processes a queued abort signal before starting another advance activity", async () => { + const testEnv = await TestWorkflowEnvironment.createTimeSkipping(); + try { + const runId = randomUUID(); + const taskQueue = `devflow-workflow-test-${runId}`; + const workflowId = `devflow-run:${runId}`; + const lockStarted = deferred(); + const releaseLock = deferred(); + let status = runStatus(runId, "created", []); + let advanceCalls = 0; + let abortCalls = 0; + let reportComposed = false; + + const activities: DevflowActivities = { + async prepareRunActivity(input) { + const preparedRunId = input.runId ?? runId; + status = runStatus(preparedRunId, "created", []); + return { runId: preparedRunId }; + }, + async lockBindingsActivity(input) { + status = runStatus(input.runId ?? runId, "executing", []); + lockStarted.resolve(undefined); + await releaseLock.promise; + }, + async failRunActivity(input) { + status = runStatus(input.runId, "failed", []); + }, + async advanceRunActivity(input) { + advanceCalls += 1; + status = runStatus(input.runId, "completed", []); + return status; + }, + async signalApprovalActivity() { + throw new Error("approval signal should not be needed for this workflow path"); + }, + async pauseRunActivity(payload) { + status = runStatus(payload.runId, "paused", []); + }, + async resumeRunActivity(payload) { + status = runStatus(payload.runId, "executing", []); + }, + async abortRunActivity(payload) { + abortCalls += 1; + status = runStatus(payload.runId, "aborted", []); + }, + async getStatusActivity() { + return status; + }, + async isRunTerminalActivity() { + return ["completed", "failed", "aborted"].includes(status.run.state); + }, + async composeFinalReportActivity(runIdToReport) { + reportComposed = true; + status = runStatus(runIdToReport, status.run.state, [], { + finalReportPath: "/workspace/run/run.report.md", + }); + }, + }; + + const worker = await Worker.create({ + activities, + connection: testEnv.nativeConnection, + ...(testEnv.namespace === undefined ? {} : { namespace: testEnv.namespace }), + taskQueue, + workflowsPath: fileURLToPath(new URL("./workflow.ts", import.meta.url)), + }); + + await expect( + worker.runUntil(async () => { + const handle = await testEnv.client.workflow.start(runWorkflow, { + args: [ + { + runId, + requirementsMd: "Abort while a fake advancement is completing.", + repoPath: "/repo", + baseBranch: "main", + }, + ], + taskQueue, + workflowId, + }); + + await lockStarted.promise; + await handle.signal(abortSignal, { + runId, + reason: "user_requested_abort", + clientToken: "abort-token-1", + }); + releaseLock.resolve(undefined); + return handle.result(); + }), + ).resolves.toEqual({ runId }); + + expect(advanceCalls).toBe(0); + expect(abortCalls).toBe(1); + expect(reportComposed).toBe(true); + expect(status.run.state).toBe("aborted"); + } finally { + await testEnv.teardown(); + } + }, 120_000); + + it("applies abort before waiting for an interrupted advance to settle", async () => { + const testEnv = await TestWorkflowEnvironment.createTimeSkipping(); + try { + const runId = randomUUID(); + const taskQueue = `devflow-workflow-test-${runId}`; + const workflowId = `devflow-run:${runId}`; + const advanceStarted = deferred(); + const abortObserved = deferred(); + let status = runStatus(runId, "created", []); + let abortCalls = 0; + let reportComposed = false; + + const activities: DevflowActivities = { + async prepareRunActivity(input) { + const preparedRunId = input.runId ?? runId; + status = runStatus(preparedRunId, "created", []); + return { runId: preparedRunId }; + }, + async lockBindingsActivity(input) { + status = runStatus(input.runId ?? runId, "executing", []); + }, + async failRunActivity(input) { + status = runStatus(input.runId, "failed", []); + }, + async advanceRunActivity(input) { + advanceStarted.resolve(undefined); + await abortObserved.promise; + throw ApplicationFailure.create({ + message: "Run left active state before fake phase mutation", + type: "DevflowError", + nonRetryable: true, + details: [{ class: "human_required", code: "run_state_changed", runId: input.runId }], + }); + }, + async signalApprovalActivity() { + throw new Error("approval signal should not be needed for this workflow path"); + }, + async pauseRunActivity(payload) { + status = runStatus(payload.runId, "paused", []); + }, + async resumeRunActivity(payload) { + status = runStatus(payload.runId, "executing", []); + }, + async abortRunActivity(payload) { + abortCalls += 1; + status = runStatus(payload.runId, "aborted", []); + abortObserved.resolve(undefined); + }, + async getStatusActivity() { + return status; + }, + async isRunTerminalActivity() { + return ["completed", "failed", "aborted"].includes(status.run.state); + }, + async composeFinalReportActivity(runIdToReport) { + reportComposed = true; + status = runStatus(runIdToReport, status.run.state, [], { + finalReportPath: "/workspace/run/run.report.md", + }); + }, + }; + + const worker = await Worker.create({ + activities, + connection: testEnv.nativeConnection, + ...(testEnv.namespace === undefined ? {} : { namespace: testEnv.namespace }), + taskQueue, + workflowsPath: fileURLToPath(new URL("./workflow.ts", import.meta.url)), + }); + + await expect( + worker.runUntil(async () => { + const handle = await testEnv.client.workflow.start(runWorkflow, { + args: [ + { + runId, + requirementsMd: "Abort while advancement is already in flight.", + repoPath: "/repo", + baseBranch: "main", + }, + ], + taskQueue, + workflowId, + }); + + await advanceStarted.promise; + await handle.signal(abortSignal, { + runId, + reason: "user_requested_abort", + clientToken: "abort-token-1", + }); + return handle.result(); + }), + ).resolves.toEqual({ runId }); + + expect(abortCalls).toBe(1); + expect(reportComposed).toBe(true); + expect(status.run.state).toBe("aborted"); + } finally { + await testEnv.teardown(); + } + }, 120_000); + + it("preserves non-retryable DevflowError activity failures through TemporalRunEngine", async () => { + const testEnv = await TestWorkflowEnvironment.createLocal(); + try { + const runId = randomUUID(); + const taskQueue = `devflow-workflow-test-${runId}`; + let status = runStatus(runId, "created", []); + let lockAttempts = 0; + + const activities: DevflowActivities = { + async prepareRunActivity(input) { + const preparedRunId = input.runId ?? runId; + status = runStatus(preparedRunId, "created", []); + return { runId: preparedRunId }; + }, + async lockBindingsActivity(input) { + lockAttempts += 1; + status = runStatus(input.runId ?? runId, "executing", []); + throw ApplicationFailure.create({ + message: "No eligible persona", + type: "DevflowError", + nonRetryable: true, + details: [ + { + class: "human_required", + code: "no_eligible_persona", + runId: input.runId ?? runId, + }, + ], + }); + }, + async failRunActivity(input) { + status = runStatus(input.runId, "failed", []); + }, + async advanceRunActivity(input) { + status = runStatus(input.runId, "completed", []); + return status; + }, + async signalApprovalActivity() { + throw new Error("approval signal should not be needed for this workflow path"); + }, + async pauseRunActivity(payload) { + status = runStatus(payload.runId, "paused", []); + }, + async resumeRunActivity(payload) { + status = runStatus(payload.runId, "executing", []); + }, + async abortRunActivity(payload) { + status = runStatus(payload.runId, "aborted", []); + }, + async getStatusActivity() { + return status; + }, + async isRunTerminalActivity() { + return ["completed", "failed", "aborted"].includes(status.run.state); + }, + async composeFinalReportActivity(runIdToReport) { + status = runStatus(runIdToReport, status.run.state, [], { + finalReportPath: "/workspace/run/run.report.md", + }); + }, + }; + + const worker = await Worker.create({ + activities, + connection: testEnv.nativeConnection, + ...(testEnv.namespace === undefined ? {} : { namespace: testEnv.namespace }), + taskQueue, + workflowsPath: fileURLToPath(new URL("./workflow.ts", import.meta.url)), + }); + const engine = new TemporalRunEngine({ + client: testEnv.client.workflow, + startRunPollMs: 1, + statusReader: { getStatus: async () => status }, + taskQueue, + }); + + await expect( + worker.runUntil(() => + engine.startRun({ + runId, + requirementsMd: "Propagate lock binding failure through Temporal.", + repoPath: "/repo", + baseBranch: "main", + }), + ), + ).rejects.toMatchObject({ code: "no_eligible_persona" }); + expect(lockAttempts).toBe(1); + expect(status.run.state).toBe("failed"); + } finally { + await testEnv.teardown(); + } + }, 120_000); +}); + +function runStatus( + runId: string, + state: string, + approvals: RunStatus["approvals"], + overrides: Partial = {}, +): RunStatus { + return { + run: { + id: runId, + state, + repoPath: "/repo", + baseBranch: "main", + worktreeRoot: "/workspace/run/main", + currentPhaseId: null, + finalReportPath: null, + startedAt: null, + endedAt: null, + ...overrides, + }, + approvals, + eventsTail: [], + phases: [], + }; +} + +interface Deferred { + promise: Promise; + resolve(value: T | PromiseLike): void; + reject(reason?: unknown): void; +} + +function deferred(): Deferred { + let resolve!: Deferred["resolve"]; + let reject!: Deferred["reject"]; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + + return { promise, resolve, reject }; +} diff --git a/packages/workflows/src/workflow.test.ts b/packages/workflows/src/workflow.test.ts new file mode 100644 index 0000000..3cef1ea --- /dev/null +++ b/packages/workflows/src/workflow.test.ts @@ -0,0 +1,59 @@ +import { ActivityCancellationType } from "@temporalio/workflow"; +import { describe, expect, it } from "vitest"; + +import { + type QueuedSignal, + advanceRunActivityCancellationType, + handleQueuedSignal, + settleInterruptedAdvance, +} from "./workflow.js"; + +describe("runWorkflow signal handling", () => { + it("waits for advance activity cancellation completion before handling control signals", () => { + expect(advanceRunActivityCancellationType).toBe( + ActivityCancellationType.WAIT_CANCELLATION_COMPLETED, + ); + }); + + it("treats stale resume approval conflicts as idempotent no-op controls", async () => { + const calls: string[] = []; + const signal: QueuedSignal = { + type: "resume", + payload: { runId: "run-1", clientToken: "token-1" }, + }; + + await expect( + handleQueuedSignal(signal, { + async abortRunActivity() { + calls.push("abort"); + }, + async pauseRunActivity() { + calls.push("pause"); + }, + async resumeRunActivity() { + calls.push("resume"); + throw new Error("Approval decision conflicts with the current request state"); + }, + async signalApprovalActivity() { + calls.push("approve"); + }, + }), + ).resolves.toBeUndefined(); + + expect(calls).toEqual(["resume"]); + }); + + it("discards a successful advance result after a control signal wins the race", async () => { + await expect(settleInterruptedAdvance(Promise.resolve({ state: "completed" }))).resolves.toBe( + undefined, + ); + }); + + it("treats interrupted advance cancellation as a control signal handoff", async () => { + const cancellation = new Error("activity canceled"); + + await expect( + settleInterruptedAdvance(Promise.reject(cancellation), (error) => error === cancellation), + ).resolves.toBe(undefined); + }); +}); diff --git a/packages/workflows/src/workflow.ts b/packages/workflows/src/workflow.ts new file mode 100644 index 0000000..4bd6300 --- /dev/null +++ b/packages/workflows/src/workflow.ts @@ -0,0 +1,268 @@ +import { + ActivityCancellationType, + ActivityFailure, + ApplicationFailure, + CancellationScope, + condition, + defineSignal, + isCancellation, + proxyActivities, + rootCause, + setHandler, +} from "@temporalio/workflow"; + +import type { RunStartInput, RunStatus } from "@devflow/run-engine"; + +import type { DevflowActivities } from "./activities.js"; +import type { + AbortSignalPayload, + ApprovalSignalPayload, + RunSignalPayload, + RunWorkflowResult, +} from "./types.js"; + +export const approveSignal = defineSignal<[ApprovalSignalPayload]>("approve"); +export const pauseSignal = defineSignal<[RunSignalPayload]>("pause"); +export const resumeSignal = defineSignal<[RunSignalPayload]>("resume"); +export const abortSignal = defineSignal<[AbortSignalPayload]>("abort"); +export const unpauseSignal = defineSignal<[RunSignalPayload]>("unpause"); + +export type QueuedSignal = + | { type: "approve"; payload: ApprovalSignalPayload } + | { type: "pause"; payload: RunSignalPayload } + | { type: "resume"; payload: RunSignalPayload } + | { type: "abort"; payload: AbortSignalPayload } + | { type: "unpause"; payload: RunSignalPayload }; + +type ControlActivities = Pick< + DevflowActivities, + "abortRunActivity" | "pauseRunActivity" | "resumeRunActivity" | "signalApprovalActivity" +>; + +const defaultActivities = proxyActivities({ + startToCloseTimeout: "10 minutes", + retry: { + maximumAttempts: 3, + initialInterval: "1 second", + maximumInterval: "30 seconds", + }, +}); + +export const advanceRunActivityCancellationType = + ActivityCancellationType.WAIT_CANCELLATION_COMPLETED; + +const interruptibleActivities = proxyActivities>({ + startToCloseTimeout: "10 minutes", + heartbeatTimeout: "5 seconds", + cancellationType: advanceRunActivityCancellationType, + retry: { + maximumAttempts: 3, + initialInterval: "1 second", + maximumInterval: "30 seconds", + }, +}); + +const singleAttemptActivities = proxyActivities< + Pick +>({ + startToCloseTimeout: "1 minute", + retry: { maximumAttempts: 1 }, +}); + +export async function runWorkflow(input: RunStartInput): Promise { + const queue: QueuedSignal[] = []; + const enqueue = (signal: QueuedSignal) => { + queue.push(signal); + }; + + setHandler(approveSignal, (payload) => enqueue({ type: "approve", payload })); + setHandler(pauseSignal, (payload) => enqueue({ type: "pause", payload })); + setHandler(resumeSignal, (payload) => enqueue({ type: "resume", payload })); + setHandler(abortSignal, (payload) => enqueue({ type: "abort", payload })); + setHandler(unpauseSignal, (payload) => enqueue({ type: "unpause", payload })); + + const result = await defaultActivities.prepareRunActivity(input); + const runInput = { ...input, runId: result.runId }; + try { + await defaultActivities.lockBindingsActivity(runInput); + } catch (error) { + await defaultActivities.failRunActivity({ + runId: result.runId, + reason: "lock_bindings_failed", + }); + rethrowDevflowFailure(error); + } + let status: RunStatus | undefined; + try { + status = await advanceUntilBlockedOrSignal(result.runId, false, queue); + } catch (error) { + rethrowDevflowFailure(error); + } + if (status === undefined) { + if (queue.length > 0) { + await handleQueuedSignal(queue.shift()); + } + status = await defaultActivities.getStatusActivity(result.runId); + } + + while (!isTerminalRunState(status.run.state)) { + if (queue.length > 0) { + await handleQueuedSignal(queue.shift()); + status = await defaultActivities.getStatusActivity(result.runId); + continue; + } + + if (status.run.state === "executing" || status.run.state === "planning") { + let advanced: RunStatus | undefined; + try { + advanced = await advanceUntilBlockedOrSignal(result.runId, true, queue); + } catch (error) { + rethrowDevflowFailure(error); + } + if (advanced !== undefined) { + status = advanced; + continue; + } + if (queue.length > 0) { + await handleQueuedSignal(queue.shift()); + status = await defaultActivities.getStatusActivity(result.runId); + continue; + } + status = await defaultActivities.getStatusActivity(result.runId); + continue; + } + + await condition(() => queue.length > 0); + await handleQueuedSignal(queue.shift()); + status = await defaultActivities.getStatusActivity(result.runId); + } + + await singleAttemptActivities.composeFinalReportActivity(result.runId); + return result; +} + +export async function handleQueuedSignal( + signal: QueuedSignal | undefined, + activities: ControlActivities = defaultActivities, +): Promise { + if (signal === undefined) { + return; + } + + if (signal.type === "approve") { + await ignoreControlConflict(activities.signalApprovalActivity(signal.payload)); + } else if (signal.type === "pause") { + await ignoreControlConflict(activities.pauseRunActivity(signal.payload)); + } else if (signal.type === "resume" || signal.type === "unpause") { + await ignoreControlConflict(activities.resumeRunActivity(signal.payload)); + } else { + await ignoreControlConflict(activities.abortRunActivity(signal.payload)); + } +} + +async function ignoreControlConflict(operation: Promise): Promise { + try { + await operation; + } catch (error) { + if (rootCause(error) === "Approval decision conflicts with the current request state") { + return; + } + rethrowDevflowFailure(error); + } +} + +async function advanceUntilBlockedOrSignal( + runId: string, + resumeActivePhase: boolean, + queue: QueuedSignal[], +): Promise { + const scope = new CancellationScope({ cancellable: true }); + const input = resumeActivePhase ? { runId, resumeActivePhase: true } : { runId }; + const activityPromise = scope.run(() => interruptibleActivities.advanceRunActivity(input)); + const signalPromise = condition(() => queue.length > 0).then(() => undefined); + const result = await Promise.race([activityPromise, signalPromise]); + + if (result !== undefined) { + return result; + } + + scope.cancel(); + const interruptingSignal = queue[0]; + if (interruptingSignal?.type === "abort" || interruptingSignal?.type === "pause") { + queue.shift(); + await handleQueuedSignal(interruptingSignal); + await settleInterruptedAdvance(activityPromise, isCancellation, { + ignoreRunStateChanged: true, + }); + return defaultActivities.getStatusActivity(runId); + } + + return settleInterruptedAdvance(activityPromise); +} + +export async function settleInterruptedAdvance( + activityPromise: Promise, + isCanceled: (error: unknown) => boolean = isCancellation, + options: { ignoreRunStateChanged?: boolean } = {}, +): Promise { + try { + await activityPromise; + return undefined; + } catch (error) { + if (isCanceled(error)) { + return undefined; + } + if ( + options.ignoreRunStateChanged === true && + isDevflowFailureCode(error, "run_state_changed") + ) { + return undefined; + } + throw error; + } +} + +function isTerminalRunState(state: string): boolean { + return state === "completed" || state === "failed" || state === "aborted"; +} + +function rethrowDevflowFailure(error: unknown): never { + const failure = devflowApplicationFailure(error); + if (failure !== undefined) { + throw ApplicationFailure.create({ + message: failure.message, + type: failure.type ?? "DevflowError", + nonRetryable: true, + details: failure.details ?? [], + }); + } + throw error; +} + +function devflowApplicationFailure(error: unknown): ApplicationFailure | undefined { + let current = error; + const seen = new Set(); + while (current !== null && typeof current === "object" && !seen.has(current)) { + seen.add(current); + if (current instanceof ApplicationFailure && current.type === "DevflowError") { + return current; + } + if (current instanceof ActivityFailure) { + current = current.cause; + continue; + } + current = (current as { cause?: unknown }).cause; + } + return undefined; +} + +function isDevflowFailureCode(error: unknown, code: string): boolean { + const failure = devflowApplicationFailure(error); + const details = (failure as { details?: unknown[] } | undefined)?.details; + return ( + details?.some( + (detail) => + typeof detail === "object" && detail !== null && "code" in detail && detail.code === code, + ) ?? false + ); +} diff --git a/packages/workflows/tsconfig.build.json b/packages/workflows/tsconfig.build.json new file mode 100644 index 0000000..28a70ef --- /dev/null +++ b/packages/workflows/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "emitDeclarationOnly": true, + "noEmit": false + }, + "references": [], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/workflows/tsconfig.json b/packages/workflows/tsconfig.json new file mode 100644 index 0000000..d9a4229 --- /dev/null +++ b/packages/workflows/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node", "vitest"] + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../core" }, + { "path": "../db" }, + { "path": "../run-engine" }, + { "path": "../session" } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4794918..f21232a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,7 @@ importers: version: 8.20.0 '@vitest/coverage-v8': specifier: 2.1.8 - version: 2.1.8(vitest@2.1.8(@types/node@22.10.2)) + version: 2.1.8(vitest@2.1.8(@types/node@22.10.2)(terser@5.47.1)) drizzle-kit: specifier: 0.31.10 version: 0.31.10 @@ -44,7 +44,7 @@ importers: version: 2.1.6 tsup: specifier: 8.3.5 - version: 8.3.5(postcss@8.5.14)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1) + version: 8.3.5(@swc/core@1.15.33)(postcss@8.5.14)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1) tsx: specifier: 4.19.2 version: 4.19.2 @@ -53,10 +53,10 @@ importers: version: 5.6.3 vite: specifier: 6.0.3 - version: 6.0.3(@types/node@22.10.2)(tsx@4.19.2)(yaml@2.6.1) + version: 6.0.3(@types/node@22.10.2)(terser@5.47.1)(tsx@4.19.2)(yaml@2.6.1) vitest: specifier: 2.1.8 - version: 2.1.8(@types/node@22.10.2) + version: 2.1.8(@types/node@22.10.2)(terser@5.47.1) apps/api: dependencies: @@ -72,6 +72,12 @@ importers: '@devflow/session': specifier: workspace:* version: link:../../packages/session + '@devflow/workflows': + specifier: workspace:* + version: link:../../packages/workflows + '@temporalio/client': + specifier: ^1.17.1 + version: 1.17.1 apps/cli: dependencies: @@ -88,6 +94,27 @@ importers: specifier: 3.24.1 version: 3.24.1 + apps/worker: + dependencies: + '@devflow/core': + specifier: workspace:* + version: link:../../packages/core + '@devflow/db': + specifier: workspace:* + version: link:../../packages/db + '@devflow/session': + specifier: workspace:* + version: link:../../packages/session + '@devflow/workflows': + specifier: workspace:* + version: link:../../packages/workflows + '@temporalio/client': + specifier: ^1.17.1 + version: 1.17.1 + '@temporalio/worker': + specifier: ^1.17.1 + version: 1.17.1(esbuild@0.24.2)(postcss@8.5.14)(tslib@2.8.1) + packages/core: dependencies: ajv: @@ -139,6 +166,37 @@ importers: specifier: workspace:* version: link:../db + packages/workflows: + dependencies: + '@devflow/core': + specifier: workspace:* + version: link:../core + '@devflow/db': + specifier: workspace:* + version: link:../db + '@devflow/run-engine': + specifier: workspace:* + version: link:../run-engine + '@devflow/session': + specifier: workspace:* + version: link:../session + '@temporalio/activity': + specifier: ^1.17.1 + version: 1.17.1 + '@temporalio/client': + specifier: ^1.17.1 + version: 1.17.1 + '@temporalio/worker': + specifier: ^1.17.1 + version: 1.17.1(esbuild@0.24.2)(postcss@8.5.14)(tslib@2.8.1) + '@temporalio/workflow': + specifier: ^1.17.1 + version: 1.17.1 + devDependencies: + '@temporalio/testing': + specifier: ^1.17.1 + version: 1.17.1(esbuild@0.24.2)(postcss@8.5.14)(tslib@2.8.1) + packages: '@ampproject/remapping@2.3.0': @@ -1105,6 +1163,15 @@ packages: cpu: [x64] os: [win32] + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1120,16 +1187,172 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@jsonjoy.com/base64@1.1.2': + resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/base64@17.67.0': + resolution: {integrity: sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/buffers@1.2.1': + resolution: {integrity: sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/buffers@17.67.0': + resolution: {integrity: sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/codegen@1.0.0': + resolution: {integrity: sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/codegen@17.67.0': + resolution: {integrity: sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-core@4.57.2': + resolution: {integrity: sha512-SVjwklkpIV5wrynpYtuYnfYH1QF4/nDuLBX7VXdb+3miglcAgBVZb/5y0cOsehRV/9Vb+3UqhkMq3/NR3ztdkQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-fsa@4.57.2': + resolution: {integrity: sha512-fhO8+iR2I+OCw668ISDJdn1aArc9zx033sWejIyzQ8RBeXa9bDSaUeA3ix0poYOfrj1KdOzytmYNv2/uLDfV6g==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-builtins@4.57.2': + resolution: {integrity: sha512-xhiegylRmhw43Ki2HO1ZBL7DQ5ja/qpRsL29VtQ2xuUHiuDGbgf2uD4p9Qd8hJI5P6RCtGYD50IXHXVq/Ocjcg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-to-fsa@4.57.2': + resolution: {integrity: sha512-18LmWTSONhoAPW+IWRuf8w/+zRolPFGPeGwMxlAhhfY11EKzX+5XHDBPAw67dBF5dxDErHJbl40U+3IXSDRXSQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-utils@4.57.2': + resolution: {integrity: sha512-rsPSJgekz43IlNbLyAM/Ab+ouYLWGp5DDBfYBNNEqDaSpsbXfthBn29Q4muFA9L0F+Z3mKo+CWlgSCXrf+mOyQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node@4.57.2': + resolution: {integrity: sha512-nX2AdL6cOFwLdju9G4/nbRnYevmCJbh7N7hvR3gGm97Cs60uEjyd0rpR+YBS7cTg175zzl22pGKXR5USaQMvKg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-print@4.57.2': + resolution: {integrity: sha512-wK9NSow48i4DbDl9F1CQE5TqnyZOJ04elU3WFG5aJ76p+YxO/ulyBBQvKsessPxdo381Bc2pcEoyPujMOhcRqQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-snapshot@4.57.2': + resolution: {integrity: sha512-GdduDZuoP5V/QCgJkx9+BZ6SC0EZ/smXAdTS7PfMqgMTGXLlt/bH/FqMYaqB9JmLf05sJPtO0XRbAwwkEEPbVw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@1.21.0': + resolution: {integrity: sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@17.67.0': + resolution: {integrity: sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pointer@1.0.2': + resolution: {integrity: sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pointer@17.67.0': + resolution: {integrity: sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@1.9.0': + resolution: {integrity: sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@17.67.0': + resolution: {integrity: sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.1': + resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@rollup/rollup-android-arm-eabi@4.60.3': resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} cpu: [arm] @@ -1255,12 +1478,144 @@ packages: cpu: [x64] os: [win32] + '@swc/core-darwin-arm64@1.15.33': + resolution: {integrity: sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.33': + resolution: {integrity: sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.33': + resolution: {integrity: sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.33': + resolution: {integrity: sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-arm64-musl@1.15.33': + resolution: {integrity: sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-ppc64-gnu@1.15.33': + resolution: {integrity: sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + + '@swc/core-linux-s390x-gnu@1.15.33': + resolution: {integrity: sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + + '@swc/core-linux-x64-gnu@1.15.33': + resolution: {integrity: sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-linux-x64-musl@1.15.33': + resolution: {integrity: sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-win32-arm64-msvc@1.15.33': + resolution: {integrity: sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.33': + resolution: {integrity: sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.33': + resolution: {integrity: sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.33': + resolution: {integrity: sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.26': + resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==} + + '@temporalio/activity@1.17.1': + resolution: {integrity: sha512-DfcWhqAHtRtkEcBDx1zKJKXljljK4mmaeoemYvNk6mZizTnt+HPYEnMXGulcSEYILiO3E648JJneScMM7VrUSw==} + engines: {node: '>= 20.0.0'} + + '@temporalio/client@1.17.1': + resolution: {integrity: sha512-xDA1Vt+b8VYyIWaZbUdCL0uEeGVu186ARXaIJGK33fx45GR18ZQ5DmY2Ipdp6Pb/TRU8/SPaRhFy0AcMkxmYBw==} + engines: {node: '>= 20.0.0'} + + '@temporalio/common@1.17.1': + resolution: {integrity: sha512-lzDdDeXKO0EmG65zdimzx+v0mF123uoT6MHP7UugobnqEuqeUU+d8i4sdvGqBSYivr5ziF0xI8ceX9DjVkyj7w==} + engines: {node: '>= 20.0.0'} + + '@temporalio/core-bridge@1.17.1': + resolution: {integrity: sha512-ig4nhiSkgE3enI7faxWWPm37NbmF88TWcJZyrL9JACg3punVWZ+VcOV9BG0RLtn5J9wb2KCdHLBxjhHdH6yEaA==} + engines: {node: '>= 20.0.0'} + + '@temporalio/nexus@1.17.1': + resolution: {integrity: sha512-e7nXkWAcWkGGW9mZVKgTBtltDhV71oZo6fDq+PifljqNGmQ3zq8oM06JOPFkx8fPw3TuH+JsGT3gocmHWzd1Ng==} + engines: {node: '>= 20.0.0'} + + '@temporalio/proto@1.17.1': + resolution: {integrity: sha512-bNrTijDjbQaC6Ae/JThTAeOAqQOM5e40H0XE1HTuOaS0WyeCKRpiQ4t+5F3D2KlBOiZq7sIVm+9bFrHlIOHjoA==} + engines: {node: '>= 20.0.0'} + + '@temporalio/testing@1.17.1': + resolution: {integrity: sha512-9d5+k3GlwE7mZz8Qu5WMUyIgHo6cUL1s7AJFW9o/i7DRb5hiSALSwz5Lq9u4VBXZoycXZuFGUGX+wG/1zmj+Ig==} + engines: {node: '>= 20.0.0'} + + '@temporalio/worker@1.17.1': + resolution: {integrity: sha512-KyD+Cx2vtL+AsEeG5PLjfFwoZmy/Ih+rSF8QBCDwO7yb2mO+qrZUYBBIDrfQiw8XaUwILJEJguilTDNzwAHvzw==} + engines: {node: '>= 20.0.0'} + + '@temporalio/workflow@1.17.1': + resolution: {integrity: sha512-FxMHNbeZsx+xRk2q/xuHVbt0tNIE9qELvUWD7L3kBwl5/xcm4xAROnUEamLDVnkfcu/CAJdd9xMIO1lJUdc75g==} + engines: {node: '>= 20.0.0'} + + '@types/eslint-scope@3.7.7': + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@22.10.2': resolution: {integrity: sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==} @@ -1308,6 +1663,85 @@ packages: '@vitest/utils@2.1.8': resolution: {integrity: sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==} + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + acorn-import-phases@1.0.4: + resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} + engines: {node: '>=10.13.0'} + peerDependencies: + acorn: ^8.14.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} @@ -1341,6 +1775,11 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.10.29: + resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} + engines: {node: '>=6.0.0'} + hasBin: true + brace-expansion@2.1.0: resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} @@ -1348,6 +1787,11 @@ packages: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1361,6 +1805,9 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -1373,6 +1820,14 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1384,6 +1839,9 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1512,15 +1970,25 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + electron-to-chromium@1.5.353: + resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + enhanced-resolve@5.21.2: + resolution: {integrity: sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==} + engines: {node: '>=10.13.0'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} @@ -1551,9 +2019,37 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1577,26 +2073,57 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fs-monkey@1.1.0: + resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + glob-to-regex.js@1.2.0: + resolution: {integrity: sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + heap-js@2.7.1: + resolution: {integrity: sha512-EQfezRg0NCZGNlhlDR3Evrw1FVL2G3LhU7EgPoxufQKruNBSYA8MiRPHeWbU+36o+Fhel0wMwM+sLEiBAlNLJA==} + engines: {node: '>=10.0.0'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + hyperdyperid@1.2.0: + resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} + engines: {node: '>=10.18'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -1623,6 +2150,10 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -1695,9 +2226,19 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + loader-runner@4.3.2: + resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} + engines: {node: '>=6.11.5'} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -1714,6 +2255,18 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + memfs@4.57.2: + resolution: {integrity: sha512-2nWzSsJzrukurSDna4Z0WywuScK4Id3tSKejgu74u8KCdW4uNrseKRSIDg75C6Yw5ZRqBe0F0EtMNlTbUq8bAQ==} + peerDependencies: + tslib: '2' + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1729,6 +2282,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + ms@3.0.0-canary.1: + resolution: {integrity: sha512-kh8ARjh8rMN7Du2igDRO9QJnqCb2xYTJxyQYK7vJJS4TvLLmsbyhiKpSW+t+y26gyOyMd0riphX0GeWKU3ky5g==} + engines: {node: '>=12.13'} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -1737,6 +2294,16 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + nexus-rpc@0.0.2: + resolution: {integrity: sha512-IWjIExdVYlmwXuzHdY/Q3lXCv1gbqoAXPazQhy2w4Xgtgha3H0OOujEESVPQcFUFMWm+pAk2gKnb57g8S41JZg==} + engines: {node: '>= 20.0.0'} + + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1842,6 +2409,18 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + proto3-json-serializer@2.0.2: + resolution: {integrity: sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==} + engines: {node: '>=14.0.0'} + + protobufjs@7.5.5: + resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==} + engines: {node: '>=12.0.0'} + + protobufjs@7.5.7: + resolution: {integrity: sha512-NGnrxS/nLKUo5nkbVQxlC71sB4hdfImdYIbFeSCidxtwATx0AHRPcANSLd0q5Bb2BkoSWo2iisQhGg5/r+ihbA==} + engines: {node: '>=12.0.0'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1850,6 +2429,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1866,6 +2449,16 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + semver@7.8.0: resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} engines: {node: '>=10'} @@ -1890,6 +2483,12 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-loader@4.0.2: + resolution: {integrity: sha512-oYwAqCuL0OZhBoSgmdrLa7mv9MjommVMiQIWgcztf+eS4+8BfcUee6nenFnDhKOhzAVnk5gpZdfnz1iiBv+5sg==} + engines: {node: '>= 14.15.0'} + peerDependencies: + webpack: ^5.72.1 + source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -1897,6 +2496,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} @@ -1937,6 +2540,68 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + swc-loader@0.2.7: + resolution: {integrity: sha512-nwYWw3Fh9ame3Rtm7StS9SBLpHRRnYcK7bnpF3UKZmesAK0gw2/ADvlURFAINmPvKtDLzp+GBiP9yLoEjg6S9w==} + peerDependencies: + '@swc/core': ^1.2.147 + webpack: '>=2' + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + terser-webpack-plugin@5.6.0: + resolution: {integrity: sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@minify-html/node': '*' + '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' + esbuild: '*' + html-minifier-terser: '*' + lightningcss: '*' + postcss: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@minify-html/node': + optional: true + '@swc/core': + optional: true + '@swc/css': + optional: true + '@swc/html': + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true + esbuild: + optional: true + html-minifier-terser: + optional: true + lightningcss: + optional: true + postcss: + optional: true + uglify-js: + optional: true + + terser@5.47.1: + resolution: {integrity: sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==} + engines: {node: '>=10'} + hasBin: true + test-exclude@7.0.2: resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} engines: {node: '>=18'} @@ -1948,6 +2613,12 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thingies@2.6.0: + resolution: {integrity: sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==} + engines: {node: '>=10.18'} + peerDependencies: + tslib: ^2 + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1973,6 +2644,12 @@ packages: tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + tree-dump@1.1.0: + resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -1980,6 +2657,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.3.5: resolution: {integrity: sha512-Tunf6r6m6tnZsG9GYWndg0z8dEV7fD733VBFzFJ5Vcm1FtlXB8xBD/rtrBi2a3YKEV7hHtxiZtW5EAVADoe1pA==} engines: {node: '>=18'} @@ -2017,6 +2697,19 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + unionfs@4.6.0: + resolution: {integrity: sha512-fJAy3gTHjFi5S3TP5EGdjs/OUMFFvI/ady3T8qVuZfkv8Qi8prV/Q8BuFEgODJslhZTT2z2qdD2lGdee9qjEnA==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} + hasBin: true + vite-node@2.1.8: resolution: {integrity: sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -2118,9 +2811,27 @@ packages: jsdom: optional: true + watchpack@2.5.1: + resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} + engines: {node: '>=10.13.0'} + webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + webpack-sources@3.4.1: + resolution: {integrity: sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==} + engines: {node: '>=10.13.0'} + + webpack@5.106.2: + resolution: {integrity: sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} @@ -2146,11 +2857,23 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yaml@2.6.1: resolution: {integrity: sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==} engines: {node: '>= 14'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + zod@3.24.1: resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} @@ -2661,6 +3384,18 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.7 + yargs: 17.7.2 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -2679,6 +3414,11 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.31': @@ -2686,9 +3426,161 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + + '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/base64@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/buffers@1.2.1(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/buffers@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/codegen@1.0.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/codegen@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/fs-core@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + thingies: 2.6.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-fsa@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-core': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + thingies: 2.6.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-builtins@4.57.2(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-to-fsa@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-fsa': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-utils@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-core': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-print': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-snapshot': 4.57.2(tslib@2.8.1) + glob-to-regex.js: 1.2.0(tslib@2.8.1) + thingies: 2.6.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-print@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-snapshot@4.57.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/json-pack': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pack@1.21.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/base64': 1.1.2(tslib@2.8.1) + '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + '@jsonjoy.com/json-pointer': 1.0.2(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + hyperdyperid: 1.2.0 + thingies: 2.6.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pack@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/base64': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/json-pointer': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + hyperdyperid: 1.2.0 + thingies: 2.6.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pointer@1.0.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pointer@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/util@1.9.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/util@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + '@pkgjs/parseargs@0.11.0': optional: true + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.1 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.1': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + '@rollup/rollup-android-arm-eabi@4.60.3': optional: true @@ -2764,10 +3656,195 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.3': optional: true + '@swc/core-darwin-arm64@1.15.33': + optional: true + + '@swc/core-darwin-x64@1.15.33': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.33': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.33': + optional: true + + '@swc/core-linux-arm64-musl@1.15.33': + optional: true + + '@swc/core-linux-ppc64-gnu@1.15.33': + optional: true + + '@swc/core-linux-s390x-gnu@1.15.33': + optional: true + + '@swc/core-linux-x64-gnu@1.15.33': + optional: true + + '@swc/core-linux-x64-musl@1.15.33': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.33': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.33': + optional: true + + '@swc/core-win32-x64-msvc@1.15.33': + optional: true + + '@swc/core@1.15.33': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.26 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.33 + '@swc/core-darwin-x64': 1.15.33 + '@swc/core-linux-arm-gnueabihf': 1.15.33 + '@swc/core-linux-arm64-gnu': 1.15.33 + '@swc/core-linux-arm64-musl': 1.15.33 + '@swc/core-linux-ppc64-gnu': 1.15.33 + '@swc/core-linux-s390x-gnu': 1.15.33 + '@swc/core-linux-x64-gnu': 1.15.33 + '@swc/core-linux-x64-musl': 1.15.33 + '@swc/core-win32-arm64-msvc': 1.15.33 + '@swc/core-win32-ia32-msvc': 1.15.33 + '@swc/core-win32-x64-msvc': 1.15.33 + + '@swc/counter@0.1.3': {} + + '@swc/types@0.1.26': + dependencies: + '@swc/counter': 0.1.3 + + '@temporalio/activity@1.17.1': + dependencies: + '@temporalio/client': 1.17.1 + '@temporalio/common': 1.17.1 + abort-controller: 3.0.0 + + '@temporalio/client@1.17.1': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@temporalio/common': 1.17.1 + '@temporalio/proto': 1.17.1 + abort-controller: 3.0.0 + long: 5.3.2 + uuid: 11.1.1 + + '@temporalio/common@1.17.1': + dependencies: + '@temporalio/proto': 1.17.1 + long: 5.3.2 + ms: 3.0.0-canary.1 + nexus-rpc: 0.0.2 + proto3-json-serializer: 2.0.2 + + '@temporalio/core-bridge@1.17.1': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@temporalio/common': 1.17.1 + + '@temporalio/nexus@1.17.1': + dependencies: + '@temporalio/client': 1.17.1 + '@temporalio/common': 1.17.1 + '@temporalio/proto': 1.17.1 + long: 5.3.2 + nexus-rpc: 0.0.2 + + '@temporalio/proto@1.17.1': + dependencies: + long: 5.3.2 + protobufjs: 7.5.5 + + '@temporalio/testing@1.17.1(esbuild@0.24.2)(postcss@8.5.14)(tslib@2.8.1)': + dependencies: + '@temporalio/activity': 1.17.1 + '@temporalio/client': 1.17.1 + '@temporalio/common': 1.17.1 + '@temporalio/core-bridge': 1.17.1 + '@temporalio/proto': 1.17.1 + '@temporalio/worker': 1.17.1(esbuild@0.24.2)(postcss@8.5.14)(tslib@2.8.1) + '@temporalio/workflow': 1.17.1 + abort-controller: 3.0.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/helpers' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - tslib + - uglify-js + - webpack-cli + + '@temporalio/worker@1.17.1(esbuild@0.24.2)(postcss@8.5.14)(tslib@2.8.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@swc/core': 1.15.33 + '@temporalio/activity': 1.17.1 + '@temporalio/client': 1.17.1 + '@temporalio/common': 1.17.1 + '@temporalio/core-bridge': 1.17.1 + '@temporalio/nexus': 1.17.1 + '@temporalio/proto': 1.17.1 + '@temporalio/workflow': 1.17.1 + abort-controller: 3.0.0 + heap-js: 2.7.1 + memfs: 4.57.2(tslib@2.8.1) + nexus-rpc: 0.0.2 + proto3-json-serializer: 2.0.2 + protobufjs: 7.5.7 + rxjs: 7.8.2 + source-map: 0.7.6 + source-map-loader: 4.0.2(webpack@5.106.2(@swc/core@1.15.33)(esbuild@0.24.2)(postcss@8.5.14)) + supports-color: 8.1.1 + swc-loader: 0.2.7(@swc/core@1.15.33)(webpack@5.106.2(@swc/core@1.15.33)(esbuild@0.24.2)(postcss@8.5.14)) + unionfs: 4.6.0 + webpack: 5.106.2(@swc/core@1.15.33)(esbuild@0.24.2)(postcss@8.5.14) + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/helpers' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - tslib + - uglify-js + - webpack-cli + + '@temporalio/workflow@1.17.1': + dependencies: + '@temporalio/common': 1.17.1 + '@temporalio/proto': 1.17.1 + nexus-rpc: 0.0.2 + + '@types/eslint-scope@3.7.7': + dependencies: + '@types/eslint': 9.6.1 + '@types/estree': 1.0.9 + + '@types/eslint@9.6.1': + dependencies: + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + '@types/estree@1.0.8': {} '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} + '@types/node@22.10.2': dependencies: undici-types: 6.20.0 @@ -2778,7 +3855,7 @@ snapshots: pg-protocol: 1.13.0 pg-types: 2.2.0 - '@vitest/coverage-v8@2.1.8(vitest@2.1.8(@types/node@22.10.2))': + '@vitest/coverage-v8@2.1.8(vitest@2.1.8(@types/node@22.10.2)(terser@5.47.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -2792,7 +3869,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 1.2.0 - vitest: 2.1.8(@types/node@22.10.2) + vitest: 2.1.8(@types/node@22.10.2)(terser@5.47.1) transitivePeerDependencies: - supports-color @@ -2803,13 +3880,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.8(vite@5.4.21(@types/node@22.10.2))': + '@vitest/mocker@2.1.8(vite@5.4.21(@types/node@22.10.2)(terser@5.47.1))': dependencies: '@vitest/spy': 2.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21(@types/node@22.10.2) + vite: 5.4.21(@types/node@22.10.2)(terser@5.47.1) '@vitest/pretty-format@2.1.8': dependencies: @@ -2840,6 +3917,105 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + acorn-import-phases@1.0.4(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv-formats@2.1.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv-keywords@5.1.0(ajv@8.17.1): + dependencies: + ajv: 8.17.1 + fast-deep-equal: 3.1.3 + ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 @@ -2865,6 +4041,8 @@ snapshots: balanced-match@4.0.4: {} + baseline-browser-mapping@2.10.29: {} + brace-expansion@2.1.0: dependencies: balanced-match: 1.0.2 @@ -2873,6 +4051,14 @@ snapshots: dependencies: balanced-match: 4.0.4 + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.29 + caniuse-lite: 1.0.30001792 + electron-to-chromium: 1.5.353 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-from@1.1.2: {} bundle-require@5.1.0(esbuild@0.24.2): @@ -2882,6 +4068,8 @@ snapshots: cac@6.7.14: {} + caniuse-lite@1.0.30001792: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -2896,6 +4084,14 @@ snapshots: dependencies: readdirp: 4.1.2 + chrome-trace-event@1.0.4: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2904,6 +4100,8 @@ snapshots: commander@12.1.0: {} + commander@2.20.3: {} + commander@4.1.1: {} consola@3.4.2: {} @@ -2936,12 +4134,21 @@ snapshots: eastasianwidth@0.2.0: {} + electron-to-chromium@1.5.353: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} + enhanced-resolve@5.21.2: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} + esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 @@ -3106,10 +4313,29 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 + event-target-shim@5.0.1: {} + + events@3.3.0: {} + expect-type@1.3.0: {} fast-deep-equal@3.1.3: {} @@ -3125,13 +4351,23 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fs-monkey@1.1.0: {} + fsevents@2.3.3: optional: true + get-caller-file@2.0.5: {} + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 + glob-to-regex.js@1.2.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + + glob-to-regexp@0.4.1: {} + glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -3141,10 +4377,20 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + graceful-fs@4.2.11: {} + has-flag@4.0.0: {} + heap-js@2.7.1: {} + html-escaper@2.0.2: {} + hyperdyperid@1.2.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + is-fullwidth-code-point@3.0.0: {} isexe@2.0.0: {} @@ -3176,6 +4422,12 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jest-worker@27.5.1: + dependencies: + '@types/node': 22.10.2 + merge-stream: 2.0.0 + supports-color: 8.1.1 + joycon@3.1.1: {} json-schema-traverse@1.0.0: {} @@ -3229,8 +4481,14 @@ snapshots: load-tsconfig@0.2.5: {} + loader-runner@4.3.2: {} + + lodash.camelcase@4.3.0: {} + lodash.sortby@4.7.0: {} + long@5.3.2: {} + loupe@3.2.1: {} lru-cache@10.4.3: {} @@ -3249,6 +4507,27 @@ snapshots: dependencies: semver: 7.8.0 + memfs@4.57.2(tslib@2.8.1): + dependencies: + '@jsonjoy.com/fs-core': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-fsa': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-to-fsa': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-print': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/fs-snapshot': 4.57.2(tslib@2.8.1) + '@jsonjoy.com/json-pack': 1.21.0(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + glob-to-regex.js: 1.2.0(tslib@2.8.1) + thingies: 2.6.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + merge-stream@2.0.0: {} + + mime-db@1.54.0: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -3261,6 +4540,8 @@ snapshots: ms@2.1.3: {} + ms@3.0.0-canary.1: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -3269,6 +4550,12 @@ snapshots: nanoid@3.3.12: {} + neo-async@2.6.2: {} + + nexus-rpc@0.0.2: {} + + node-releases@2.0.38: {} + object-assign@4.1.1: {} package-json-from-dist@1.0.1: {} @@ -3349,10 +4636,46 @@ snapshots: dependencies: xtend: 4.0.2 + proto3-json-serializer@2.0.2: + dependencies: + protobufjs: 7.5.7 + + protobufjs@7.5.5: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.1 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 22.10.2 + long: 5.3.2 + + protobufjs@7.5.7: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.1 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 22.10.2 + long: 5.3.2 + punycode@2.3.1: {} readdirp@4.1.2: {} + require-directory@2.1.1: {} + require-from-string@2.0.2: {} resolve-from@5.0.0: {} @@ -3390,6 +4713,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.3 fsevents: 2.3.3 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safer-buffer@2.1.2: {} + + schema-utils@4.3.3: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.17.1 + ajv-formats: 2.1.1(ajv@8.17.1) + ajv-keywords: 5.1.0(ajv@8.17.1) + semver@7.8.0: {} shebang-command@2.0.0: @@ -3404,6 +4740,12 @@ snapshots: source-map-js@1.2.1: {} + source-map-loader@4.0.2(webpack@5.106.2(@swc/core@1.15.33)(esbuild@0.24.2)(postcss@8.5.14)): + dependencies: + iconv-lite: 0.6.3 + source-map-js: 1.2.1 + webpack: 5.106.2(@swc/core@1.15.33)(esbuild@0.24.2)(postcss@8.5.14) + source-map-support@0.5.21: dependencies: buffer-from: 1.1.2 @@ -3411,6 +4753,8 @@ snapshots: source-map@0.6.1: {} + source-map@0.7.6: {} + source-map@0.8.0-beta.0: dependencies: whatwg-url: 7.1.0 @@ -3455,6 +4799,37 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + swc-loader@0.2.7(@swc/core@1.15.33)(webpack@5.106.2(@swc/core@1.15.33)(esbuild@0.24.2)(postcss@8.5.14)): + dependencies: + '@swc/core': 1.15.33 + '@swc/counter': 0.1.3 + webpack: 5.106.2(@swc/core@1.15.33)(esbuild@0.24.2)(postcss@8.5.14) + + tapable@2.3.3: {} + + terser-webpack-plugin@5.6.0(@swc/core@1.15.33)(esbuild@0.24.2)(postcss@8.5.14)(webpack@5.106.2(@swc/core@1.15.33)(esbuild@0.24.2)(postcss@8.5.14)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.47.1 + webpack: 5.106.2(@swc/core@1.15.33)(esbuild@0.24.2)(postcss@8.5.14) + optionalDependencies: + '@swc/core': 1.15.33 + esbuild: 0.24.2 + postcss: 8.5.14 + + terser@5.47.1: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.16.0 + commander: 2.20.3 + source-map-support: 0.5.21 + test-exclude@7.0.2: dependencies: '@istanbuljs/schema': 0.1.6 @@ -3469,6 +4844,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thingies@2.6.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -3488,11 +4867,17 @@ snapshots: dependencies: punycode: 2.3.1 + tree-dump@1.1.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + tree-kill@1.2.2: {} ts-interface-checker@0.1.13: {} - tsup@8.3.5(postcss@8.5.14)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1): + tslib@2.8.1: {} + + tsup@8.3.5(@swc/core@1.15.33)(postcss@8.5.14)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1): dependencies: bundle-require: 5.1.0(esbuild@0.24.2) cac: 6.7.14 @@ -3511,6 +4896,7 @@ snapshots: tinyglobby: 0.2.16 tree-kill: 1.2.2 optionalDependencies: + '@swc/core': 1.15.33 postcss: 8.5.14 typescript: 5.6.3 transitivePeerDependencies: @@ -3537,13 +4923,25 @@ snapshots: undici-types@6.20.0: {} - vite-node@2.1.8(@types/node@22.10.2): + unionfs@4.6.0: + dependencies: + fs-monkey: 1.1.0 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uuid@11.1.1: {} + + vite-node@2.1.8(@types/node@22.10.2)(terser@5.47.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.21(@types/node@22.10.2) + vite: 5.4.21(@types/node@22.10.2)(terser@5.47.1) transitivePeerDependencies: - '@types/node' - less @@ -3555,7 +4953,7 @@ snapshots: - supports-color - terser - vite@5.4.21(@types/node@22.10.2): + vite@5.4.21(@types/node@22.10.2)(terser@5.47.1): dependencies: esbuild: 0.21.5 postcss: 8.5.14 @@ -3563,8 +4961,9 @@ snapshots: optionalDependencies: '@types/node': 22.10.2 fsevents: 2.3.3 + terser: 5.47.1 - vite@6.0.3(@types/node@22.10.2)(tsx@4.19.2)(yaml@2.6.1): + vite@6.0.3(@types/node@22.10.2)(terser@5.47.1)(tsx@4.19.2)(yaml@2.6.1): dependencies: esbuild: 0.24.2 postcss: 8.5.14 @@ -3572,13 +4971,14 @@ snapshots: optionalDependencies: '@types/node': 22.10.2 fsevents: 2.3.3 + terser: 5.47.1 tsx: 4.19.2 yaml: 2.6.1 - vitest@2.1.8(@types/node@22.10.2): + vitest@2.1.8(@types/node@22.10.2)(terser@5.47.1): dependencies: '@vitest/expect': 2.1.8 - '@vitest/mocker': 2.1.8(vite@5.4.21(@types/node@22.10.2)) + '@vitest/mocker': 2.1.8(vite@5.4.21(@types/node@22.10.2)(terser@5.47.1)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.8 '@vitest/snapshot': 2.1.8 @@ -3594,8 +4994,8 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@22.10.2) - vite-node: 2.1.8(@types/node@22.10.2) + vite: 5.4.21(@types/node@22.10.2)(terser@5.47.1) + vite-node: 2.1.8(@types/node@22.10.2)(terser@5.47.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.10.2 @@ -3610,8 +5010,55 @@ snapshots: - supports-color - terser + watchpack@2.5.1: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + webidl-conversions@4.0.2: {} + webpack-sources@3.4.1: {} + + webpack@5.106.2(@swc/core@1.15.33)(esbuild@0.24.2)(postcss@8.5.14): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.21.2 + es-module-lexer: 2.1.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + loader-runner: 4.3.2 + mime-db: 1.54.0 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.3 + terser-webpack-plugin: 5.6.0(@swc/core@1.15.33)(esbuild@0.24.2)(postcss@8.5.14)(webpack@5.106.2(@swc/core@1.15.33)(esbuild@0.24.2)(postcss@8.5.14)) + watchpack: 2.5.1 + webpack-sources: 3.4.1 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + whatwg-url@7.1.0: dependencies: lodash.sortby: 4.7.0 @@ -3641,6 +5088,20 @@ snapshots: xtend@4.0.2: {} + y18n@5.0.8: {} + yaml@2.6.1: {} + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + zod@3.24.1: {} diff --git a/tsconfig.json b/tsconfig.json index b6f81a3..2f3cf5c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,9 @@ { "path": "./packages/db" }, { "path": "./packages/run-engine" }, { "path": "./packages/session" }, + { "path": "./packages/workflows" }, { "path": "./apps/api" }, - { "path": "./apps/cli" } + { "path": "./apps/cli" }, + { "path": "./apps/worker" } ] } diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index 924216b..0f530cb 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -11,7 +11,8 @@ "@devflow/core": ["packages/core/src/index.ts"], "@devflow/db": ["packages/db/src/index.ts"], "@devflow/run-engine": ["packages/run-engine/src/index.ts"], - "@devflow/session": ["packages/session/src/index.ts"] + "@devflow/session": ["packages/session/src/index.ts"], + "@devflow/workflows": ["packages/workflows/src/index.ts"] } }, "include": ["apps/**/*.ts", "packages/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts", "*.ts"], diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 0f1cc43..8cf06f8 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -8,6 +8,7 @@ const alias = { "@devflow/db": resolve(root, "packages/db/src/index.ts"), "@devflow/run-engine": resolve(root, "packages/run-engine/src/index.ts"), "@devflow/session": resolve(root, "packages/session/src/index.ts"), + "@devflow/workflows": resolve(root, "packages/workflows/src/index.ts"), }; function nodeProject(name: string, include: string[]) { @@ -27,6 +28,8 @@ export default defineWorkspace([ nodeProject("packages/core", ["packages/core/src/**/*.test.ts"]), nodeProject("packages/session", ["packages/session/src/**/*.test.ts"]), nodeProject("packages/run-engine", ["packages/run-engine/src/**/*.test.ts"]), + nodeProject("packages/workflows", ["packages/workflows/src/**/*.test.ts"]), nodeProject("apps/api", ["apps/api/src/**/*.test.ts"]), nodeProject("apps/cli", ["apps/cli/src/**/*.test.ts"]), + nodeProject("apps/worker", ["apps/worker/src/**/*.test.ts"]), ]);