@xfey/tutti 0.1.94 → 0.1.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/collaboration-state/index.d.ts +2 -0
  2. package/dist/collaboration-state/index.js +1 -0
  3. package/dist/collaboration-state/messages.js +15 -0
  4. package/dist/collaboration-state/run-recording.js +18 -1
  5. package/dist/collaboration-state/run-results.js +6 -0
  6. package/dist/collaboration-state/task-retry.d.ts +16 -0
  7. package/dist/collaboration-state/task-retry.js +136 -0
  8. package/dist/control-plane/command-recovery.js +9 -1
  9. package/dist/control-plane/index.d.ts +7 -3
  10. package/dist/control-plane/index.js +68 -6
  11. package/dist/control-plane/run-retry.d.ts +19 -0
  12. package/dist/control-plane/run-retry.js +103 -0
  13. package/dist/control-plane/run-start.d.ts +2 -1
  14. package/dist/control-plane/run-start.js +3 -0
  15. package/dist/control-plane/scratchpad-refresh.js +3 -4
  16. package/dist/control-plane/types.d.ts +12 -0
  17. package/dist/control-plane/workflows/openai.d.ts +1 -0
  18. package/dist/control-plane/workflows/openai.js +86 -15
  19. package/dist/control-plane/worklist-terminal-feedback.js +3 -0
  20. package/dist/prompt-templates/index.d.ts +1 -1
  21. package/dist/prompt-templates/index.js +4 -3
  22. package/dist/providers/openai/app-server/durable-session-smoke-cli.d.ts +2 -0
  23. package/dist/providers/openai/app-server/durable-session-smoke-cli.js +229 -0
  24. package/dist/providers/openai/app-server/read-only-procedure.d.ts +10 -0
  25. package/dist/providers/openai/app-server/read-only-procedure.js +42 -13
  26. package/dist/providers/openai/app-server/session-store.d.ts +74 -0
  27. package/dist/providers/openai/app-server/session-store.js +303 -0
  28. package/dist/providers/openai/app-server/workspace-write-run.d.ts +17 -4
  29. package/dist/providers/openai/app-server/workspace-write-run.js +92 -28
  30. package/dist/providers/openai/codex-app-server.d.ts +1 -0
  31. package/dist/providers/openai/codex-app-server.js +2 -0
  32. package/dist/run-pipeline/openai.d.ts +1 -0
  33. package/dist/run-pipeline/openai.js +15 -43
  34. package/dist/run-pipeline/task-run-invocation.js +354 -93
  35. package/dist/server-shell/http/routes/project-api/execution-routes.js +32 -2
  36. package/dist/server-shell/http/routes/project-api/invalidations.d.ts +2 -1
  37. package/dist/server-shell/http/routes/project-api/invalidations.js +13 -0
  38. package/dist/server-shell/http/routes/project-api/openapi-execution-routes.d.ts +21 -0
  39. package/dist/server-shell/http/routes/project-api/openapi-execution-routes.js +12 -1
  40. package/dist/server-shell/http/routes/project-api/openapi.d.ts +7 -0
  41. package/node_modules/@tutti/shared/dist/schemas/api/clarifications.d.ts +1 -0
  42. package/node_modules/@tutti/shared/dist/schemas/api/index.d.ts +1 -1
  43. package/node_modules/@tutti/shared/dist/schemas/api/index.js +1 -1
  44. package/node_modules/@tutti/shared/dist/schemas/api/messages.d.ts +5 -0
  45. package/node_modules/@tutti/shared/dist/schemas/api/messages.js +1 -0
  46. package/node_modules/@tutti/shared/dist/schemas/api/runtime-events.d.ts +1 -0
  47. package/node_modules/@tutti/shared/dist/schemas/api/types.d.ts +4 -1
  48. package/node_modules/@tutti/shared/dist/schemas/api/viewer-reference.d.ts +1 -0
  49. package/node_modules/@tutti/shared/dist/schemas/api/work-items.d.ts +4 -0
  50. package/node_modules/@tutti/shared/dist/schemas/api/work-items.js +3 -0
  51. package/node_modules/@tutti/shared/dist/schemas/api/workspace-commands.d.ts +33 -0
  52. package/node_modules/@tutti/shared/dist/schemas/api/workspace-commands.js +35 -1
  53. package/package.json +1 -1
  54. package/prompts/runs/README.md +11 -16
  55. package/prompts/runs/task-execute.md +86 -0
  56. package/prompts/runs/task-finalize.md +11 -0
  57. package/prompts/runs/task-recovery.md +15 -0
  58. package/prompts/runs/task-result-contract.md +64 -0
  59. package/web/assets/{homepage-motion-scene-CLTkixIk.js → homepage-motion-scene-BFo4r4nz.js} +1 -1
  60. package/web/assets/index-CNIl4TF8.js +69 -0
  61. package/web/assets/index-quWLFJgD.css +1 -0
  62. package/web/index.html +2 -2
  63. package/prompts/runs/task-continuation.md +0 -201
  64. package/prompts/runs/task-retry.md +0 -209
  65. package/prompts/runs/task-run.md +0 -188
  66. package/web/assets/index-BTNYElB-.js +0 -69
  67. package/web/assets/index-Deu_eF5K.css +0 -1
