Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/api/tests/space-control-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const png = Buffer.from("89504e470d0a1a0a0000000d4948445200000001000000010806000
const dirs: string[] = [];
afterEach(async () => { await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); });

/** Exercise the real capture adapter with synthetic engine replies and temporary artifacts. */
async function adapter(answer: (request: Record<string, unknown>) => unknown | Promise<unknown>) {
const artifactRoot = await mkdtemp(join(tmpdir(), "space-control-capture-"));
dirs.push(artifactRoot);
Expand Down Expand Up @@ -131,7 +132,8 @@ describe("Space Control screenshot capture", () => {
await vi.advanceTimersByTimeAsync(6000);
const running = await started as { status: string; operationId: string };
expect(running).toMatchObject({ status: "RUNNING", operationId: expect.any(String) });
expect(requests[1]).toMatchObject({ viewport: "story" });
// RUNNING does not wait for the real filesystem setup before capture starts.
await vi.waitFor(() => expect(requests[1]).toMatchObject({ viewport: "story" }));
release();
await vi.waitFor(async () => expect(await service.operations(actor, room.id, running.operationId)).toMatchObject({ status: "COMPLETED" }));
const done = await service.operations(actor, room.id, running.operationId) as { results: Array<{ evidence: { result: Record<string, unknown> } }> };
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/features/app-notices/useAppNotices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ export function useAppNotices(): AppNotices {
if (detail?.message) showLiveDebug(detail.message, detail.type || "info");
};
window.addEventListener("space-live-debug", handleDebug);
return () => window.removeEventListener("space-live-debug", handleDebug);
return () => {
window.removeEventListener("space-live-debug", handleDebug);
if (liveDebugTimerRef.current !== null) window.clearTimeout(liveDebugTimerRef.current);
liveDebugTimerRef.current = null;
};
}, [showLiveDebug]);

const [storageWarning, setStorageWarning] = useState<string>("");
Expand Down
49 changes: 49 additions & 0 deletions apps/web/tests/app-notices-lifecycle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAppNotices } from "../src/features/app-notices/useAppNotices.js";

beforeEach(() => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
vi.spyOn(console, "log").mockImplementation(() => {});
});

afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});

describe("app notice lifecycle", () => {
it("releases the pending live-debug timer on unmount", () => {
const { result, unmount } = renderHook(() => useAppNotices());
const initialTimers = vi.getTimerCount();
act(() => {
window.dispatchEvent(new CustomEvent("space-live-debug", {
detail: { message: "Capture unavailable", type: "error" }
}));
});
expect(result.current.liveDebugToast?.message).toBe("Capture unavailable");
expect(vi.getTimerCount()).toBe(initialTimers + 1);

unmount();
expect(vi.getTimerCount(), "UNMOUNTED_NOTICE_TIMER_LEAK").toBe(initialTimers);
act(() => {
window.dispatchEvent(new CustomEvent("space-live-debug", {
detail: { message: "After unmount", type: "error" }
}));
vi.advanceTimersByTime(4500);
});
expect(vi.getTimerCount()).toBe(initialTimers);
});

it("keeps a replacement error visible for its full dismissal interval", () => {
const { result } = renderHook(() => useAppNotices());
act(() => result.current.showLiveDebug("First error", "error"));
act(() => vi.advanceTimersByTime(4000));
act(() => result.current.showLiveDebug("Second error", "error"));
act(() => vi.advanceTimersByTime(500));
expect(result.current.liveDebugToast?.message).toBe("Second error");
act(() => vi.advanceTimersByTime(4000));
expect(result.current.liveDebugToast).toBeNull();
});
});
6 changes: 5 additions & 1 deletion apps/worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"start": "node dist/worker.js",
"build": "tsc -p tsconfig.json",
"check": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
"test": "vitest run",
"temporal:record-histories": "TEMPORAL_RECORD_HISTORIES=1 vitest run tests/temporal-sandbox.test.ts"
},
"dependencies": {
"@space/codex-app-server": "0.2.5",
Expand All @@ -26,6 +27,9 @@
"undici": "8.10.2"
},
"devDependencies": {
"@temporalio/common": "1.24.0",
"@temporalio/proto": "1.24.0",
"@temporalio/testing": "1.24.0",
"typescript": "^5.9.3",
"vitest": "^4.1.12"
},
Expand Down
15 changes: 15 additions & 0 deletions apps/worker/tests/fixtures/workflow-histories/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Frozen Temporal histories

Run `npm test -w @space/worker` to execute real sandbox tests and replay the committed JSON histories. The replay gate does not regenerate its inputs. A failing replay reports the history filename and the SDK's determinism error.

