@osolmaz/pi-workflows 0.1.0 → 0.3.0

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 (171) hide show
  1. package/README.md +107 -26
  2. package/dist/builtins/monitor.workflow.d.ts +71 -0
  3. package/dist/builtins/monitor.workflow.js +234 -0
  4. package/dist/builtins/monitor.workflow.js.map +1 -0
  5. package/dist/controllers/conditions.d.ts +6 -0
  6. package/dist/controllers/conditions.js +68 -0
  7. package/dist/controllers/conditions.js.map +1 -0
  8. package/dist/controllers/definition.d.ts +6 -0
  9. package/dist/controllers/definition.js +45 -0
  10. package/dist/controllers/definition.js.map +1 -0
  11. package/dist/controllers/effects.d.ts +14 -0
  12. package/dist/controllers/effects.js +104 -0
  13. package/dist/controllers/effects.js.map +1 -0
  14. package/dist/controllers/errors.d.ts +12 -0
  15. package/dist/controllers/errors.js +25 -0
  16. package/dist/controllers/errors.js.map +1 -0
  17. package/dist/controllers/index.d.ts +13 -0
  18. package/dist/controllers/index.js +13 -0
  19. package/dist/controllers/index.js.map +1 -0
  20. package/dist/controllers/json.d.ts +5 -0
  21. package/dist/controllers/json.js +57 -0
  22. package/dist/controllers/json.js.map +1 -0
  23. package/dist/controllers/loader.d.ts +23 -0
  24. package/dist/controllers/loader.js +74 -0
  25. package/dist/controllers/loader.js.map +1 -0
  26. package/dist/controllers/manager.d.ts +58 -0
  27. package/dist/controllers/manager.js +399 -0
  28. package/dist/controllers/manager.js.map +1 -0
  29. package/dist/controllers/results.d.ts +5 -0
  30. package/dist/controllers/results.js +32 -0
  31. package/dist/controllers/results.js.map +1 -0
  32. package/dist/controllers/sqlite.d.ts +212 -0
  33. package/dist/controllers/sqlite.js +1009 -0
  34. package/dist/controllers/sqlite.js.map +1 -0
  35. package/dist/controllers/store.d.ts +112 -0
  36. package/dist/controllers/store.js +32 -0
  37. package/dist/controllers/store.js.map +1 -0
  38. package/dist/controllers/types.d.ts +159 -0
  39. package/dist/controllers/types.js +2 -0
  40. package/dist/controllers/types.js.map +1 -0
  41. package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
  42. package/dist/controllers/workflow-engine-scheduler.js +93 -0
  43. package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
  44. package/dist/controllers/workflows.d.ts +27 -0
  45. package/dist/controllers/workflows.js +109 -0
  46. package/dist/controllers/workflows.js.map +1 -0
  47. package/dist/extension/controller-host.d.ts +47 -0
  48. package/dist/extension/controller-host.js +110 -0
  49. package/dist/extension/controller-host.js.map +1 -0
  50. package/dist/extension/executor.d.ts +14 -1
  51. package/dist/extension/executor.js +12 -2
  52. package/dist/extension/executor.js.map +1 -1
  53. package/dist/extension/index.d.ts +7 -0
  54. package/dist/extension/index.js +1118 -89
  55. package/dist/extension/index.js.map +1 -1
  56. package/dist/extension/recorder.d.ts +91 -0
  57. package/dist/extension/recorder.js +537 -0
  58. package/dist/extension/recorder.js.map +1 -0
  59. package/dist/extension/session-events.d.ts +134 -0
  60. package/dist/extension/session-events.js +60 -0
  61. package/dist/extension/session-events.js.map +1 -0
  62. package/dist/extension/widget.js +25 -24
  63. package/dist/extension/widget.js.map +1 -1
  64. package/dist/extension/workflow-tool.d.ts +28 -0
  65. package/dist/extension/workflow-tool.js +33 -0
  66. package/dist/extension/workflow-tool.js.map +1 -0
  67. package/dist/host/processes.d.ts +24 -0
  68. package/dist/host/processes.js +114 -0
  69. package/dist/host/processes.js.map +1 -0
  70. package/dist/host/rpc-bridge.d.ts +9 -0
  71. package/dist/host/rpc-bridge.js +39 -0
  72. package/dist/host/rpc-bridge.js.map +1 -0
  73. package/dist/host/rpc-executor.d.ts +38 -0
  74. package/dist/host/rpc-executor.js +254 -0
  75. package/dist/host/rpc-executor.js.map +1 -0
  76. package/dist/host/runner.d.ts +49 -0
  77. package/dist/host/runner.js +350 -0
  78. package/dist/host/runner.js.map +1 -0
  79. package/dist/render/canvas.d.ts +1 -1
  80. package/dist/render/canvas.js +5 -0
  81. package/dist/render/canvas.js.map +1 -1
  82. package/dist/render/graph-render.d.ts +5 -0
  83. package/dist/render/graph-render.js +211 -48
  84. package/dist/render/graph-render.js.map +1 -1
  85. package/dist/viewer/cli.d.ts +7 -3
  86. package/dist/viewer/cli.js +150 -19
  87. package/dist/viewer/cli.js.map +1 -1
  88. package/dist/viewer/render.js +19 -3
  89. package/dist/viewer/render.js.map +1 -1
  90. package/dist/viewer/session-reducer.d.ts +45 -0
  91. package/dist/viewer/session-reducer.js +266 -0
  92. package/dist/viewer/session-reducer.js.map +1 -0
  93. package/dist/workflows/artifacts.d.ts +40 -0
  94. package/dist/workflows/artifacts.js +155 -0
  95. package/dist/workflows/artifacts.js.map +1 -0
  96. package/dist/workflows/engine.d.ts +38 -0
  97. package/dist/workflows/engine.js +281 -20
  98. package/dist/workflows/engine.js.map +1 -1
  99. package/dist/workflows/errors.d.ts +23 -0
  100. package/dist/workflows/errors.js +38 -0
  101. package/dist/workflows/errors.js.map +1 -1
  102. package/dist/workflows/graph.js +0 -5
  103. package/dist/workflows/graph.js.map +1 -1
  104. package/dist/workflows/index.d.ts +3 -2
  105. package/dist/workflows/index.js +2 -1
  106. package/dist/workflows/index.js.map +1 -1
  107. package/dist/workflows/loader.d.ts +5 -3
  108. package/dist/workflows/loader.js +10 -1
  109. package/dist/workflows/loader.js.map +1 -1
  110. package/dist/workflows/schema.js +1 -1
  111. package/dist/workflows/schema.js.map +1 -1
  112. package/dist/workflows/store.d.ts +98 -10
  113. package/dist/workflows/store.js +921 -46
  114. package/dist/workflows/store.js.map +1 -1
  115. package/dist/workflows/types.d.ts +136 -3
  116. package/docs/CONTROLLERS.md +215 -0
  117. package/docs/development.md +51 -24
  118. package/docs/live-replay-protocol.md +155 -0
  119. package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
  120. package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
  121. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
  122. package/docs/plans/piw-viewer-experience-implementation-plan.md +674 -0
  123. package/docs/plans/replayable-run-bundles-implementation-plan.md +65 -0
  124. package/docs/plans/session-event-replay-implementation-plan.md +494 -0
  125. package/docs/plans/tui-viewer-implementation-plan.md +64 -0
  126. package/docs/run-bundles.md +359 -55
  127. package/docs/session-event-journal.md +470 -0
  128. package/docs/tui-viewer.md +218 -0
  129. package/docs/workflows.md +131 -11
  130. package/examples/controllers/pull-request.controller.ts +215 -0
  131. package/package.json +11 -2
  132. package/src/builtins/monitor.workflow.ts +278 -0
  133. package/src/controllers/conditions.ts +110 -0
  134. package/src/controllers/definition.ts +65 -0
  135. package/src/controllers/effects.ts +123 -0
  136. package/src/controllers/errors.ts +27 -0
  137. package/src/controllers/index.ts +90 -0
  138. package/src/controllers/json.ts +62 -0
  139. package/src/controllers/loader.ts +104 -0
  140. package/src/controllers/manager.ts +533 -0
  141. package/src/controllers/results.ts +46 -0
  142. package/src/controllers/sqlite.ts +1427 -0
  143. package/src/controllers/store.ts +160 -0
  144. package/src/controllers/types.ts +183 -0
  145. package/src/controllers/workflow-engine-scheduler.ts +145 -0
  146. package/src/controllers/workflows.ts +152 -0
  147. package/src/extension/controller-host.ts +163 -0
  148. package/src/extension/executor.ts +29 -2
  149. package/src/extension/index.ts +1315 -109
  150. package/src/extension/recorder.ts +662 -0
  151. package/src/extension/session-events.ts +119 -0
  152. package/src/extension/widget.ts +26 -24
  153. package/src/extension/workflow-tool.ts +59 -0
  154. package/src/host/processes.ts +119 -0
  155. package/src/host/rpc-bridge.ts +44 -0
  156. package/src/host/rpc-executor.ts +299 -0
  157. package/src/host/runner.ts +406 -0
  158. package/src/render/canvas.ts +19 -1
  159. package/src/render/graph-render.ts +277 -44
  160. package/src/viewer/cli.ts +167 -21
  161. package/src/viewer/render.ts +21 -3
  162. package/src/viewer/session-reducer.ts +347 -0
  163. package/src/workflows/artifacts.ts +188 -0
  164. package/src/workflows/engine.ts +365 -19
  165. package/src/workflows/errors.ts +45 -0
  166. package/src/workflows/graph.ts +0 -5
  167. package/src/workflows/index.ts +15 -0
  168. package/src/workflows/loader.ts +13 -3
  169. package/src/workflows/schema.ts +1 -1
  170. package/src/workflows/store.ts +1157 -48
  171. package/src/workflows/types.ts +151 -3