@@ -1,5 +1,5 @@
1
- import { createMainChatAgentMessage, markScratchpadRefreshSucceeded, markScratchpadSourceDirty, upsertScratchpadProjection, } from "../collaboration-state/index.js";
2
- import { mainChatInvalidates, scratchpadRefreshInvalidates, } from "./invalidations.js";
1
+ import { createMainChatAgentMessage, markScratchpadRefreshSucceeded, upsertScratchpadProjection, } from "../collaboration-state/index.js";
2
+ import { mainChatInvalidates, scratchpadRefreshInvalidates } from "./invalidations.js";
3
3
  export function applyScratchpadRefreshOutput(input) {
4
4
  if (input.output.decision === "needs_human") {
5
5
  const message = createMainChatAgentMessage(input.store.db, {
@@ -7,7 +7,7 @@ export function applyScratchpadRefreshOutput(input) {
7
7
  refs: {
8
8
  workflow_ref: input.workflowRef,
9
9
  activity_ref: input.activityRef,
10
- scratchpad_source: "include",
10
+ scratchpad_source: "exclude",
11
11
  },
12
12
  now: input.now,
13
13
  });
@@ -20,7 +20,6 @@ export function applyScratchpadRefreshOutput(input) {
20
20
  ...input.cursorUpdate,
21
21
  now: input.now,
22
22
  });
23
- markScratchpadSourceDirty(input.store.db, { now: input.now });
24
23
  return {
25
24
  kind: "needs_human",
26
25
  summary: input.output.request_payload.summary,
@@ -85,6 +85,18 @@ export type RunContinuationContext = {
85
85
  };
86
86
  };
87
87
  export type RunCorrectionContext = {
88
+ previous_provider_result: {
89
+ decision: "completed" | "completed_no_repo_changes";
90
+ summary: string;
91
+ user_note_candidate?: string;
92
+ } | {
93
+ decision: "needs_human";
94
+ summary: string;
95
+ clarification_request_payload: ClarificationRequestPayload;
96
+ } | {
97
+ decision: "failed";
98
+ summary: string;
99
+ };
88
100
  summary: string;
89
101
  reason_code: string;
90
102
  guidance: string;
@@ -19,6 +19,7 @@ export type OpenAiProcedureWorkflowRunnerOptions = {
19
19
  turnTimeoutMs?: number;
20
20
  baseEnv?: NodeJS.ProcessEnv;
21
21
  tuttiHome?: string;
22
+ projectId?: ProjectId;
22
23
  userSkillsRoot?: string;
23
24
  builtInSkillsRoot?: string;
24
25
  agentContext?: CodexAppServerAgentContextRuntime;
@@ -7,7 +7,9 @@ import { createOpenAiStructuredOutputClient, runOpenAiStructuredOutput, } from "
7
7
  import { readOpenAiApiKeyCredential } from "../../providers/openai/credential-store.js";
8
8
  import { resolveOpenAiProviderConfig, } from "../../providers/openai/provider-config.js";
9
9
  import { SCRATCHPAD_REFRESH_OUTPUT_SCHEMA, TASK_COMPILE_OUTPUT_SCHEMA, PROJECT_CONTEXT_BOOTSTRAP_OUTPUT_SCHEMA, CONTEXT_SYNC_OUTPUT_SCHEMA, PROJECT_BRIEF_REFRESH_OUTPUT_SCHEMA, FOLLOW_UP_CHECK_OUTPUT_SCHEMA, REFERENCE_FILE_SUMMARY_OUTPUT_SCHEMA, isContextSyncWorkflowOutput, isFollowUpCheckStructuredOutput, isProjectBriefRefreshWorkflowOutput, isProjectContextBootstrapWorkflowOutput, isReferenceFileSummaryStructuredOutput, isScratchpadRefreshStructuredOutput, isTaskCompileStructuredOutput, } from "./structured-output.js";
10
- import { runCodexAppServerReadOnlyProcedure } from "../../providers/openai/app-server/read-only-procedure.js";
10
+ import { runCodexAppServerReadOnlyProcedure, } from "../../providers/openai/app-server/read-only-procedure.js";
11
+ import { CODEX_APP_SERVER_VERSION } from "../../providers/openai/codex-app-server.js";
12
+ import { acquireCodexTaskSessionLock, createProviderFingerprint, markCodexTaskSessionArchived, openCodexDurableSession, recordCodexSessionArchiveFailure, recordCodexTaskSessionThread, } from "../../providers/openai/app-server/session-store.js";
11
13
  import { getProjectUserSkillsRoot } from "../../skills/index.js";
12
14
  function readOnlyAppServerOptions(options, prompt, outputSchema, skillSelectionName) {
13
15
  if (options.readOnlyAppServer === undefined) {
@@ -70,6 +72,56 @@ function readOnlyRuntimeTelemetry(context, stageId) {
70
72
  },
71
73
  };
72
74
  }
75
+ async function runDurableReadOnlyProcedure(input) {
76
+ const runtime = input.options.readOnlyAppServer;
77
+ if (runtime?.tuttiHome === undefined ||
78
+ runtime.projectId === undefined ||
79
+ input.context === undefined) {
80
+ return runCodexAppServerReadOnlyProcedure(input.invocation);
81
+ }
82
+ let session = openCodexDurableSession({
83
+ tuttiHome: runtime.tuttiHome,
84
+ projectId: runtime.projectId,
85
+ kind: input.kind,
86
+ ownerRef: input.context.workflow_ref,
87
+ compatibility: {
88
+ codexVersion: CODEX_APP_SERVER_VERSION,
89
+ model: input.options.config.default_model,
90
+ providerFingerprint: createProviderFingerprint({
91
+ providerKind: "openai",
92
+ ...(input.options.config.api_base_url === undefined
93
+ ? {}
94
+ : { apiBaseUrl: input.options.config.api_base_url }),
95
+ }),
96
+ },
97
+ });
98
+ const release = acquireCodexTaskSessionLock(session);
99
+ try {
100
+ return await runCodexAppServerReadOnlyProcedure({
101
+ ...input.invocation,
102
+ session: {
103
+ codexHome: session.codexHome,
104
+ ...(session.metadata.thread_id === undefined
105
+ ? {}
106
+ : { threadId: session.metadata.thread_id }),
107
+ ...(session.metadata.lifecycle === "archived" ? { unarchive: true } : {}),
108
+ onThreadReady: (threadId) => {
109
+ session = recordCodexTaskSessionThread(session, threadId);
110
+ },
111
+ archiveAfterTurn: input.archiveAfterTurn,
112
+ onThreadArchived: () => {
113
+ session = markCodexTaskSessionArchived(session);
114
+ },
115
+ onThreadArchiveFailure: () => {
116
+ recordCodexSessionArchiveFailure(session);
117
+ },
118
+ },
119
+ });
120
+ }
121
+ finally {
122
+ release();
123
+ }
124
+ }
73
125
  function referenceSummarySourcePath(options, input) {
74
126
  if (options.readOnlyAppServer === undefined) {
75
127
  throw new Error("Codex app-server read-only Procedure adapter is not configured");
@@ -284,12 +336,18 @@ export function createOpenAiProcedureWorkflowRunner(options) {
284
336
  workflow_input_json: buildTaskCompilePromptInput(input),
285
337
  },
286
338
  });
287
- const result = await runCodexAppServerReadOnlyProcedure({
288
- ...readOnlyAppServerOptions(options, instructions, TASK_COMPILE_OUTPUT_SCHEMA, "task_compile"),
289
- ...readOnlyRuntimeTelemetry(context, "task_compile"),
290
- validateOutput: isTaskCompileStructuredOutput,
291
- networkAccess: false,
292
- webSearchMode: "disabled",
339
+ const result = await runDurableReadOnlyProcedure({
340
+ options,
341
+ context,
342
+ kind: "task_compile",
343
+ archiveAfterTurn: (output) => "proposal" in output.result,
344
+ invocation: {
345
+ ...readOnlyAppServerOptions(options, instructions, TASK_COMPILE_OUTPUT_SCHEMA, "task_compile"),
346
+ ...readOnlyRuntimeTelemetry(context, "task_compile"),
347
+ validateOutput: isTaskCompileStructuredOutput,
348
+ networkAccess: false,
349
+ webSearchMode: "disabled",
350
+ },
293
351
  });
294
352
  recordUsage(options, "task_compile", result, {
295
353
  workflow_ref: context?.workflow_ref ?? input.workflow_ref,
@@ -333,10 +391,16 @@ export function createOpenAiProcedureWorkflowRunner(options) {
333
391
  workflow_input_json: input,
334
392
  },
335
393
  });
336
- const result = await runCodexAppServerReadOnlyProcedure({
337
- ...readOnlyAppServerOptions(options, instructions, PROJECT_CONTEXT_BOOTSTRAP_OUTPUT_SCHEMA, "project_context_bootstrap"),
338
- ...readOnlyRuntimeTelemetry(context, "project_context_bootstrap"),
339
- validateOutput: isProjectContextBootstrapWorkflowOutput,
394
+ const result = await runDurableReadOnlyProcedure({
395
+ options,
396
+ context,
397
+ kind: "project_context_bootstrap",
398
+ archiveAfterTurn: () => true,
399
+ invocation: {
400
+ ...readOnlyAppServerOptions(options, instructions, PROJECT_CONTEXT_BOOTSTRAP_OUTPUT_SCHEMA, "project_context_bootstrap"),
401
+ ...readOnlyRuntimeTelemetry(context, "project_context_bootstrap"),
402
+ validateOutput: isProjectContextBootstrapWorkflowOutput,
403
+ },
340
404
  });
341
405
  recordUsage(options, "project_context_bootstrap", result, {
342
406
  ...(context?.workflow_ref === undefined ? {} : { workflow_ref: context.workflow_ref }),
@@ -349,10 +413,16 @@ export function createOpenAiProcedureWorkflowRunner(options) {
349
413
  templateKey: "procedures.context_sync",
350
414
  ...(options.promptsRoot === undefined ? {} : { promptsRoot: options.promptsRoot }),
351
415
  });
352
- const result = await runCodexAppServerReadOnlyProcedure({
353
- ...readOnlyAppServerOptions(options, instructions, CONTEXT_SYNC_OUTPUT_SCHEMA, "context_sync"),
354
- ...readOnlyRuntimeTelemetry(context, "context_sync"),
355
- validateOutput: isContextSyncWorkflowOutput,
416
+ const result = await runDurableReadOnlyProcedure({
417
+ options,
418
+ context,
419
+ kind: "context_sync",
420
+ archiveAfterTurn: () => true,
421
+ invocation: {
422
+ ...readOnlyAppServerOptions(options, instructions, CONTEXT_SYNC_OUTPUT_SCHEMA, "context_sync"),
423
+ ...readOnlyRuntimeTelemetry(context, "context_sync"),
424
+ validateOutput: isContextSyncWorkflowOutput,
425
+ },
356
426
  });
357
427
  recordUsage(options, "context_sync", result, {
358
428
  workflow_ref: context?.workflow_ref ?? input.workflow_ref,
@@ -426,6 +496,7 @@ export function resolveProjectOpenAiProcedureWorkflowRunner(options) {
426
496
  apiKey: credential.secret,
427
497
  workspaceRoot: options.workspaceRoot,
428
498
  tuttiHome: options.tuttiHome,
499
+ projectId: options.projectId,
429
500
  userSkillsRoot: getProjectUserSkillsRoot(options.tuttiHome, options.projectId),
430
501
  ...(options.agentContext === undefined ? {} : { agentContext: options.agentContext }),
431
502
  ...(options.invocationCoordinator === undefined
@@ -272,6 +272,9 @@ export function emitTaskFailedResultMessage(input) {
272
272
  : { latest_run_result_id: item.latest_run_result_id }),
273
273
  ...(input.activityRef === undefined ? {} : { activity_ref: input.activityRef }),
274
274
  ...(errorCode === undefined ? {} : { error_code: errorCode }),
275
+ ...(entry.detail?.detail.result.retry_action === undefined
276
+ ? {}
277
+ : { retry_action: entry.detail.detail.result.retry_action }),
275
278
  };
276
279
  return publishTaskResultMessage(input, result);
277
280
  }
@@ -1,4 +1,4 @@
1
- export type PromptTemplateKey = "procedures.project_context_bootstrap" | "procedures.context_sync" | "procedures.project_brief_refresh" | "procedures.scratchpad_refresh" | "procedures.task_compile" | "procedures.reference_file_summary" | "procedures.follow_up_check" | "runs.task_run" | "runs.task_continuation" | "runs.task_retry" | "artifacts.applicability" | "chat.assistant" | "codex.no_write_smoke";
1
+ export type PromptTemplateKey = "procedures.project_context_bootstrap" | "procedures.context_sync" | "procedures.project_brief_refresh" | "procedures.scratchpad_refresh" | "procedures.task_compile" | "procedures.reference_file_summary" | "procedures.follow_up_check" | "runs.task_execute" | "runs.task_finalize" | "runs.task_recovery" | "runs.task_result_contract" | "artifacts.applicability" | "chat.assistant" | "codex.no_write_smoke";
2
2
  export type RenderPromptTemplateOptions = {
3
3
  templateKey: PromptTemplateKey;
4
4
  variables?: Record<string, unknown>;
@@ -19,9 +19,10 @@ const TEMPLATE_REGISTRY = {
19
19
  "procedures.task_compile": "procedures/task-compile.md",
20
20
  "procedures.reference_file_summary": "procedures/reference-file-summary.md",
21
21
  "procedures.follow_up_check": "procedures/follow-up-check.md",
22
- "runs.task_run": "runs/task-run.md",
23
- "runs.task_continuation": "runs/task-continuation.md",
24
- "runs.task_retry": "runs/task-retry.md",
22
+ "runs.task_execute": "runs/task-execute.md",
23
+ "runs.task_finalize": "runs/task-finalize.md",
24
+ "runs.task_recovery": "runs/task-recovery.md",
25
+ "runs.task_result_contract": "runs/task-result-contract.md",
25
26
  "artifacts.applicability": "artifacts/applicability.md",
26
27
  "chat.assistant": "chat-assistant.md",
27
28
  "codex.no_write_smoke": "codex/no-write-smoke.md",
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=durable-session-smoke-cli.d.ts.map
@@ -0,0 +1,229 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, lstatSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, } from "node:fs";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { join, relative } from "node:path";
6
+ import { createProjectId, createTaskId } from "@tutti/shared/ids";
7
+ import { CODEX_APP_SERVER_VERSION } from "../codex-app-server.js";
8
+ import { DEFAULT_OPENAI_MODEL } from "../model-config.js";
9
+ import { runCodexAppServerReadOnlyProcedure } from "./read-only-procedure.js";
10
+ import { resolveCodexAppServerSmokeProviderInput } from "./smoke-provider-input.js";
11
+ import { acquireCodexTaskSessionLock, createProviderFingerprint, markCodexTaskSessionArchived, openCodexTaskSession, recordCodexTaskSessionThread, } from "./session-store.js";
12
+ import { runCodexAppServerWorkspaceWriteRun } from "./workspace-write-run.js";
13
+ const WRITE_SENTINEL = "TUTTI_DURABLE_WRITE";
14
+ const RESULT_SCHEMA = {
15
+ type: "object",
16
+ additionalProperties: false,
17
+ properties: {
18
+ sentinel: { type: "string", enum: [WRITE_SENTINEL] },
19
+ file_present: { type: "boolean" },
20
+ },
21
+ required: ["sentinel", "file_present"],
22
+ };
23
+ function isResult(value) {
24
+ return (typeof value === "object" &&
25
+ value !== null &&
26
+ !Array.isArray(value) &&
27
+ value.sentinel === WRITE_SENTINEL &&
28
+ value.file_present === true);
29
+ }
30
+ function mode(path) {
31
+ return statSync(path).mode & 0o777;
32
+ }
33
+ function requireMode(path, expected) {
34
+ const actual = mode(path);
35
+ if (actual !== expected) {
36
+ throw new Error(`Durable session path has mode ${actual.toString(8)}; expected ${expected.toString(8)}.`);
37
+ }
38
+ }
39
+ function fingerprintPath(path) {
40
+ if (!existsSync(path)) {
41
+ return "missing";
42
+ }
43
+ const hash = createHash("sha256");
44
+ const visit = (current) => {
45
+ const entry = lstatSync(current);
46
+ hash.update(JSON.stringify({
47
+ path: relative(path, current),
48
+ mode: entry.mode & 0o777,
49
+ size: entry.size,
50
+ modified_ms: entry.mtimeMs,
51
+ type: entry.isDirectory() ? "directory" : entry.isSymbolicLink() ? "symlink" : "file",
52
+ }));
53
+ if (!entry.isDirectory()) {
54
+ return;
55
+ }
56
+ for (const child of readdirSync(current).sort()) {
57
+ visit(join(current, child));
58
+ }
59
+ };
60
+ visit(path);
61
+ return hash.digest("hex");
62
+ }
63
+ function userCodexConfigurationFingerprint() {
64
+ const root = join(homedir(), ".codex");
65
+ const hash = createHash("sha256");
66
+ for (const relativePath of ["auth.json", "config.toml", "plugins", "skills"]) {
67
+ hash.update(relativePath);
68
+ hash.update(fingerprintPath(join(root, relativePath)));
69
+ }
70
+ return hash.digest("hex");
71
+ }
72
+ async function withSessionLock(session, run) {
73
+ const release = acquireCodexTaskSessionLock(session);
74
+ try {
75
+ return await run();
76
+ }
77
+ finally {
78
+ release();
79
+ }
80
+ }
81
+ async function main() {
82
+ const provider = resolveCodexAppServerSmokeProviderInput();
83
+ const model = provider.defaultModel ?? DEFAULT_OPENAI_MODEL;
84
+ const smokeRoot = mkdtempSync(join(tmpdir(), "tutti-codex-durable-session-smoke-"));
85
+ const tuttiHome = join(smokeRoot, "tutti-home");
86
+ const workspaceRoot = join(smokeRoot, "repo");
87
+ const projectId = createProjectId();
88
+ const taskId = createTaskId();
89
+ const userCodexBefore = userCodexConfigurationFingerprint();
90
+ let session = openCodexTaskSession({
91
+ tuttiHome,
92
+ projectId,
93
+ taskId,
94
+ compatibility: {
95
+ codexVersion: CODEX_APP_SERVER_VERSION,
96
+ model,
97
+ providerFingerprint: createProviderFingerprint({
98
+ providerKind: "openai",
99
+ ...(provider.apiBaseUrl === undefined ? {} : { apiBaseUrl: provider.apiBaseUrl }),
100
+ }),
101
+ },
102
+ });
103
+ try {
104
+ execFileSync("git", ["init", workspaceRoot], { stdio: "ignore" });
105
+ execFileSync("git", [
106
+ "-C",
107
+ workspaceRoot,
108
+ "config",
109
+ "user.email",
110
+ "tutti-smoke@example.invalid",
111
+ ]);
112
+ execFileSync("git", ["-C", workspaceRoot, "config", "user.name", "Tutti Smoke"]);
113
+ const common = {
114
+ apiKey: provider.apiKey,
115
+ ...(provider.apiBaseUrl === undefined ? {} : { apiBaseUrl: provider.apiBaseUrl }),
116
+ defaultModel: model,
117
+ workspaceRoot,
118
+ networkAccess: false,
119
+ strictLeakCheck: true,
120
+ };
121
+ const execute = await withSessionLock(session, async () => await runCodexAppServerWorkspaceWriteRun({
122
+ ...common,
123
+ approvalPolicy: "never",
124
+ prompt: `Create durable-smoke.txt in the workspace root with exactly one line: ${WRITE_SENTINEL}. ` +
125
+ "Do not modify any other file. Then briefly report completion.",
126
+ session: {
127
+ codexHome: session.codexHome,
128
+ onThreadReady: (threadId) => {
129
+ session = recordCodexTaskSessionThread(session, threadId);
130
+ },
131
+ },
132
+ }));
133
+ if (readFileSync(join(workspaceRoot, "durable-smoke.txt"), "utf8") !== `${WRITE_SENTINEL}\n`) {
134
+ throw new Error("Workspace-write turn did not create the exact durable smoke fixture.");
135
+ }
136
+ const threadId = session.metadata.thread_id;
137
+ if (threadId === undefined) {
138
+ throw new Error("Workspace-write turn did not persist a durable thread id.");
139
+ }
140
+ const finalize = await withSessionLock(session, async () => await runCodexAppServerReadOnlyProcedure({
141
+ ...common,
142
+ prompt: "Read durable-smoke.txt and return the supplied JSON shape. " +
143
+ `Use sentinel ${WRITE_SENTINEL} and set file_present to true.`,
144
+ outputSchema: RESULT_SCHEMA,
145
+ validateOutput: isResult,
146
+ session: {
147
+ codexHome: session.codexHome,
148
+ threadId,
149
+ archiveAfterTurn: () => true,
150
+ onThreadArchived: () => {
151
+ session = markCodexTaskSessionArchived(session);
152
+ },
153
+ },
154
+ }));
155
+ if (session.metadata.lifecycle !== "archived") {
156
+ throw new Error("Durable smoke thread was not archived after the read-only turn.");
157
+ }
158
+ const recovery = await withSessionLock(session, async () => await runCodexAppServerReadOnlyProcedure({
159
+ ...common,
160
+ prompt: "After resuming this archived thread, read durable-smoke.txt and return the supplied " +
161
+ `JSON shape with sentinel ${WRITE_SENTINEL} and file_present true.`,
162
+ outputSchema: RESULT_SCHEMA,
163
+ validateOutput: isResult,
164
+ session: {
165
+ codexHome: session.codexHome,
166
+ threadId,
167
+ unarchive: true,
168
+ onThreadReady: (threadId) => {
169
+ session = recordCodexTaskSessionThread(session, threadId);
170
+ },
171
+ archiveAfterTurn: () => true,
172
+ onThreadArchived: () => {
173
+ session = markCodexTaskSessionArchived(session);
174
+ },
175
+ },
176
+ }));
177
+ const authPath = join(session.codexHome, "auth.json");
178
+ const sessionIndexPath = join(tuttiHome, "projects", projectId, "codex-sessions", "task-index", `${taskId}.json`);
179
+ requireMode(session.root, 0o700);
180
+ requireMode(session.codexHome, 0o700);
181
+ requireMode(session.metadataPath, 0o600);
182
+ requireMode(sessionIndexPath, 0o600);
183
+ requireMode(authPath, 0o600);
184
+ const codexHomeEntries = readdirSync(session.codexHome);
185
+ const repoSessionPaths = readdirSync(workspaceRoot).filter((entry) => entry === ".codex" || entry === "codex-sessions" || entry === "auth.json");
186
+ if (repoSessionPaths.length > 0) {
187
+ throw new Error("Durable session state was written into the target repository.");
188
+ }
189
+ if (userCodexConfigurationFingerprint() !== userCodexBefore) {
190
+ throw new Error("The durable smoke changed user-level Codex configuration.");
191
+ }
192
+ if (execute.thread_id !== finalize.thread_id || finalize.thread_id !== recovery.thread_id) {
193
+ throw new Error("Durable smoke turns did not resume the same app-server thread.");
194
+ }
195
+ console.log(JSON.stringify({
196
+ provider_source: provider.source,
197
+ status: "passed",
198
+ codex_version: CODEX_APP_SERVER_VERSION,
199
+ model,
200
+ same_thread_across_processes: true,
201
+ per_turn_output_schema: true,
202
+ workspace_write_then_read_only: true,
203
+ archive_then_unarchive: true,
204
+ final_lifecycle: session.metadata.lifecycle,
205
+ private_session_root_mode: mode(session.root).toString(8),
206
+ private_codex_home_mode: mode(session.codexHome).toString(8),
207
+ private_metadata_mode: mode(session.metadataPath).toString(8),
208
+ private_auth_json_mode: mode(authPath).toString(8),
209
+ auth_json_in_private_home: true,
210
+ plugin_cache_in_private_home: existsSync(join(session.codexHome, ".tmp", "plugins")),
211
+ state_store_in_private_home: codexHomeEntries.some((entry) => /^state_\d+\.sqlite$/u.test(entry)),
212
+ shell_snapshots_in_private_home: existsSync(join(session.codexHome, "shell_snapshots")),
213
+ user_codex_configuration_unchanged: true,
214
+ repo_session_files: 0,
215
+ }, null, 2));
216
+ }
217
+ finally {
218
+ rmSync(smokeRoot, { recursive: true, force: true });
219
+ }
220
+ }
221
+ main().catch((error) => {
222
+ const message = error instanceof Error ? error.message : String(error);
223
+ console.error(JSON.stringify({
224
+ status: "failed",
225
+ message,
226
+ }, null, 2));
227
+ process.exitCode = 1;
228
+ });
229
+ //# sourceMappingURL=durable-session-smoke-cli.js.map
@@ -14,6 +14,15 @@ export type CodexAppServerReadOnlyProcedureInput<TOutput> = {
14
14
  prompt: string;
15
15
  outputSchema: unknown;
16
16
  validateOutput: (value: unknown) => value is TOutput;
17
+ session?: {
18
+ codexHome: string;
19
+ threadId?: string;
20
+ unarchive?: boolean;
21
+ onThreadReady?: (threadId: string) => void | Promise<void>;
22
+ archiveAfterTurn?: (output: TOutput) => boolean;
23
+ onThreadArchived?: () => void | Promise<void>;
24
+ onThreadArchiveFailure?: () => void | Promise<void>;
25
+ };
17
26
  networkAccess?: boolean;
18
27
  webSearchMode?: CodexAppServerWebSearchMode;
19
28
  skillSelectionName?: CodexAppServerSkillSelectionName;
@@ -34,6 +43,7 @@ export type CodexAppServerReadOnlyProcedureInput<TOutput> = {
34
43
  };
35
44
  export type CodexAppServerReadOnlyProcedureResult<TOutput> = {
36
45
  output: TOutput;
46
+ thread_id: string;
37
47
  model: string;
38
48
  turn_status: "completed";
39
49
  notification_count: number;
@@ -128,7 +128,7 @@ export async function runCodexAppServerReadOnlyProcedure(options) {
128
128
  }
129
129
  const model = options.defaultModel ?? DEFAULT_OPENAI_MODEL;
130
130
  const tempRoot = mkdtempSync(join(tmpdir(), "tutti-codex-readonly-procedure-"));
131
- const codexHome = join(tempRoot, "codex-home");
131
+ const codexHome = options.session?.codexHome ?? join(tempRoot, "codex-home");
132
132
  mkdirSync(codexHome, { recursive: true, mode: 0o700 });
133
133
  const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
134
134
  const turnTimeoutMs = options.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
@@ -210,21 +210,40 @@ export async function runCodexAppServerReadOnlyProcedure(options) {
210
210
  if (!isApiKeyAccount(loginResponse)) {
211
211
  throw new CodexAppServerReadOnlyProcedureError("auth_failed", "Codex app-server did not accept API key auth.");
212
212
  }
213
- const threadStartResponse = await client.request("thread/start", {
214
- cwd: appServerCwd,
215
- model,
216
- modelProvider,
217
- approvalPolicy: "never",
218
- sandbox: "read-only",
219
- config: buildReadOnlyThreadConfig(options),
220
- ephemeral: true,
221
- experimentalRawEvents: false,
222
- persistExtendedHistory: false,
223
- }, requestTimeoutMs);
224
- const threadId = readThreadId(threadStartResponse);
213
+ let threadId;
214
+ if (options.session?.threadId === undefined) {
215
+ const threadStartResponse = await client.request("thread/start", {
216
+ cwd: appServerCwd,
217
+ model,
218
+ modelProvider,
219
+ approvalPolicy: "never",
220
+ sandbox: "read-only",
221
+ config: buildReadOnlyThreadConfig(options),
222
+ ephemeral: options.session === undefined,
223
+ experimentalRawEvents: false,
224
+ persistExtendedHistory: false,
225
+ }, requestTimeoutMs);
226
+ threadId = readThreadId(threadStartResponse);
227
+ }
228
+ else {
229
+ if (options.session.unarchive === true) {
230
+ await client.request("thread/unarchive", { threadId: options.session.threadId }, requestTimeoutMs);
231
+ }
232
+ const threadResumeResponse = await client.request("thread/resume", {
233
+ threadId: options.session.threadId,
234
+ cwd: appServerCwd,
235
+ model,
236
+ modelProvider,
237
+ approvalPolicy: "never",
238
+ sandbox: "read-only",
239
+ config: buildReadOnlyThreadConfig(options),
240
+ }, requestTimeoutMs);
241
+ threadId = readThreadId(threadResumeResponse) ?? options.session.threadId;
242
+ }
225
243
  if (threadId === null) {
226
244
  throw new CodexAppServerReadOnlyProcedureError("thread_start_failed", "Codex app-server did not return a thread id.");
227
245
  }
246
+ await options.session?.onThreadReady?.(threadId);
228
247
  await client.request("turn/start", {
229
248
  threadId,
230
249
  input: buildCodexAppServerTurnInput({
@@ -308,8 +327,18 @@ export async function runCodexAppServerReadOnlyProcedure(options) {
308
327
  throw new CodexAppServerReadOnlyProcedureError("secret_leak_detected", "Codex app-server output contained a sensitive runtime value.");
309
328
  }
310
329
  const usage = extractCodexAppServerTokenUsage(client.notifications);
330
+ if (options.session?.archiveAfterTurn?.(parsed) === true) {
331
+ try {
332
+ await client.request("thread/archive", { threadId }, requestTimeoutMs);
333
+ await options.session.onThreadArchived?.();
334
+ }
335
+ catch {
336
+ await options.session.onThreadArchiveFailure?.();
337
+ }
338
+ }
311
339
  procedureResult = {
312
340
  output: parsed,
341
+ thread_id: threadId,
313
342
  model,
314
343
  turn_status: "completed",
315
344
  notification_count: client.notifications.length,
@@ -0,0 +1,74 @@
1
+ import type { ActivityRef, ProjectId, TaskId } from "@tutti/shared/ids";
2
+ export type CodexSessionLifecycle = "active" | "archived" | "incompatible";
3
+ export type CodexSessionKind = "formal_task_run" | "task_compile" | "project_context_bootstrap" | "context_sync";
4
+ export type CodexSessionMetadata = {
5
+ version: 1;
6
+ session_ref: string;
7
+ kind: CodexSessionKind;
8
+ owner_ref: string;
9
+ thread_id?: string;
10
+ codex_version: string;
11
+ model: string;
12
+ provider_fingerprint: string;
13
+ lifecycle: CodexSessionLifecycle;
14
+ created_at: string;
15
+ last_used_at: string;
16
+ };
17
+ export type CodexDurableSession = {
18
+ root: string;
19
+ codexHome: string;
20
+ metadataPath: string;
21
+ metadata: CodexSessionMetadata;
22
+ disposition: "created" | "resumed" | "replaced_incompatible";
23
+ };
24
+ export type CodexTaskSession = CodexDurableSession;
25
+ export type CodexTaskSessionCompatibility = {
26
+ codexVersion: string;
27
+ model: string;
28
+ providerFingerprint: string;
29
+ };
30
+ export declare function createProviderFingerprint(input: {
31
+ providerKind: "openai";
32
+ apiBaseUrl?: string;
33
+ }): string;
34
+ export declare function openCodexDurableSession(input: {
35
+ tuttiHome: string;
36
+ projectId: ProjectId;
37
+ kind: CodexSessionKind;
38
+ ownerRef: string;
39
+ compatibility: CodexTaskSessionCompatibility;
40
+ now?: Date;
41
+ }): CodexDurableSession;
42
+ export declare function openCodexTaskSession(input: {
43
+ tuttiHome: string;
44
+ projectId: ProjectId;
45
+ taskId: TaskId;
46
+ compatibility: CodexTaskSessionCompatibility;
47
+ now?: Date;
48
+ }): CodexTaskSession;
49
+ export type CodexSessionRecoveryEvidence = {
50
+ version: 1;
51
+ owner_activity_ref: ActivityRef;
52
+ updated_at: string;
53
+ truncated: boolean;
54
+ evidence: Record<string, unknown>;
55
+ };
56
+ export declare function writeCodexSessionRecoveryEvidence(input: {
57
+ session: CodexDurableSession;
58
+ ownerActivityRef: ActivityRef;
59
+ evidence: Record<string, unknown>;
60
+ now?: Date;
61
+ }): CodexSessionRecoveryEvidence;
62
+ export declare function readCodexSessionRecoveryEvidence(session: CodexDurableSession): CodexSessionRecoveryEvidence | null;
63
+ export declare function updateCodexTaskSession(session: CodexDurableSession, update: (metadata: CodexSessionMetadata) => CodexSessionMetadata): CodexDurableSession;
64
+ export declare function recordCodexTaskSessionThread(session: CodexDurableSession, threadId: string, now?: Date): CodexDurableSession;
65
+ export declare function markCodexTaskSessionArchived(session: CodexDurableSession, now?: Date): CodexDurableSession;
66
+ export type CodexSessionArchiveIncident = {
67
+ version: 1;
68
+ kind: "thread_archive_failed";
69
+ occurred_at: string;
70
+ };
71
+ export declare function recordCodexSessionArchiveFailure(session: CodexDurableSession, now?: Date): CodexSessionArchiveIncident;
72
+ export declare function readCodexSessionArchiveIncident(session: CodexDurableSession): CodexSessionArchiveIncident | null;
73
+ export declare function acquireCodexTaskSessionLock(session: CodexDurableSession): () => void;
74
+ //# sourceMappingURL=session-store.d.ts.map