To deliberately record a new baseline, build the workspace dependencies and run `npm run temporal:record-histories -w @space/worker`. The recorder starts an ephemeral time-skipping server, executes production workflows with synthetic activity stubs, and writes their real histories here. No provider, database, credentials or operator data is used. The pre-patch fixture is recorded by a narrowly scoped source loader that disables only the supervisor patch call; replay always uses unchanged production code.

After the initial baseline, re-record only alongside an explicitly reviewed `patched()` workflow change. Preserve old histories so the old branch remains exercised. Never re-record merely to make a failing replay pass. Review every fixture diff before committing, including workflow types, patch markers, timers, activities and continue-as-new inputs.

The recorder refuses to overwrite an existing file. For an approved patched change, use `TEMPORAL_HISTORY_REVISION=your-patch-id npm run temporal:record-histories -w @space/worker` to add versioned histories while retaining the old ones. A revision must be new and contain only letters, digits, underscores or hyphens. The replay gate requires all nine original baselines and replays every JSON file, including later versions. Check in the new histories together with the compatible code change.

The SDK downloads its test-server executable on first use and caches it. After dependencies and that executable are available, workflow activities need no external service: all provider, database and application activity calls are synthetic stubs, and the real Temporal connection is local to the disposable test server.

Provider policy assertions account for the Java test server clamping an activity timeout to its workflow run limit. The timeout-cause regression injects a typed `TimeoutFailure` through an activity's application-failure cause, verifies its real serialized history, and checks the supervisor's persisted cause code. It does not wait for a wall-clock heartbeat deadline; retry counts, durable workflow timers, signals and continuation boundaries are exercised separately on the real server.

