Repository navigation
test(worker): exercise every workflow in a real Temporal sandbox and gate replay determinism #306
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
lilyliiphone2026-collab
wants to merge
2
commits into
oll4com:main
from
lilyliiphone2026-collab:test/workflow-sandbox-and-replay-gate
Closed
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * Records real Temporal histories for every workflow in apps/worker into | ||
| * tests/fixtures/workflow-histories/ so `workflow-replay.test.ts` can replay them | ||
| * against the current workflow code in CI. | ||
| * | ||
| * Run with `npm run temporal:record-histories -w @space/worker`. | ||
| * | ||
| * Re-record only together with a `patched()` change. Never re-record to make a | ||
| * failing replay pass: a failing replay means a running execution would break. | ||
| * | ||
| * The scenarios here are intentionally minimal — one canonical run per workflow, | ||
| * plus the supervisor's continue-as-new handover. They exist to produce a replayable | ||
| * history, not to assert behaviour; behaviour lives in workflow-sandbox.test.ts. | ||
| */ | ||
| import { mkdir, writeFile } from "node:fs/promises"; | ||
| import { dirname, join } from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import { TestWorkflowEnvironment } from "@temporalio/testing"; | ||
| import { Worker } from "@temporalio/worker"; | ||
|
|
||
| const here = dirname(fileURLToPath(import.meta.url)); | ||
| const fixtures = join(here, "..", "tests", "fixtures", "workflow-histories"); | ||
|
|
||
| const MAIN = join(here, "..", "src", "workflows.ts"); | ||
| const MAINTENANCE = join(here, "..", "src", "memory-maintenance-workflows.ts"); | ||
| const MUTATION = join(here, "..", "src", "memory-mutation-workflows.ts"); | ||
|
|
||
| const DEFAULT_TASK_QUEUE = "space-agent-turns"; | ||
| const MEMORY_MAINTENANCE_TASK_QUEUE = "space-memory-maintenance"; | ||
| const MEMORY_MUTATION_TASK_QUEUE = "space-memory-mutations"; | ||
|
|
||
| const verifiedTurn = () => ({ | ||
| status: "COMPLETED", | ||
| message: "ok", | ||
| roomAgentOutcome: { status: "VERIFIED", executedActionCount: 0, statusReason: "Verified." } | ||
| }); | ||
|
|
||
| const activities = { | ||
| recordDummyTurnStarted: async () => undefined, | ||
| recordDummyTurnCompleted: async () => ({ status: "COMPLETED", message: "ok" }), | ||
| runCodexAppServerTurn: async () => verifiedTurn(), | ||
| runOpenCodeAgentTurn: async () => verifiedTurn(), | ||
| runCliAgentTurn: async () => verifiedTurn(), | ||
| runRoomAgentBackgroundAction: async () => ({ status: "COMPLETED" }), | ||
| markRoomAgentMissionStarted: async () => undefined, | ||
| markRoomAgentMissionFinished: async () => undefined, | ||
| markRoomAgentMissionContinued: async () => undefined, | ||
| settleRoomAgentBackgroundMission: async () => true, | ||
| checkRoomAgentSupervisorTurn: async () => "PROCEED", | ||
| discardRoomAgentSupervisorTurn: async () => undefined, | ||
| finishRoomAgentSupervisorTurn: async () => undefined, | ||
| prepareRoomAgentBackgroundContinuation: async () => ({ state: "DONE" }), | ||
| failRoomAgentBackgroundAction: async () => undefined, | ||
| dotGoalStep: async () => ({ state: "DONE", runId: null, goalStatus: "COMPLETE" }), | ||
| dotGoalObserve: async () => ({ run: "SUCCEEDED", goalStatus: "COMPLETE" }), | ||
| refreshMemoryGraphSnapshot: async (input) => ({ | ||
| status: "REFRESHED", generatedAt: input.scheduledAt, sourceHash: "hash-1", | ||
| previousSourceHash: null, summary: {}, durationMs: 1 | ||
| }), | ||
| refreshAllMonthsMemoryGraphSnapshot: async () => undefined, | ||
| reconcileStaleSpaceAgentSessions: async () => undefined, | ||
| createScheduledMemoryAudit: async () => ({ id: "run-recorded", workflowId: "space-memory-maintenance:recorded" }), | ||
| prepareMemoryConsolidation: async () => ({ changeSetId: "cs-recorded", run: { id: "run-recorded", status: "PENDING" } }), | ||
| finalizeMemoryRepair: async (input) => ({ id: input.runId, status: "REPAIRED" }), | ||
| executePersistedMemoryChangeSet: async () => ({ status: "APPLIED" }) | ||
| }; | ||
|
|
||
| const turn = { roomId: "room-recorder", paneId: "pane-recorder", prompt: "Record a history", artifactIds: [] }; | ||
|
|
||
| const scenario = [ | ||
| { name: "main-dummy-turn", bundle: MAIN, queue: DEFAULT_TASK_QUEUE, type: "dummyTurnWorkflow", args: [turn] }, | ||
| { name: "main-codex-turn", bundle: MAIN, queue: DEFAULT_TASK_QUEUE, type: "codexAppServerTurnWorkflow", args: [turn] }, | ||
| { | ||
| name: "main-room-action", bundle: MAIN, queue: DEFAULT_TASK_QUEUE, type: "roomAgentActionWorkflow", | ||
| workflowId: "space-room-action:recorded", args: [{ bridge: { roomId: "room-recorder", missionId: "mission-recorder" } }] | ||
| }, | ||
| { name: "main-dot-goal", bundle: MAIN, queue: DEFAULT_TASK_QUEUE, type: "dotGoalWorkflow", args: [{ goalId: "goal-recorder" }] }, | ||
| { | ||
| name: "main-supervisor-first-run", bundle: MAIN, queue: DEFAULT_TASK_QUEUE, type: "roomAgentSupervisorWorkflow", | ||
| args: [{ | ||
| roomId: "room-recorder", monitoringMissionIds: [], interrupted: [], processedCount: 49, | ||
| pending: [{ missionId: "mission-recorder", turn: { ...turn, agentRunId: "agent-run-recorder", traceId: "trace-recorder-1" } }] | ||
| }], | ||
| recordFirstRun: true | ||
| }, | ||
| { | ||
| name: "maintenance-run", bundle: MAINTENANCE, queue: MEMORY_MAINTENANCE_TASK_QUEUE, type: "memoryMaintenanceWorkflow", | ||
| args: [{ scheduledAt: "2026-10-25T00:00:00.000Z", traceId: "trace-recorder" }] | ||
| }, | ||
| { | ||
| name: "maintenance-consolidation", bundle: MAINTENANCE, queue: MEMORY_MAINTENANCE_TASK_QUEUE, type: "memoryConsolidationWorkflow", | ||
| args: [{ runId: "run-recorder", traceId: "trace-recorder" }] | ||
| }, | ||
| { | ||
| name: "mutation-run", bundle: MUTATION, queue: MEMORY_MUTATION_TASK_QUEUE, type: "memoryMutationWorkflow", | ||
| args: [{ changeSetId: "cs-recorder", traceId: "trace-recorder" }] | ||
| } | ||
| ]; | ||
|
|
||
| const namespace = "default"; | ||
|
|
||
| /** | ||
| * Waits until the workflow has handed over to a new run, then returns the id of the | ||
| * run that was superseded. Waiting on the run id instead of sleeping a fixed interval | ||
| * keeps the recorded history shape stable across machines. | ||
| */ | ||
| async function waitForContinuation(env, workflowId, firstRunId, name, timeoutMs = 120_000) { | ||
| const latest = env.client.workflow.getHandle(workflowId); | ||
| const deadline = Date.now() + timeoutMs; | ||
| for (;;) { | ||
| const description = await latest.describe(); | ||
| if (description.runId !== firstRunId) return; | ||
| if (Date.now() > deadline) throw new Error(`${name}: the run never continued as new`); | ||
| await new Promise((resolve) => setTimeout(resolve, 50)); | ||
| } | ||
| } | ||
|
|
||
| async function main() { | ||
| const env = await TestWorkflowEnvironment.createTimeSkipping(); | ||
| const workers = await Promise.all( | ||
| [...new Set(scenario.map((entry) => `${entry.bundle}|${entry.queue}`))].map((key) => { | ||
| const [bundle, queue] = key.split("|"); | ||
| return Worker.create({ connection: env.nativeConnection, taskQueue: queue, workflowsPath: bundle, activities }); | ||
| }) | ||
| ); | ||
| const runs = workers.map((worker) => worker.run()); | ||
|
|
||
| await mkdir(fixtures, { recursive: true }); | ||
|
|
||
| try { | ||
| for (const entry of scenario) { | ||
| const workflowId = entry.workflowId ?? `${entry.name}-recorder`; | ||
| const handle = await env.client.workflow.start(entry.type, { | ||
| taskQueue: entry.queue, workflowId, args: entry.args | ||
| }); | ||
|
|
||
| // A workflow that never returns is recorded from its first run: that is the run | ||
| // holding the activities, the child workflow and the handover, not the idle run | ||
| // that continueAsNew leaves behind. | ||
| const recordedRunId = handle.firstExecutionRunId; | ||
| if (entry.recordFirstRun) await waitForContinuation(env, workflowId, recordedRunId, entry.name); | ||
| else await handle.result(); | ||
|
|
||
| const response = await env.client.workflowService.getWorkflowExecutionHistory({ | ||
| namespace, | ||
| execution: { workflowId, runId: recordedRunId } | ||
| }); | ||
| const events = response.history?.events?.length ?? 0; | ||
| await writeFile(join(fixtures, `${entry.name}.json`), `${JSON.stringify(response.history, null, 2)}\n`, "utf8"); | ||
| console.log(`recorded ${entry.name}.json (${events} events, run ${recordedRunId.slice(0, 8)})`); | ||
|
|
||
| if (entry.recordFirstRun) await handle.terminate("recorded for the replay gate"); | ||
| } | ||
| } finally { | ||
| for (const worker of workers) worker.shutdown(); | ||
| await Promise.allSettled(runs); | ||
| await env.teardown(); | ||
| } | ||
| } | ||
|
|
||
| main().catch((error) => { | ||
| console.error(error); | ||
| process.exitCode = 1; | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { proxyActivities, sleep } from "@temporalio/workflow"; | ||
|
|
||
| /** | ||
| * Reference workflow for the replay gate: a fixed number of activities separated by | ||
| * timers. Its recorded history must replay against this file unchanged. | ||
| * | ||
| * The sibling `drifted-workflows.ts` deliberately differs by one extra timer; the | ||
| * replay test asserts that the drift is caught as a determinism violation. | ||
| */ | ||
| const { recordReplayProbeStep } = proxyActivities<{ | ||
| recordReplayProbeStep(input: { step: number }): Promise<void>; | ||
| }>({ startToCloseTimeout: "1 minute" }); | ||
|
|
||
| export async function replayProbeWorkflow(): Promise<string> { | ||
| await recordReplayProbeStep({ step: 1 }); | ||
| await sleep("1 minute"); | ||
| await recordReplayProbeStep({ step: 2 }); | ||
| await sleep("1 minute"); | ||
| await recordReplayProbeStep({ step: 3 }); | ||
| return "done"; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { proxyActivities, sleep } from "@temporalio/workflow"; | ||
|
|
||
| /** | ||
| * The same workflow as `clean-workflows.ts` with one extra timer at the start. | ||
| * | ||
| * It exists only so the replay gate can prove it still catches drift: replaying a | ||
| * history recorded by the clean file against this file must fail with | ||
| * `DeterminismViolationError`. Nothing in production references this file. | ||
| */ | ||
| const { recordReplayProbeStep } = proxyActivities<{ | ||
| recordReplayProbeStep(input: { step: number }): Promise<void>; | ||
| }>({ startToCloseTimeout: "1 minute" }); | ||
|
|
||
| export async function replayProbeWorkflow(): Promise<string> { | ||
| await sleep("1 minute"); // <- the drift: a timer the recorded history never had | ||
| await recordReplayProbeStep({ step: 1 }); | ||
| await sleep("1 minute"); | ||
| await recordReplayProbeStep({ step: 2 }); | ||
| await sleep("1 minute"); | ||
| await recordReplayProbeStep({ step: 3 }); | ||
| return "done"; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.