package/src/viewer/cli.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { realpathSync } from "node:fs";
2
+ import fs, { realpathSync } from "node:fs";
3
+ import path from "node:path";
3
4
  import { pathToFileURL } from "node:url";
5
+ import { SqliteControllerStore } from "../controllers/sqlite.js";
6
+ import { projectControllerStoreBaseDir } from "../controllers/store.js";
4
7
  import { sanitizeText } from "../render/ansi.js";
5
8
  import { listRunBundles, readRunBundle, workflowRunsBaseDir } from "../workflows/store.js";
6
9
  import {
@@ -12,54 +15,99 @@ import {
12
15
  } from "./render.js";
13
16
  import { runViewer } from "./tui.js";
14
17
 
15
- const USAGE = `pi-workflows — live terminal viewer for pi workflow runs
18
+ const USAGE = `pi-workflows — workflow runs and controller resources
16
19
 
17
20
  Usage:
18
21
  pi-workflows view [runId] [--dir <runsDir>] [--once]
19
22
  pi-workflows runs [--dir <runsDir>]
23
+ pi-workflows controllers [--controller-dir <dir>]
24
+ pi-workflows controller <controller> <key> [--controller-dir <dir>]
25
+ pi-workflows host [--project <dir>] [-- <extra pi args>]
20
26
 
21
27
  Commands:
22
- view Open the live TUI viewer. With --once, print a snapshot and exit.
23
- runs List recent workflow runs.
28
+ view Open the live workflow TUI. With --once, print a snapshot.
29
+ runs List recent workflow runs.
30
+ controllers List durable controller resources.
31
+ controller Show one resource, its effects, child workflows, and events.
32
+ host Run the always-on workflow host in the foreground.
24
33
 
25
34
  Options:
26
- --dir <runsDir> Runs directory (default: ~/.pi/agent/workflows/runs)
27
- --once Render once to stdout without the interactive TUI
35
+ --dir <runsDir> Runs directory (default: ~/.pi/agent/workflows/runs)
36
+ --controller-dir <dir> Controller directory (default: project-scoped local store)
37
+ --once Render once without the interactive TUI
38
+ --project <dir> Project directory for the host (default: cwd)
28
39
  `;
29
40
 
30
- type CliArgs = {
41
+ export type CliArgs = {
31
42
  command: string;
32
- runId?: string | undefined;
43
+ runId?: string;
44
+ controllerName?: string;
45
+ resourceKey?: string;
33
46
  dir: string;
47
+ controllerDir: string;
34
48
  once: boolean;
49
+ project?: string | undefined;
50
+ piArgs?: string[] | undefined;
35
51
  };
36
52
 
37
53
  export function parseCliArgs(argv: string[]): CliArgs {
38
54
  const args = [...argv];
39
55
  const command = args[0] && !args[0].startsWith("-") ? (args.shift() as string) : "view";
40
56
  let dir = workflowRunsBaseDir();
57
+ let controllerDir = projectControllerStoreBaseDir(process.cwd());
41
58
  let once = false;
42
- let runId: string | undefined;
59
+ const positionals: string[] = [];
60
+ let project: string | undefined;
61
+ const piArgs: string[] = [];
43
62
 
44
63
  while (args.length > 0) {
45
64
  const arg = args.shift() as string;
46
65
  if (arg === "--dir") {
47
- const value = args.shift();
48
- if (!value) {
49
- throw new Error("--dir requires a path");
50
- }
51
- dir = value;
66
+ dir = requiredValue(args, "--dir");
67
+ } else if (arg === "--controller-dir") {
68
+ controllerDir = requiredValue(args, "--controller-dir");
69
+ } else if (arg === "--project") {
70
+ project = requiredValue(args, "--project");
52
71
  } else if (arg === "--once") {
53
72
  once = true;
54
73
  } else if (arg === "--help" || arg === "-h") {
55
- return { command: "help", dir, once };
56
- } else if (!arg.startsWith("-") && runId === undefined) {
57
- runId = arg;
58
- } else {
74
+ return { command: "help", dir, controllerDir, once };
75
+ } else if (arg === "--") {
76
+ piArgs.push(...args.splice(0));
77
+ } else if (arg.startsWith("-")) {
59
78
  throw new Error(`Unknown argument: ${arg}`);
79
+ } else {
80
+ positionals.push(arg);
81
+ }
82
+ }
83
+
84
+ if (command === "host") {
85
+ return { command, dir, controllerDir, once, project, piArgs };
86
+ }
87
+
88
+ if (command === "controller") {
89
+ if (positionals.length !== 2) {
90
+ throw new Error("controller requires <controller> and <key>");
60
91
  }
92
+ return {
93
+ command,
94
+ controllerName: positionals[0] as string,
95
+ resourceKey: positionals[1] as string,
96
+ dir,
97
+ controllerDir,
98
+ once,
99
+ };
61
100
  }
62
- return { command, runId, dir, once };
101
+ if (positionals.length > 1) {
102
+ throw new Error(`Unexpected argument: ${positionals[1]}`);
103
+ }
104
+ return {
105
+ command,
106
+ ...(positionals[0] !== undefined ? { runId: positionals[0] } : {}),
107
+ dir,
108
+ controllerDir,
109
+ once,
110
+ };
63
111
  }
64
112
 
65
113
  async function printRuns(dir: string): Promise<void> {
@@ -70,7 +118,6 @@ async function printRuns(dir: string): Promise<void> {
70
118
  }
71
119
  for (const bundle of bundles) {
72
120
  const state = bundle.state;
73
- // Titles can interpolate untrusted run input; strip control sequences.
74
121
  const title = state.runTitle ? ` — ${sanitizeText(state.runTitle)}` : "";
75
122
  process.stdout.write(
76
123
  `${statusLabel(state.status)} ${sanitizeText(state.workflowName)}${title} ${state.runId} ${formatDuration(
@@ -98,6 +145,57 @@ async function printOnce(dir: string, runId: string | undefined): Promise<void>
98
145
  process.stdout.write(`${renderRunDetailLines(bundle, size).join("\n")}\n`);
99
146
  }
100
147
 
148
+ function printControllers(controllerDir: string): void {
149
+ const store = openControllerStore(controllerDir);
150
+ if (store === undefined) {
151
+ process.stdout.write(`No controller resources found in ${controllerDir}\n`);
152
+ return;
153
+ }
154
+ try {
155
+ const resources = store.listResources();
156
+ if (resources.length === 0) {
157
+ process.stdout.write(`No controller resources found in ${controllerDir}\n`);
158
+ return;
159
+ }
160
+ for (const resource of resources) {
161
+ const condition =
162
+ resource.status.conditions.find((item) => item.type === "Ready") ??
163
+ resource.status.conditions[0];
164
+ const conditionText =
165
+ condition === undefined
166
+ ? "unknown"
167
+ : `${String(condition.status)}:${sanitizeText(condition.reason)}`;
168
+ process.stdout.write(
169
+ `${sanitizeText(resource.metadata.controller)} ${sanitizeText(resource.metadata.key)} generation=${resource.metadata.generation} ready=${conditionText}\n`,
170
+ );
171
+ }
172
+ } finally {
173
+ store.close();
174
+ }
175
+ }
176
+
177
+ function printController(controllerDir: string, controller: string, key: string): void {
178
+ const store = openControllerStore(controllerDir);
179
+ if (store === undefined) {
180
+ throw new Error(`Controller store not found in ${controllerDir}`);
181
+ }
182
+ try {
183
+ const resource = store.getResource({ controller, key });
184
+ if (resource === undefined) {
185
+ throw new Error(`Controller resource not found: ${controller}/${key}`);
186
+ }
187
+ const value = {
188
+ resource,
189
+ effects: store.listEffects(resource.metadata.uid),
190
+ workflows: store.listWorkflows(resource.metadata.uid),
191
+ events: store.listEvents({ controller, key, limit: 50 }),
192
+ };
193
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
194
+ } finally {
195
+ store.close();
196
+ }
197
+ }
198
+
101
199
  export async function main(argv: string[] = process.argv.slice(2)): Promise<number> {
102
200
  let args: CliArgs;
103
201
  try {
@@ -116,6 +214,21 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
116
214
  await printRuns(args.dir);
117
215
  return 0;
118
216
  }
217
+ if (args.command === "controllers") {
218
+ printControllers(args.controllerDir);
219
+ return 0;
220
+ }
221
+ if (args.command === "controller") {
222
+ printController(
223
+ args.controllerDir,
224
+ args.controllerName as string,
225
+ args.resourceKey as string,
226
+ );
227
+ return 0;
228
+ }
229
+ if (args.command === "host") {
230
+ return await runHost(args.project ?? process.cwd(), args.piArgs);
231
+ }
119
232
  if (args.command === "view") {
120
233
  if (args.once || !process.stdout.isTTY) {
121
234
  await printOnce(args.dir, args.runId);
@@ -132,8 +245,41 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
132
245
  }
133
246
  }
134
247
 
248
+ async function runHost(project: string, piArgs: string[] | undefined): Promise<number> {
249
+ const { WorkflowHost } = await import("../host/runner.js");
250
+ const host = new WorkflowHost({
251
+ cwd: project,
252
+ piArgs: piArgs ?? [],
253
+ onLog: (message) => process.stdout.write(`[host] ${message}\n`),
254
+ });
255
+ await host.start();
256
+ await new Promise<void>((resolve) => {
257
+ const shutdown = () => {
258
+ void host.stop().then(() => resolve());
259
+ };
260
+ process.once("SIGTERM", shutdown);
261
+ process.once("SIGINT", shutdown);
262
+ });
263
+ return 0;
264
+ }
265
+
266
+ function openControllerStore(controllerDir: string): SqliteControllerStore | undefined {
267
+ const file = path.join(controllerDir, "controller.sqlite");
268
+ if (!fs.existsSync(file)) {
269
+ return undefined;
270
+ }
271
+ return new SqliteControllerStore(file, { readOnly: true });
272
+ }
273
+
274
+ function requiredValue(args: string[], option: string): string {
275
+ const value = args.shift();
276
+ if (!value) {
277
+ throw new Error(`${option} requires a path`);
278
+ }
279
+ return value;
280
+ }
281
+
135
282
  const entryPath = process.argv[1];
136
- // realpath so invocation through a bin symlink (npm link) still matches.
137
283
  const resolvedEntry = entryPath !== undefined ? realpathSyncSafe(entryPath) : undefined;
138
284
  if (resolvedEntry !== undefined && import.meta.url === pathToFileURL(resolvedEntry).href) {
139
285
  main().then((code) => {
@@ -1,11 +1,28 @@
1
1
  import { ansi, fitWidth, sanitizeText } from "../render/ansi.js";
2
2
  import { formatDuration, runElapsedMs } from "../render/format.js";
3
3
  import { renderGraphLines } from "../render/graph-render.js";
4
+ import { decodeValueWith } from "../workflows/artifacts.js";
4
5
  import type { LoadedRunBundle } from "../workflows/store.js";
5
6
  import type { WorkflowRunStatus, WorkflowStepRecord } from "../workflows/types.js";
6
7
 
7
8
  export { formatDuration, runElapsedMs };
8
9
 
10
+ /**
11
+ * Replace `$artifact` references with a compact placeholder for display. The
12
+ * terminal viewer shows summaries; full artifact contents are for replay
13
+ * tooling.
14
+ */
15
+ function withArtifactPlaceholders(value: unknown): unknown {
16
+ return decodeValueWith(value, (ref) => `«artifact ${formatBytes(ref.bytes)} ${ref.path}»`);
17
+ }
18
+
19
+ function formatBytes(bytes: number): string {
20
+ if (bytes < 1024) {
21
+ return `${bytes}B`;
22
+ }
23
+ return `${(bytes / 1024).toFixed(1)}KB`;
24
+ }
25
+
9
26
  export type ViewportSize = {
10
27
  width: number;
11
28
  height: number;
@@ -24,10 +41,11 @@ export function statusLabel(status: WorkflowRunStatus): string {
24
41
  return STATUS_COLORS[status](status);
25
42
  }
26
43
 
27
- function previewValue(value: unknown, maxLength: number): string {
28
- if (value === undefined) {
44
+ function previewValue(rawValue: unknown, maxLength: number): string {
45
+ if (rawValue === undefined) {
29
46
  return "";
30
47
  }
48
+ const value = withArtifactPlaceholders(rawValue);
31
49
  const text = typeof value === "string" ? value : JSON.stringify(value);
32
50
  // Model-controlled values must not carry escape sequences into the terminal.
33
51
  const singleLine = sanitizeText(text ?? "")
@@ -120,7 +138,7 @@ function nodeStatusLine(bundle: LoadedRunBundle, nodeId: string, width: number,
120
138
  /** Pretty-printed JSON body of the selected step for the inspector pane. */
121
139
  function inspectorLines(step: WorkflowStepRecord, width: number): string[] {
122
140
  const lines: string[] = [];
123
- const body = step.error !== undefined ? step.error : step.output;
141
+ const body = step.error !== undefined ? step.error : withArtifactPlaceholders(step.output);
124
142
  const rendered =
125
143
  typeof body === "string" && step.error !== undefined ? body : JSON.stringify(body, null, 2);
126
144
  for (const raw of (rendered ?? "null").split("\n")) {
@@ -0,0 +1,347 @@
1
+ import type { WorkflowSessionEntryRecord, WorkflowSessionEventRecord } from "../workflows/types.js";
2
+
3
+ export type TemporalContentBlock = {
4
+ contentIndex: number;
5
+ kind: "text" | "thinking" | "toolCall";
6
+ text: string;
7
+ value?: unknown;
8
+ };
9
+
10
+ export type TemporalMessage = {
11
+ messageId: string;
12
+ role: string;
13
+ status: "streaming" | "finished" | "error" | "settled" | "unsettled";
14
+ entryId?: string;
15
+ blocks: TemporalContentBlock[];
16
+ };
17
+
18
+ export type TemporalTool = {
19
+ toolCallId: string;
20
+ messageId: string;
21
+ toolName: string;
22
+ status: "running" | "finished" | "failed";
23
+ updates: number;
24
+ args?: unknown;
25
+ result?: unknown;
26
+ };
27
+
28
+ export type TemporalSessionState = {
29
+ throughSeq: number;
30
+ messages: TemporalMessage[];
31
+ tools: TemporalTool[];
32
+ settledEntryIds: string[];
33
+ diagnostics: string[];
34
+ };
35
+
36
+ type MutableMessage = Omit<TemporalMessage, "blocks"> & {
37
+ blocks: Map<number, TemporalContentBlock>;
38
+ };
39
+
40
+ function payloadString(payload: Record<string, unknown>, key: string): string | undefined {
41
+ const value = payload[key];
42
+ return typeof value === "string" ? value : undefined;
43
+ }
44
+
45
+ function payloadIndex(payload: Record<string, unknown>): number | undefined {
46
+ const value = payload.contentIndex;
47
+ return Number.isSafeInteger(value) && (value as number) >= 0 ? (value as number) : undefined;
48
+ }
49
+
50
+ function ensureBlock(
51
+ message: MutableMessage,
52
+ contentIndex: number,
53
+ kind: TemporalContentBlock["kind"],
54
+ ): TemporalContentBlock {
55
+ const existing = message.blocks.get(contentIndex);
56
+ if (existing) {
57
+ return existing;
58
+ }
59
+ const block: TemporalContentBlock = { contentIndex, kind, text: "" };
60
+ message.blocks.set(contentIndex, block);
61
+ return block;
62
+ }
63
+
64
+ function entryIds(entries: WorkflowSessionEntryRecord[]): Set<string> {
65
+ return new Set(
66
+ entries.flatMap((record) => {
67
+ const id = record.entry.id;
68
+ return typeof id === "string" ? [id] : [];
69
+ }),
70
+ );
71
+ }
72
+
73
+ function foldSessionEvents(
74
+ entries: WorkflowSessionEntryRecord[],
75
+ events: WorkflowSessionEventRecord[],
76
+ throughSeq: number,
77
+ initial?: TemporalSessionState,
78
+ ): TemporalSessionState {
79
+ const messages = new Map<string, MutableMessage>();
80
+ const messageOrder: string[] = [];
81
+ for (const message of initial?.messages ?? []) {
82
+ messages.set(message.messageId, {
83
+ ...message,
84
+ blocks: new Map(message.blocks.map((block) => [block.contentIndex, { ...block }] as const)),
85
+ });
86
+ messageOrder.push(message.messageId);
87
+ }
88
+ const tools = new Map<string, TemporalTool>();
89
+ const toolOrder: string[] = [];
90
+ for (const tool of initial?.tools ?? []) {
91
+ tools.set(tool.toolCallId, { ...tool });
92
+ toolOrder.push(tool.toolCallId);
93
+ }
94
+ const settledEntryIds: string[] = [...(initial?.settledEntryIds ?? [])];
95
+ const knownEntries = entryIds(entries);
96
+ const diagnostics: string[] = [...(initial?.diagnostics ?? [])];
97
+ let expectedSeq = (initial?.throughSeq ?? 0) + 1;
98
+ let lastSeq = initial?.throughSeq ?? 0;
99
+
100
+ for (const event of events) {
101
+ if (event.seq <= lastSeq) {
102
+ continue;
103
+ }
104
+ if (event.seq > throughSeq) {
105
+ break;
106
+ }
107
+ if (event.seq !== expectedSeq) {
108
+ diagnostics.push(`session event sequence gap at ${expectedSeq}`);
109
+ expectedSeq = event.seq;
110
+ }
111
+ expectedSeq += 1;
112
+ lastSeq = event.seq;
113
+
114
+ switch (event.type) {
115
+ case "message_started": {
116
+ if (!event.messageId) {
117
+ diagnostics.push(`message_started ${event.seq} has no messageId`);
118
+ break;
119
+ }
120
+ if (!messages.has(event.messageId)) {
121
+ messages.set(event.messageId, {
122
+ messageId: event.messageId,
123
+ role: payloadString(event.payload, "role") ?? "unknown",
124
+ status: "streaming",
125
+ blocks: new Map(),
126
+ });
127
+ messageOrder.push(event.messageId);
128
+ }
129
+ break;
130
+ }
131
+ case "assistant_event": {
132
+ if (!event.messageId) {
133
+ diagnostics.push(`assistant_event ${event.seq} has no messageId`);
134
+ break;
135
+ }
136
+ const message = messages.get(event.messageId);
137
+ if (!message) {
138
+ diagnostics.push(`assistant_event ${event.seq} precedes message_started`);
139
+ break;
140
+ }
141
+ const assistantType = payloadString(event.payload, "type");
142
+ const contentIndex = payloadIndex(event.payload);
143
+ if (
144
+ contentIndex !== undefined &&
145
+ (assistantType === "text_start" ||
146
+ assistantType === "thinking_start" ||
147
+ assistantType === "toolcall_start")
148
+ ) {
149
+ ensureBlock(
150
+ message,
151
+ contentIndex,
152
+ assistantType === "text_start"
153
+ ? "text"
154
+ : assistantType === "thinking_start"
155
+ ? "thinking"
156
+ : "toolCall",
157
+ );
158
+ } else if (
159
+ contentIndex !== undefined &&
160
+ (assistantType === "text_delta" ||
161
+ assistantType === "thinking_delta" ||
162
+ assistantType === "toolcall_delta")
163
+ ) {
164
+ const block = ensureBlock(
165
+ message,
166
+ contentIndex,
167
+ assistantType === "text_delta"
168
+ ? "text"
169
+ : assistantType === "thinking_delta"
170
+ ? "thinking"
171
+ : "toolCall",
172
+ );
173
+ block.text += payloadString(event.payload, "delta") ?? "";
174
+ } else if (
175
+ contentIndex !== undefined &&
176
+ (assistantType === "text_end" || assistantType === "thinking_end")
177
+ ) {
178
+ const block = ensureBlock(
179
+ message,
180
+ contentIndex,
181
+ assistantType === "text_end" ? "text" : "thinking",
182
+ );
183
+ const content = payloadString(event.payload, "content") ?? "";
184
+ if (block.text !== content) {
185
+ diagnostics.push(`${assistantType} mismatch for ${event.messageId}:${contentIndex}`);
186
+ block.text = content;
187
+ }
188
+ } else if (contentIndex !== undefined && assistantType === "toolcall_end") {
189
+ const block = ensureBlock(message, contentIndex, "toolCall");
190
+ block.value = event.payload.toolCall;
191
+ } else if (assistantType === "done") {
192
+ message.status = "finished";
193
+ } else if (assistantType === "error") {
194
+ message.status = "error";
195
+ }
196
+ break;
197
+ }
198
+ case "message_finished": {
199
+ if (!event.messageId) {
200
+ diagnostics.push(`message_finished ${event.seq} has no messageId`);
201
+ break;
202
+ }
203
+ const message = messages.get(event.messageId);
204
+ if (!message) {
205
+ diagnostics.push(`message_finished ${event.seq} precedes message_started`);
206
+ break;
207
+ }
208
+ const settled = event.payload.settled === true;
209
+ const entryId = payloadString(event.payload, "entryId");
210
+ if (settled && entryId) {
211
+ message.status = "settled";
212
+ message.entryId = entryId;
213
+ settledEntryIds.push(entryId);
214
+ if (!knownEntries.has(entryId)) {
215
+ diagnostics.push(`settled entry ${entryId} is missing`);
216
+ }
217
+ } else {
218
+ message.status = "unsettled";
219
+ }
220
+ break;
221
+ }
222
+ case "tool_execution_started": {
223
+ if (!event.toolCallId || !event.messageId) {
224
+ diagnostics.push(`tool_execution_started ${event.seq} is uncorrelated`);
225
+ break;
226
+ }
227
+ const tool: TemporalTool = {
228
+ toolCallId: event.toolCallId,
229
+ messageId: event.messageId,
230
+ toolName: payloadString(event.payload, "toolName") ?? "tool",
231
+ status: "running",
232
+ updates: 0,
233
+ ...(event.payload.args === undefined ? {} : { args: event.payload.args }),
234
+ };
235
+ tools.set(event.toolCallId, tool);
236
+ toolOrder.push(event.toolCallId);
237
+ break;
238
+ }
239
+ case "tool_execution_updated": {
240
+ const tool = event.toolCallId ? tools.get(event.toolCallId) : undefined;
241
+ if (tool) {
242
+ tool.updates += 1;
243
+ } else {
244
+ diagnostics.push(`tool_execution_updated ${event.seq} precedes start`);
245
+ }
246
+ break;
247
+ }
248
+ case "tool_execution_finished": {
249
+ const tool = event.toolCallId ? tools.get(event.toolCallId) : undefined;
250
+ if (!tool) {
251
+ diagnostics.push(`tool_execution_finished ${event.seq} precedes start`);
252
+ break;
253
+ }
254
+ tool.status = event.payload.isError === true ? "failed" : "finished";
255
+ if (event.payload.result !== undefined) {
256
+ tool.result = event.payload.result;
257
+ }
258
+ break;
259
+ }
260
+ case "turn_started":
261
+ case "turn_finished":
262
+ break;
263
+ default:
264
+ break;
265
+ }
266
+ }
267
+
268
+ return {
269
+ throughSeq: lastSeq,
270
+ messages: messageOrder.map((messageId) => {
271
+ const message = messages.get(messageId)!;
272
+ return {
273
+ messageId: message.messageId,
274
+ role: message.role,
275
+ status: message.status,
276
+ ...(message.entryId === undefined ? {} : { entryId: message.entryId }),
277
+ blocks: [...message.blocks.values()].toSorted(
278
+ (left, right) => left.contentIndex - right.contentIndex,
279
+ ),
280
+ };
281
+ }),
282
+ tools: toolOrder.map((toolCallId) => tools.get(toolCallId)!),
283
+ settledEntryIds,
284
+ diagnostics,
285
+ };
286
+ }
287
+
288
+ /** Fold the durable semantic journal through one sequence position. */
289
+ export function reduceSessionEvents(
290
+ entries: WorkflowSessionEntryRecord[],
291
+ events: WorkflowSessionEventRecord[],
292
+ throughSeq: number = Number.MAX_SAFE_INTEGER,
293
+ ): TemporalSessionState {
294
+ return foldSessionEvents(entries, events, throughSeq);
295
+ }
296
+
297
+ /**
298
+ * In-memory seek index. Checkpoints are viewer-only cache state and are never
299
+ * persisted into a run bundle.
300
+ */
301
+ export class SessionReplayIndex {
302
+ private readonly entries: WorkflowSessionEntryRecord[];
303
+ private readonly events: WorkflowSessionEventRecord[];
304
+ private readonly checkpoints: Array<{ seq: number; state: TemporalSessionState }> = [];
305
+ private readonly timestamps: Array<{ at: number; seq: number }>;
306
+
307
+ constructor(
308
+ entries: WorkflowSessionEntryRecord[],
309
+ events: WorkflowSessionEventRecord[],
310
+ checkpointInterval = 256,
311
+ ) {
312
+ this.entries = entries;
313
+ this.events = events;
314
+ let lastTimestamp = Number.NEGATIVE_INFINITY;
315
+ this.timestamps = events.map((event) => {
316
+ const parsed = Date.parse(event.at);
317
+ lastTimestamp = Math.max(lastTimestamp, Number.isFinite(parsed) ? parsed : lastTimestamp);
318
+ return { at: lastTimestamp, seq: event.seq };
319
+ });
320
+ let state = foldSessionEvents(entries, events, 0);
321
+ this.checkpoints.push({ seq: 0, state });
322
+ for (let end = checkpointInterval - 1; end < events.length; end += checkpointInterval) {
323
+ const seq = events[end]?.seq ?? state.throughSeq;
324
+ state = foldSessionEvents(entries, events, seq, state);
325
+ this.checkpoints.push({ seq, state });
326
+ }
327
+ }
328
+
329
+ stateAtSeq(throughSeq: number): TemporalSessionState {
330
+ const checkpoint = this.checkpoints.findLast(({ seq }) => seq <= throughSeq);
331
+ return foldSessionEvents(this.entries, this.events, throughSeq, checkpoint?.state);
332
+ }
333
+
334
+ seqAtOrBefore(timestampMs: number): number {
335
+ let low = 0;
336
+ let high = this.timestamps.length;
337
+ while (low < high) {
338
+ const middle = (low + high) >>> 1;
339
+ if ((this.timestamps[middle]?.at ?? Number.POSITIVE_INFINITY) <= timestampMs) {
340
+ low = middle + 1;
341
+ } else {
342
+ high = middle;
343
+ }
344
+ }
345
+ return low === 0 ? 0 : (this.timestamps[low - 1]?.seq ?? 0);
346
+ }
347
+ }