The negative replay test inserts an extra timer in a test-only Dot workflow and requires a `DeterminismViolationError`. To check the production gate manually, temporarily insert `await sleep("1 second")` at the start of `dotGoalWorkflow`, run the tests without the recording environment variable, then revert that edit.
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
{
"events": [
{
"eventId": "1",
"eventTime": {
"seconds": "1792898854",
"nanos": 370000000
},
"eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_STARTED",
"workflowExecutionStartedEventAttributes": {
"workflowType": {
"name": "codexAppServerTurnWorkflow"
},
"taskQueue": {
"name": "synthetic-workflows",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"input": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJyb29tSWQiOiJyb29tOnN5bnRoZXRpYyIsInBhbmVJZCI6InBhbmU6c3ludGhldGljIiwicHJvbXB0IjoiU3ludGhldGljIHRlc3QgdGFzayIsImFydGlmYWN0SWRzIjpbXSwiYWdlbnRSdW5JZCI6ImFnZW50X3J1bjpjb2RleCIsInBlcm1pc3Npb25Nb2RlIjpudWxsLCJjb2xsYWJvcmF0aW9uTW9kZSI6ImRlZmF1bHQiLCJ0cmFjZUlkIjoidHJhY2U6Y29kZXgiLCJwcm92aWRlcklkIjoiY29kZXgifQ=="
}
]
},
"workflowExecutionTimeout": {
"seconds": "315360000"
},
"workflowRunTimeout": {
"seconds": "315360000"
},
"workflowTaskTimeout": {
"seconds": "10"
},
"originalExecutionRunId": "5357b545-554c-45b3-828e-1f71e4d9e904",
"identity": "2706@runnervmtai0g",
"firstExecutionRunId": "5357b545-554c-45b3-828e-1f71e4d9e904",
"attempt": 1,
"firstWorkflowTaskBackoff": {},
"header": {}
}
},
{
"eventId": "2",
"eventTime": {
"seconds": "1792898854",
"nanos": 370000000
},
"eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED",
"workflowTaskScheduledEventAttributes": {
"taskQueue": {
"name": "synthetic-workflows",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"startToCloseTimeout": {
"seconds": "10"
},
"attempt": 1
}
},
{
"eventId": "3",
"eventTime": {
"seconds": "1792898854",
"nanos": 370000000
},
"eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED",
"workflowTaskStartedEventAttributes": {
"scheduledEventId": "2",
"identity": "2706@runnervmtai0g",
"historySizeBytes": "515"
}
},
{
"eventId": "4",
"eventTime": {
"seconds": "1792898855",
"nanos": 23000000
},
"eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED",
"workflowTaskCompletedEventAttributes": {
"scheduledEventId": "2",
"identity": "2706@runnervmtai0g",
"binaryChecksum": "@temporalio/worker@1.24.0+56f3f8765c0c1cfea6f6a4da690872528e23901f952c6558bf7949b3732b73d5",
"sdkMetadata": {
"coreUsedFlags": [
2,
1,
3
],
"sdkName": "temporal-typescript",
"sdkVersion": "1.24.0"
},
"meteringMetadata": {}
}
},
{
"eventId": "5",
"eventTime": {
"seconds": "1792898855",
"nanos": 23000000
},
"eventType": "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED",
"activityTaskScheduledEventAttributes": {
"activityId": "1",
"activityType": {
"name": "runCodexAppServerTurn"
},
"taskQueue": {
"name": "synthetic-workflows",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"header": {},
"input": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJyb29tSWQiOiJyb29tOnN5bnRoZXRpYyIsInBhbmVJZCI6InBhbmU6c3ludGhldGljIiwicHJvbXB0IjoiU3ludGhldGljIHRlc3QgdGFzayIsImFydGlmYWN0SWRzIjpbXSwiYWdlbnRSdW5JZCI6ImFnZW50X3J1bjpjb2RleCIsInBlcm1pc3Npb25Nb2RlIjpudWxsLCJjb2xsYWJvcmF0aW9uTW9kZSI6ImRlZmF1bHQiLCJ0cmFjZUlkIjoidHJhY2U6Y29kZXgiLCJwcm92aWRlcklkIjoiY29kZXgifQ=="
}
]
},
"scheduleToCloseTimeout": {
"seconds": "315360000"
},
"scheduleToStartTimeout": {
"seconds": "315360000"
},
"startToCloseTimeout": {
"seconds": "86400"
},
"heartbeatTimeout": {
"seconds": "15"
},
"workflowTaskCompletedEventId": "3",
"retryPolicy": {
"initialInterval": {
"seconds": "1"
},
"backoffCoefficient": 2,
"maximumInterval": {
"seconds": "100"
},
"maximumAttempts": 5
}
}
},
{
"eventId": "6",
"eventTime": {
"seconds": "1792898856",
"nanos": 26000000
},
"eventType": "EVENT_TYPE_ACTIVITY_TASK_STARTED",
"activityTaskStartedEventAttributes": {
"scheduledEventId": "5",
"identity": "2706@runnervmtai0g",
"attempt": 2
}
},
{
"eventId": "7",
"eventTime": {
"seconds": "1792898856",
"nanos": 28000000
},
"eventType": "EVENT_TYPE_ACTIVITY_TASK_COMPLETED",
"activityTaskCompletedEventAttributes": {
"result": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJzdGF0dXMiOiJDT01QTEVURUQiLCJtZXNzYWdlIjoiU3ludGhldGljIHN1Y2Nlc3MiLCJyb29tQWdlbnRPdXRjb21lIjp7InN0YXR1cyI6IlZFUklGSUVEIiwiZXhlY3V0ZWRBY3Rpb25Db3VudCI6MSwic3RhdHVzUmVhc29uIjoiU3ludGhldGljIGV2aWRlbmNlIn19"
}
]
},
"scheduledEventId": "5",
"startedEventId": "6",
"identity": "2706@runnervmtai0g"
}
},
{
"eventId": "8",
"eventTime": {
"seconds": "1792898856",
"nanos": 28000000
},
"eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED",
"workflowTaskScheduledEventAttributes": {
"taskQueue": {
"name": "synthetic-workflows",
"kind": "TASK_QUEUE_KIND_NORMAL"
},
"startToCloseTimeout": {
"seconds": "10"
},
"attempt": 1
}
},
{
"eventId": "9",
"eventTime": {
"seconds": "1792898856",
"nanos": 28000000
},
"eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED",
"workflowTaskStartedEventAttributes": {
"scheduledEventId": "8",
"identity": "2706@runnervmtai0g",
"historySizeBytes": "1443"
}
},
{
"eventId": "10",
"eventTime": {
"seconds": "1792898856",
"nanos": 32000000
},
"eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED",
"workflowTaskCompletedEventAttributes": {
"scheduledEventId": "8",
"identity": "2706@runnervmtai0g",
"binaryChecksum": "@temporalio/worker@1.24.0+56f3f8765c0c1cfea6f6a4da690872528e23901f952c6558bf7949b3732b73d5",
"sdkMetadata": {},
"meteringMetadata": {}
}
},
{
"eventId": "11",
"eventTime": {
"seconds": "1792898856",
"nanos": 32000000
},
"eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED",
"workflowExecutionCompletedEventAttributes": {
"result": {
"payloads": [
{
"metadata": {
"encoding": "anNvbi9wbGFpbg=="
},
"data": "eyJzdGF0dXMiOiJDT01QTEVURUQiLCJtZXNzYWdlIjoiU3ludGhldGljIHN1Y2Nlc3MiLCJyb29tQWdlbnRPdXRjb21lIjp7InN0YXR1cyI6IlZFUklGSUVEIiwiZXhlY3V0ZWRBY3Rpb25Db3VudCI6MSwic3RhdHVzUmVhc29uIjoiU3ludGhldGljIGV2aWRlbmNlIn19"
}
]
},
"workflowTaskCompletedEventId": "9"
}
}
]
}
Loading