@osolmaz/pi-workflows 0.15.3 → 0.16.1

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 (207) hide show
  1. package/README.md +24 -8
  2. package/dist/channels/adapter-entry.d.ts +3 -0
  3. package/dist/channels/adapter-entry.js +203 -0
  4. package/dist/channels/adapter-entry.js.map +1 -0
  5. package/dist/channels/config.d.ts +43 -0
  6. package/dist/channels/config.js +234 -0
  7. package/dist/channels/config.js.map +1 -0
  8. package/dist/channels/protocol.d.ts +94 -0
  9. package/dist/channels/protocol.js +219 -0
  10. package/dist/channels/protocol.js.map +1 -0
  11. package/dist/channels/telegram.d.ts +46 -0
  12. package/dist/channels/telegram.js +285 -0
  13. package/dist/channels/telegram.js.map +1 -0
  14. package/dist/client/activity.d.ts +2 -0
  15. package/dist/client/activity.js +6 -0
  16. package/dist/client/activity.js.map +1 -0
  17. package/dist/client/client.d.ts +102 -0
  18. package/dist/client/client.js +737 -0
  19. package/dist/client/client.js.map +1 -0
  20. package/dist/client/index.d.ts +3 -0
  21. package/dist/client/index.js +3 -0
  22. package/dist/client/index.js.map +1 -0
  23. package/dist/client/materialize.d.ts +7 -0
  24. package/dist/client/materialize.js +177 -0
  25. package/dist/client/materialize.js.map +1 -0
  26. package/dist/client/protocol.d.ts +60 -0
  27. package/dist/client/protocol.js +275 -0
  28. package/dist/client/protocol.js.map +1 -0
  29. package/dist/client/resolver.d.ts +23 -0
  30. package/dist/client/resolver.js +2 -0
  31. package/dist/client/resolver.js.map +1 -0
  32. package/dist/client/view.d.ts +144 -0
  33. package/dist/client/view.js +3 -0
  34. package/dist/client/view.js.map +1 -0
  35. package/dist/controllers/index.d.ts +1 -1
  36. package/dist/controllers/index.js.map +1 -1
  37. package/dist/controllers/sqlite.d.ts +47 -157
  38. package/dist/controllers/sqlite.js +230 -406
  39. package/dist/controllers/sqlite.js.map +1 -1
  40. package/dist/extension/index.d.ts +23 -1
  41. package/dist/extension/index.js +734 -555
  42. package/dist/extension/index.js.map +1 -1
  43. package/dist/extension/recorder.d.ts +95 -0
  44. package/dist/extension/recorder.js +530 -0
  45. package/dist/extension/recorder.js.map +1 -0
  46. package/dist/extension/remote-recorder-store.d.ts +25 -0
  47. package/dist/extension/remote-recorder-store.js +81 -0
  48. package/dist/extension/remote-recorder-store.js.map +1 -0
  49. package/dist/extension/session-events.d.ts +134 -0
  50. package/dist/extension/session-events.js +60 -0
  51. package/dist/extension/session-events.js.map +1 -0
  52. package/dist/extension/session-view.d.ts +9 -2
  53. package/dist/extension/session-view.js +117 -51
  54. package/dist/extension/session-view.js.map +1 -1
  55. package/dist/extension/step-message.d.ts +6 -4
  56. package/dist/extension/step-message.js +12 -6
  57. package/dist/extension/step-message.js.map +1 -1
  58. package/dist/extension/widget.d.ts +3 -2
  59. package/dist/extension/widget.js +41 -24
  60. package/dist/extension/widget.js.map +1 -1
  61. package/dist/extension/workflow-message-coordinator.d.ts +27 -0
  62. package/dist/extension/workflow-message-coordinator.js +294 -0
  63. package/dist/extension/workflow-message-coordinator.js.map +1 -0
  64. package/dist/host/channel-effects.d.ts +72 -0
  65. package/dist/host/channel-effects.js +271 -0
  66. package/dist/host/channel-effects.js.map +1 -0
  67. package/dist/host/channel-supervisor.d.ts +21 -0
  68. package/dist/host/channel-supervisor.js +54 -0
  69. package/dist/host/channel-supervisor.js.map +1 -0
  70. package/dist/host/child-worker-supervisor.js +1 -1
  71. package/dist/host/child-worker-supervisor.js.map +1 -1
  72. package/dist/host/resolver-entry.d.ts +2 -23
  73. package/dist/host/resolver-entry.js +1 -1
  74. package/dist/host/resolver-entry.js.map +1 -1
  75. package/dist/host/runner.d.ts +54 -8
  76. package/dist/host/runner.js +1997 -305
  77. package/dist/host/runner.js.map +1 -1
  78. package/dist/host/state.d.ts +25 -19
  79. package/dist/host/state.js +303 -89
  80. package/dist/host/state.js.map +1 -1
  81. package/dist/host/view.d.ts +83 -0
  82. package/dist/host/view.js +975 -0
  83. package/dist/host/view.js.map +1 -0
  84. package/dist/host/worker-protocol.js +1 -1
  85. package/dist/host/worker-protocol.js.map +1 -1
  86. package/dist/state/database.d.ts +1 -0
  87. package/dist/state/database.js +15 -0
  88. package/dist/state/database.js.map +1 -1
  89. package/dist/state/prune.d.ts +3 -1
  90. package/dist/state/prune.js +9 -33
  91. package/dist/state/prune.js.map +1 -1
  92. package/dist/state/schema.js +88 -98
  93. package/dist/state/schema.js.map +1 -1
  94. package/dist/state/workflow-messages.d.ts +96 -0
  95. package/dist/state/workflow-messages.js +335 -0
  96. package/dist/state/workflow-messages.js.map +1 -0
  97. package/dist/viewer/backup.d.ts +2 -0
  98. package/dist/viewer/backup.js +28 -0
  99. package/dist/viewer/backup.js.map +1 -0
  100. package/dist/viewer/cli.d.ts +4 -0
  101. package/dist/viewer/cli.js +150 -170
  102. package/dist/viewer/cli.js.map +1 -1
  103. package/dist/viewer/render.js +1 -1
  104. package/dist/viewer/render.js.map +1 -1
  105. package/dist/viewer/tui.d.ts +5 -7
  106. package/dist/viewer/tui.js +245 -108
  107. package/dist/viewer/tui.js.map +1 -1
  108. package/dist/workflows/human-decision.d.ts +2 -0
  109. package/dist/workflows/human-decision.js +8 -2
  110. package/dist/workflows/human-decision.js.map +1 -1
  111. package/dist/workflows/index.d.ts +1 -1
  112. package/dist/workflows/index.js.map +1 -1
  113. package/dist/workflows/settings.d.ts +1 -8
  114. package/dist/workflows/settings.js.map +1 -1
  115. package/dist/workflows/store.d.ts +67 -20
  116. package/dist/workflows/store.js +451 -378
  117. package/dist/workflows/store.js.map +1 -1
  118. package/dist/workflows/tool-input.d.ts +21 -0
  119. package/dist/workflows/tool-input.js +23 -1
  120. package/dist/workflows/tool-input.js.map +1 -1
  121. package/dist/workflows/types.d.ts +5 -5
  122. package/dist/workflows/workflow-message-content.d.ts +38 -0
  123. package/dist/workflows/workflow-message-content.js +157 -0
  124. package/dist/workflows/workflow-message-content.js.map +1 -0
  125. package/docs/2026-08-18-herdr-piw-plan.md +2 -1
  126. package/docs/2026-08-20-durable-workflow-launch-plan.md +1 -1
  127. package/docs/2026-08-25-workflow-follow-ups.md +26 -50
  128. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +3 -3
  129. package/docs/2026-09-01-restore-session-delivery-controls-plan.md +4 -0
  130. package/docs/2026-09-01-unified-workflow-client-plan.md +385 -0
  131. package/docs/2026-09-02-installed-live-e2e-plan.md +239 -0
  132. package/docs/2026-09-02-unify-workflow-messages-plan.md +562 -0
  133. package/docs/DEFERRED_TURNS.md +66 -294
  134. package/docs/HUMAN_DECISIONS.md +29 -30
  135. package/docs/HUMAN_DECISION_PRESENTATIONS.md +4 -11
  136. package/docs/SQLITE_STATE.md +31 -25
  137. package/docs/WORKFLOW_HOST.md +155 -77
  138. package/docs/WORKFLOW_STEP_MESSAGES.md +166 -75
  139. package/docs/development.md +2 -1
  140. package/docs/live-replay-protocol.md +74 -132
  141. package/docs/tui-viewer.md +12 -14
  142. package/docs/workflows.md +78 -28
  143. package/herdr-plugin.toml +1 -1
  144. package/package.json +9 -3
  145. package/plugins/herdr/viewer.mjs +14 -5
  146. package/protocol/client.v1.schema.json +137 -0
  147. package/protocol/fixtures/client-v1.json +23 -0
  148. package/src/channels/adapter-entry.ts +220 -0
  149. package/src/channels/config.ts +296 -0
  150. package/src/channels/protocol.ts +333 -0
  151. package/src/channels/telegram.ts +335 -0
  152. package/src/client/activity.ts +6 -0
  153. package/src/client/client.ts +939 -0
  154. package/src/client/index.ts +25 -0
  155. package/src/client/materialize.ts +228 -0
  156. package/src/client/protocol.ts +333 -0
  157. package/src/client/resolver.ts +26 -0
  158. package/src/client/view.ts +163 -0
  159. package/src/controllers/index.ts +0 -1
  160. package/src/controllers/sqlite.ts +339 -808
  161. package/src/extension/index.ts +853 -665
  162. package/src/extension/recorder.ts +687 -0
  163. package/src/extension/remote-recorder-store.ts +126 -0
  164. package/src/extension/session-events.ts +119 -0
  165. package/src/extension/session-view.ts +157 -51
  166. package/src/extension/step-message.ts +19 -9
  167. package/src/extension/widget.ts +53 -25
  168. package/src/extension/workflow-message-coordinator.ts +334 -0
  169. package/src/host/channel-effects.ts +465 -0
  170. package/src/host/channel-supervisor.ts +73 -0
  171. package/src/host/child-worker-supervisor.ts +1 -1
  172. package/src/host/resolver-entry.ts +11 -26
  173. package/src/host/runner.ts +2639 -450
  174. package/src/host/state.ts +404 -137
  175. package/src/host/view.ts +1212 -0
  176. package/src/host/worker-protocol.ts +1 -1
  177. package/src/state/database.ts +13 -0
  178. package/src/state/prune.ts +14 -42
  179. package/src/state/schema.ts +88 -98
  180. package/src/state/workflow-messages.ts +548 -0
  181. package/src/viewer/backup.ts +29 -0
  182. package/src/viewer/cli.ts +171 -185
  183. package/src/viewer/render.ts +1 -5
  184. package/src/viewer/tui.ts +261 -124
  185. package/src/workflows/human-decision.ts +18 -2
  186. package/src/workflows/index.ts +0 -1
  187. package/src/workflows/settings.ts +1 -20
  188. package/src/workflows/store.ts +650 -511
  189. package/src/workflows/tool-input.ts +36 -1
  190. package/src/workflows/types.ts +5 -5
  191. package/src/workflows/workflow-message-content.ts +197 -0
  192. package/dist/extension/decision-channels.d.ts +0 -134
  193. package/dist/extension/decision-channels.js +0 -1307
  194. package/dist/extension/decision-channels.js.map +0 -1
  195. package/dist/host/client.d.ts +0 -48
  196. package/dist/host/client.js +0 -216
  197. package/dist/host/client.js.map +0 -1
  198. package/dist/host/protocol.d.ts +0 -38
  199. package/dist/host/protocol.js +0 -156
  200. package/dist/host/protocol.js.map +0 -1
  201. package/dist/viewer/watch.d.ts +0 -6
  202. package/dist/viewer/watch.js +0 -46
  203. package/dist/viewer/watch.js.map +0 -1
  204. package/src/extension/decision-channels.ts +0 -1826
  205. package/src/host/client.ts +0 -293
  206. package/src/host/protocol.ts +0 -196
  207. package/src/viewer/watch.ts +0 -51
@@ -0,0 +1,1212 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ RUN_VIEW_SCHEMA,
4
+ SESSION_VIEW_SCHEMA,
5
+ type WorkflowDisplay,
6
+ type WorkflowDisplayStatus,
7
+ type WorkflowRunListPage,
8
+ type WorkflowRunQueueView,
9
+ type WorkflowRunSummary,
10
+ type WorkflowRunView,
11
+ type WorkflowSessionView,
12
+ } from "../client/view.js";
13
+ import type {
14
+ SqliteControllerStore,
15
+ WorkflowRunQueueRecord,
16
+ WorkflowRunQueueViewRecord,
17
+ } from "../controllers/sqlite.js";
18
+ import type { StateDatabase } from "../state/database.js";
19
+ import { canonicalJson, parseJson, type JsonValue } from "../state/json.js";
20
+ import { WorkflowMessageStore, type WorkflowMessage } from "../state/workflow-messages.js";
21
+ import type { WorkflowRunDisplayState, WorkflowRunStore } from "../workflows/store.js";
22
+ import type {
23
+ WorkflowRunState,
24
+ WorkflowSessionEntryRecord,
25
+ WorkflowSessionEventRecord,
26
+ WorkflowStepRecord,
27
+ WorkflowTraceEvent,
28
+ WorkflowUpdateRecord,
29
+ } from "../workflows/types.js";
30
+ import type { HostStateStore } from "./state.js";
31
+
32
+ export const WORKFLOW_PAGE_KINDS = [
33
+ "steps",
34
+ "trace",
35
+ "trace_at_step",
36
+ "session_entries",
37
+ "session_events",
38
+ "settings",
39
+ "follow_ups",
40
+ "updates",
41
+ ] as const;
42
+
43
+ export type WorkflowPageKind = (typeof WORKFLOW_PAGE_KINDS)[number];
44
+
45
+ type RunPageRequest = {
46
+ kind: WorkflowPageKind;
47
+ cursor: number;
48
+ };
49
+
50
+ type ContentRecord = {
51
+ runId: string;
52
+ path: string;
53
+ mediaType: "application/json" | "text/plain";
54
+ bytes: Buffer;
55
+ sha256: string;
56
+ };
57
+
58
+ const INLINE_CONTENT_BYTES = 16 * 1024;
59
+ const VIEW_PAGE_BYTES = 64 * 1024;
60
+ const VIEW_PAGE_ITEMS = 256;
61
+ const CONTENT_CHUNK_BYTES = 192 * 1024;
62
+ const CONTENT_CACHE_BYTES = 64 * 1024 * 1024;
63
+ const VIEW_CACHE_ITEMS = 64;
64
+ const TERMINAL_VIEW_RETENTION_MS = 60_000;
65
+
66
+ export class HostViewStore {
67
+ private readonly contentRecords = new Map<string, ContentRecord>();
68
+ private readonly listCache = new Map<string, { revision: string; page: WorkflowRunListPage }>();
69
+ private readonly runCache = new Map<string, { version: string; view: WorkflowRunView | null }>();
70
+ private readonly sessionCache = new Map<string, { version: string; view: WorkflowSessionView }>();
71
+ private contentBytes = 0;
72
+ private originActivityRevision = 0;
73
+ private readonly workflowMessages: WorkflowMessageStore;
74
+
75
+ constructor(
76
+ private readonly state: StateDatabase,
77
+ private readonly queue: SqliteControllerStore,
78
+ private readonly hostState: HostStateStore,
79
+ private readonly runs: WorkflowRunStore,
80
+ private readonly hasLiveWorker: (runId: string) => boolean,
81
+ private readonly hasActiveSessionTurn: (targetSessionId: string) => boolean,
82
+ ) {
83
+ this.workflowMessages = hostState.workflowMessages;
84
+ }
85
+
86
+ noteOriginActivityChange(): void {
87
+ this.originActivityRevision += 1;
88
+ }
89
+
90
+ list(cursor = 0, limit?: number): WorkflowRunListPage {
91
+ return this.state.readTransaction(() => {
92
+ const pageSize = Math.min(limit ?? VIEW_PAGE_ITEMS, VIEW_PAGE_ITEMS);
93
+ const current = this.queue.workflowRunListRevision();
94
+ const revision = `${current.revision}:${this.workflowActivityRevision()}`;
95
+ const cacheKey = `${cursor}:${pageSize}`;
96
+ const cached = this.listCache.get(cacheKey);
97
+ if (cached?.revision === revision) {
98
+ refreshCacheEntry(this.listCache, cacheKey, cached);
99
+ return cached.page;
100
+ }
101
+ const loaded = this.queue.listWorkflowRunViews({ offset: cursor, limit: pageSize });
102
+ const summaries = loaded.runs.map((run) => {
103
+ const display = this.projectDisplay(
104
+ run.runId,
105
+ this.display(run, {
106
+ status: run.runStateStatus as WorkflowRunState["status"],
107
+ paused: run.paused,
108
+ error: run.errorMessage,
109
+ }),
110
+ );
111
+ return {
112
+ runId: run.runId,
113
+ workflowName: run.workflowName,
114
+ originSessionId: run.originSessionId,
115
+ createdAt: run.createdAt,
116
+ updatedAt: run.updatedAt,
117
+ display,
118
+ manifest: manifest(run, display.status),
119
+ live: display.status === "running" || display.status === "waiting",
120
+ possiblyInterrupted: run.status === "parked" && display.status !== "paused",
121
+ } satisfies WorkflowRunSummary;
122
+ });
123
+ const items = byteBoundedForwardPage(summaries, (summary) => toJson(summary)).map(
124
+ (item) => item as WorkflowRunSummary,
125
+ );
126
+ const page: WorkflowRunListPage = {
127
+ schema: "pi-workflows.run-list-page.v1",
128
+ revision,
129
+ start: cursor,
130
+ total: loaded.total,
131
+ items,
132
+ };
133
+ rememberCacheEntry(this.listCache, cacheKey, { revision, page });
134
+ return page;
135
+ });
136
+ }
137
+
138
+ run(runId: string): WorkflowRunView | null {
139
+ return this.state.readTransaction(() => {
140
+ const version = this.runVersion(runId);
141
+ const cached = this.runCache.get(runId);
142
+ if (cached?.version === version) {
143
+ refreshCacheEntry(this.runCache, runId, cached);
144
+ return cached.view;
145
+ }
146
+ const view = this.readRun(runId);
147
+ rememberCacheEntry(this.runCache, runId, { version, view });
148
+ return view;
149
+ });
150
+ }
151
+
152
+ page(runId: string, request: RunPageRequest): WorkflowRunView | null {
153
+ return this.state.readTransaction(() => this.readRun(runId, request));
154
+ }
155
+
156
+ private readRun(runId: string, page?: RunPageRequest): WorkflowRunView | null {
157
+ const queue = this.queue.getWorkflowRunView(runId);
158
+ const counts = this.runs.readRunViewCounts(runId);
159
+ if (queue === undefined || counts === null) return null;
160
+ const graphCursor =
161
+ page?.kind === "steps"
162
+ ? clampCursor(page.cursor, counts.steps)
163
+ : Math.max(0, counts.steps - 1);
164
+ const traceCursor =
165
+ page?.kind === "trace_at_step"
166
+ ? this.runs.traceCursorForStep(runId, page.cursor, counts.trace)
167
+ : page?.kind === "trace"
168
+ ? page.cursor
169
+ : undefined;
170
+ const stepRange = viewRange(counts.steps, page?.kind === "steps" ? page.cursor : undefined);
171
+ const traceRange = viewRange(counts.trace, traceCursor);
172
+ const entryRange = viewRange(
173
+ counts.sessionEntries,
174
+ page?.kind === "session_entries" ? page.cursor : undefined,
175
+ );
176
+ const eventRange = viewRange(
177
+ counts.sessionEvents,
178
+ page?.kind === "session_events" ? page.cursor : undefined,
179
+ );
180
+ const settingsRange = viewRange(
181
+ counts.settings,
182
+ page?.kind === "settings" ? page.cursor : undefined,
183
+ );
184
+ const followUpRange = viewRange(
185
+ counts.followUps,
186
+ page?.kind === "follow_ups" ? page.cursor : undefined,
187
+ );
188
+ const updateRange = viewRange(
189
+ counts.updates,
190
+ page?.kind === "updates" ? page.cursor : undefined,
191
+ );
192
+ const loaded = this.runs.readRunView(runId, {
193
+ steps: stepRange,
194
+ trace: traceRange,
195
+ sessionEntries: entryRange,
196
+ sessionEvents: eventRange,
197
+ settings: settingsRange,
198
+ followUps: followUpRange,
199
+ updates: updateRange,
200
+ graphCursor,
201
+ });
202
+ if (loaded === null) return null;
203
+ const stepPage = byteBoundedCandidatePage(
204
+ loaded.state.steps,
205
+ stepRange.start,
206
+ counts.steps,
207
+ page?.kind === "steps" ? page.cursor : undefined,
208
+ (step) => this.projectStep(runId, step),
209
+ );
210
+ const tracePage = byteBoundedCandidatePage(
211
+ loaded.traceEvents,
212
+ traceRange.start,
213
+ counts.trace,
214
+ traceCursor,
215
+ (event) => this.projectTraceEvent(runId, event),
216
+ );
217
+ const entryPage = byteBoundedCandidatePage(
218
+ loaded.sessionEntries,
219
+ entryRange.start,
220
+ counts.sessionEntries,
221
+ page?.kind === "session_entries" ? page.cursor : undefined,
222
+ (entry) => this.projectSessionEntry(runId, entry),
223
+ );
224
+ const eventPage = byteBoundedCandidatePage(
225
+ loaded.sessionEvents,
226
+ eventRange.start,
227
+ counts.sessionEvents,
228
+ page?.kind === "session_events" ? page.cursor : undefined,
229
+ (event) => this.projectSessionEvent(runId, event),
230
+ );
231
+ const settingsPage = byteBoundedCandidatePage(
232
+ loaded.settingsScopes,
233
+ settingsRange.start,
234
+ counts.settings,
235
+ page?.kind === "settings" ? page.cursor : undefined,
236
+ (scope) => this.projectRecordField(runId, scope, "settings"),
237
+ );
238
+ const followUps = loaded.followUpQueue?.followUps ?? [];
239
+ const followUpPage = byteBoundedCandidatePage(
240
+ followUps,
241
+ followUpRange.start,
242
+ counts.followUps,
243
+ page?.kind === "follow_ups" ? page.cursor : undefined,
244
+ (followUp) => this.projectRecordField(runId, followUp, "prompt"),
245
+ );
246
+ const updates = loaded.state.updates ?? [];
247
+ const updatePage = byteBoundedCandidatePage(
248
+ updates,
249
+ updateRange.start,
250
+ counts.updates,
251
+ page?.kind === "updates" ? page.cursor : undefined,
252
+ (update) => this.projectUpdate(runId, update),
253
+ );
254
+ const followUpQueue =
255
+ loaded.followUpQueue === null
256
+ ? null
257
+ : projectFollowUpQueue(loaded.followUpQueue, followUpPage.items);
258
+ const completeGraphSteps = loaded.graphSteps.map((step) => toCompactStepJson(step));
259
+ const completeTakenTransitions = loaded.takenTransitions.map((transition) =>
260
+ toJson(transition),
261
+ );
262
+ const graphSteps = byteBoundedForwardPage(completeGraphSteps, (step) => step);
263
+ const takenTransitions = byteBoundedForwardPage(
264
+ completeTakenTransitions,
265
+ (transition) => transition,
266
+ ).filter((transition): transition is string => typeof transition === "string");
267
+ const graphHistory = this.projectValue(runId, {
268
+ steps: completeGraphSteps,
269
+ transitions: completeTakenTransitions,
270
+ });
271
+ const revision = this.presentationRevision(runId);
272
+ const display = this.projectDisplay(runId, this.display(queue, loaded.state));
273
+ return {
274
+ schema: RUN_VIEW_SCHEMA,
275
+ runId,
276
+ revision,
277
+ display,
278
+ manifest: manifest(queue, display.status),
279
+ state: this.projectState(runId, loaded.state, stepPage.items, updatePage.items),
280
+ workflow: this.projectWorkflow(runId, loaded.snapshot),
281
+ queue: projectQueue(queue),
282
+ updates: updatePage.items,
283
+ graphSteps,
284
+ graphStepStart: 0,
285
+ graphStepTotal: loaded.graphSteps.length,
286
+ takenTransitions,
287
+ graphHistory,
288
+ takenTransitionStart: 0,
289
+ takenTransitionTotal: loaded.takenTransitions.length,
290
+ graphCursor,
291
+ stepStart: stepPage.start,
292
+ stepTotal: stepPage.total,
293
+ tracePage,
294
+ session: {
295
+ binding: toJson(loaded.sessionBinding),
296
+ entryPage,
297
+ eventPage,
298
+ capture: toJson(loaded.sessionCapture),
299
+ integrity: toJson(loaded.sessionIntegrity),
300
+ replayCheckpoint: this.projectReplayCheckpoint(
301
+ runId,
302
+ this.runs.readSessionReplayCheckpoint(runId, eventPage.start),
303
+ ),
304
+ },
305
+ settingsScopes: settingsPage.items,
306
+ settingsStart: settingsPage.start,
307
+ settingsTotal: settingsPage.total,
308
+ followUpQueue: toJson(followUpQueue),
309
+ followUpStart: followUpPage.start,
310
+ followUpTotal: followUpPage.total,
311
+ updateStart: updatePage.start,
312
+ updateTotal: updatePage.total,
313
+ live: display.status === "running" || display.status === "waiting",
314
+ possiblyInterrupted: queue.status === "parked" && display.status !== "paused",
315
+ };
316
+ }
317
+
318
+ session(
319
+ sessionId: string,
320
+ coordinator: { epoch: string; active: boolean; branchReportRequired: boolean } | null = null,
321
+ ): WorkflowSessionView {
322
+ return this.state.readTransaction(() => {
323
+ const activeQueue = this.queue.findSessionReservationView(sessionId);
324
+ const retainedRunId =
325
+ activeQueue === undefined ? this.retainedTerminalRunId(sessionId) : undefined;
326
+ const runId = activeQueue?.runId ?? retainedRunId;
327
+ const pending = this.hostState.listPendingInteractions(sessionId);
328
+ const pendingInteractions = byteBoundedForwardPage(pending, (request) =>
329
+ this.projectRecordField(request.runId, request, "contract"),
330
+ );
331
+ const workflowMessages = this.workflowMessages.listSession(sessionId);
332
+ const eligible = workflowMessages.find((message) => this.isMessageEligible(message));
333
+ const next =
334
+ coordinator === null || (coordinator.active && !coordinator.branchReportRequired)
335
+ ? eligible
336
+ : undefined;
337
+ const open = this.openWorkflowMessage(workflowMessages);
338
+ const openTurn =
339
+ open === undefined
340
+ ? undefined
341
+ : this.workflowMessages.openTurnForMessage(open.workflowMessageId);
342
+ return {
343
+ schema: SESSION_VIEW_SCHEMA,
344
+ sessionId,
345
+ run: runId === undefined ? null : this.run(runId),
346
+ pendingInteractions,
347
+ pendingInteractionStart: 0,
348
+ pendingInteractionTotal: pending.length,
349
+ workflowMessages,
350
+ workflowMessageStart: 0,
351
+ workflowMessageTotal: workflowMessages.length,
352
+ workflowMessageWindowComplete: true,
353
+ nextWorkflowMessageId: next?.workflowMessageId ?? null,
354
+ openWorkflowMessageId: open?.workflowMessageId ?? null,
355
+ openWorkflowTurn: openTurn ?? null,
356
+ coordinatorEpoch: coordinator?.epoch ?? null,
357
+ coordinatorActive: coordinator?.active ?? false,
358
+ branchReportRequired: coordinator?.branchReportRequired ?? false,
359
+ };
360
+ });
361
+ }
362
+
363
+ clearTerminal(sessionId: string, runId?: string, now: number = Date.now()): string | null {
364
+ return this.state.transaction(() => {
365
+ const retained = this.retainedTerminalRunId(sessionId, now);
366
+ if (retained === undefined) return null;
367
+ if (runId !== undefined && retained !== runId) {
368
+ throw new Error(`Retained terminal workflow does not match run ${runId}`);
369
+ }
370
+ this.state.connection
371
+ .prepare(
372
+ `INSERT INTO session_terminal_views(target_session_id, cleared_run_id, cleared_at)
373
+ VALUES (?, ?, ?)
374
+ ON CONFLICT(target_session_id) DO UPDATE SET
375
+ cleared_run_id = excluded.cleared_run_id, cleared_at = excluded.cleared_at`,
376
+ )
377
+ .run(sessionId, retained, now);
378
+ this.sessionCache.delete(sessionId);
379
+ return retained;
380
+ });
381
+ }
382
+
383
+ content(runId: string, contentPath: string, offset: number): JsonValue | null {
384
+ const key = contentKey(runId, contentPath);
385
+ const record = this.contentRecords.get(key) ?? this.recoverContent(runId, contentPath);
386
+ if (record === undefined) return null;
387
+ this.contentRecords.delete(key);
388
+ this.contentRecords.set(key, record);
389
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > record.bytes.byteLength) {
390
+ throw new Error("Workflow content offset is outside the content range");
391
+ }
392
+ const nextOffset = Math.min(record.bytes.byteLength, offset + CONTENT_CHUNK_BYTES);
393
+ return {
394
+ schema: "pi-workflows.content-chunk.v1",
395
+ runId,
396
+ path: record.path,
397
+ mediaType: record.mediaType,
398
+ bytes: record.bytes.byteLength,
399
+ sha256: record.sha256,
400
+ offset,
401
+ nextOffset,
402
+ complete: nextOffset === record.bytes.byteLength,
403
+ data: record.bytes.subarray(offset, nextOffset).toString("base64"),
404
+ };
405
+ }
406
+
407
+ private projectStep(runId: string, step: WorkflowStepRecord): JsonValue {
408
+ const projected = toJson(step);
409
+ if (!isJsonObject(projected)) return projected;
410
+ for (const field of ["prompt", "output", "assistantMessage"] as const) {
411
+ const value = projected[field];
412
+ if (value !== undefined) projected[field] = this.projectValue(runId, value);
413
+ }
414
+ return projected;
415
+ }
416
+
417
+ private projectTraceEvent(runId: string, event: WorkflowTraceEvent): JsonValue {
418
+ return this.projectRecordField(runId, event, "payload");
419
+ }
420
+
421
+ private projectSessionEntry(runId: string, entry: WorkflowSessionEntryRecord): JsonValue {
422
+ return this.projectRecordField(runId, entry, "entry");
423
+ }
424
+
425
+ private projectSessionEvent(runId: string, event: WorkflowSessionEventRecord): JsonValue {
426
+ return this.projectRecordField(runId, event, "payload");
427
+ }
428
+
429
+ private projectUpdate(runId: string, update: WorkflowUpdateRecord): JsonValue {
430
+ return this.projectRecordField(runId, update, "data");
431
+ }
432
+
433
+ private projectReplayCheckpoint(runId: string, value: JsonValue | null): JsonValue {
434
+ return value === null ? null : this.projectValue(runId, value);
435
+ }
436
+
437
+ private projectRecordField(runId: string, value: unknown, field: string): JsonValue {
438
+ const projected = toJson(value);
439
+ if (isJsonObject(projected)) {
440
+ const fieldValue = projected[field];
441
+ if (fieldValue !== undefined) projected[field] = this.projectValue(runId, fieldValue);
442
+ }
443
+ return projected;
444
+ }
445
+
446
+ private projectState(
447
+ runId: string,
448
+ stateValue: WorkflowRunState,
449
+ steps: JsonValue[],
450
+ updates: JsonValue[],
451
+ ): JsonValue {
452
+ const state = toJson(stateValue);
453
+ if (!isJsonObject(state)) return state;
454
+ for (const field of ["input", "outputs", "results", "humanDecision", "finalOutput"] as const) {
455
+ const value = state[field];
456
+ if (value !== undefined) state[field] = this.projectValue(runId, value);
457
+ }
458
+ state.steps = steps;
459
+ state.updates = updates;
460
+ return state;
461
+ }
462
+
463
+ private projectWorkflow(runId: string, value: unknown): JsonValue {
464
+ const original = toJson(value);
465
+ const workflow = escapeArtifactSentinels(original);
466
+ if (Buffer.byteLength(canonicalJson(workflow)) <= VIEW_PAGE_BYTES * 2) return workflow;
467
+ if (
468
+ !isJsonObject(workflow) ||
469
+ !isJsonObject(workflow.nodes) ||
470
+ typeof workflow.schema !== "string" ||
471
+ typeof workflow.name !== "string" ||
472
+ typeof workflow.startAt !== "string" ||
473
+ !Array.isArray(workflow.edges)
474
+ ) {
475
+ return this.registerContent(runId, original, "application/json");
476
+ }
477
+ const nodeEntries = Object.entries(workflow.nodes);
478
+ const boundedNodeEntries = byteBoundedForwardPage(nodeEntries, ([nodeId, node]) => [
479
+ nodeId,
480
+ this.projectWorkflowNode(runId, node),
481
+ ]);
482
+ const nodes = Object.fromEntries(
483
+ boundedNodeEntries.flatMap((entry) =>
484
+ Array.isArray(entry) && typeof entry[0] === "string" && entry[1] !== undefined
485
+ ? [[entry[0], entry[1]]]
486
+ : [],
487
+ ),
488
+ );
489
+ const edges = byteBoundedForwardPage(workflow.edges, (edge) => this.projectValue(runId, edge));
490
+ return {
491
+ schema: workflow.schema,
492
+ name: workflow.name,
493
+ startAt: workflow.startAt,
494
+ nodes,
495
+ nodeStart: 0,
496
+ nodeTotal: nodeEntries.length,
497
+ edges,
498
+ edgeStart: 0,
499
+ edgeTotal: workflow.edges.length,
500
+ content: this.registerContent(runId, original, "application/json"),
501
+ };
502
+ }
503
+
504
+ private projectWorkflowNode(runId: string, value: JsonValue): JsonValue {
505
+ if (!isJsonObject(value)) return this.projectValue(runId, value);
506
+ const projected: Record<string, JsonValue> = {};
507
+ for (const field of [
508
+ "nodeType",
509
+ "timeoutMs",
510
+ "statusDetail",
511
+ "actionExecution",
512
+ "settingsRoute",
513
+ "effect",
514
+ "mountPath",
515
+ "localNodeId",
516
+ "includeTransition",
517
+ ]) {
518
+ const fieldValue = value[field];
519
+ if (fieldValue !== undefined) projected[field] = this.projectValue(runId, fieldValue);
520
+ }
521
+ return projected;
522
+ }
523
+
524
+ private projectDisplay(runId: string, display: WorkflowDisplay): WorkflowDisplay {
525
+ if (
526
+ display.reason === null ||
527
+ Buffer.byteLength(display.reason, "utf8") <= INLINE_CONTENT_BYTES
528
+ ) {
529
+ return display;
530
+ }
531
+ return {
532
+ ...display,
533
+ reason: "Complete workflow failure details are available.",
534
+ reasonContent: this.registerContent(runId, display.reason, "text/plain"),
535
+ };
536
+ }
537
+
538
+ private projectValue(runId: string, value: JsonValue): JsonValue {
539
+ const safeValue = escapeArtifactSentinels(value);
540
+ const mediaType = typeof value === "string" ? "text/plain" : "application/json";
541
+ const bytes =
542
+ mediaType === "text/plain"
543
+ ? Buffer.from(value as string, "utf8")
544
+ : Buffer.from(canonicalJson(value), "utf8");
545
+ return bytes.byteLength <= INLINE_CONTENT_BYTES
546
+ ? safeValue
547
+ : this.registerContent(runId, value, mediaType);
548
+ }
549
+
550
+ private registerContent(
551
+ runId: string,
552
+ value: JsonValue,
553
+ mediaType: ContentRecord["mediaType"],
554
+ ): JsonValue {
555
+ const bytes = Buffer.from(
556
+ mediaType === "text/plain" ? (value as string) : canonicalJson(value),
557
+ "utf8",
558
+ );
559
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
560
+ const persistedDigest = this.runs.persistViewContent(runId, bytes, mediaType);
561
+ if (persistedDigest !== sha256) throw new Error("Workflow view content digest changed");
562
+ const extension = mediaType === "text/plain" ? "txt" : "json";
563
+ const contentPath = `artifacts/sha256/${sha256}.${extension}`;
564
+ this.rememberContent({
565
+ runId,
566
+ path: contentPath,
567
+ mediaType,
568
+ bytes,
569
+ sha256,
570
+ });
571
+ return {
572
+ $artifact: {
573
+ path: contentPath,
574
+ mediaType,
575
+ bytes: bytes.byteLength,
576
+ sha256,
577
+ opaque: true,
578
+ },
579
+ };
580
+ }
581
+
582
+ private rememberContent(record: ContentRecord): ContentRecord {
583
+ const key = contentKey(record.runId, record.path);
584
+ const previous = this.contentRecords.get(key);
585
+ if (previous !== undefined) this.contentBytes -= previous.bytes.byteLength;
586
+ this.contentRecords.delete(key);
587
+ this.contentRecords.set(key, record);
588
+ this.contentBytes += record.bytes.byteLength;
589
+ while (this.contentBytes > CONTENT_CACHE_BYTES && this.contentRecords.size > 1) {
590
+ const oldest = this.contentRecords.entries().next().value as
591
+ | [string, ContentRecord]
592
+ | undefined;
593
+ if (oldest === undefined) break;
594
+ this.contentRecords.delete(oldest[0]);
595
+ this.contentBytes -= oldest[1].bytes.byteLength;
596
+ }
597
+ return record;
598
+ }
599
+
600
+ private recoverContent(runId: string, contentPath: string): ContentRecord | undefined {
601
+ const match = /^artifacts\/sha256\/([0-9a-f]{64})\.(json|txt)$/u.exec(contentPath);
602
+ if (match === null) return undefined;
603
+ const mediaType = match[2] === "txt" ? "text/plain" : "application/json";
604
+ const blob = this.runs.readContentBlob(runId, match[1] as string, mediaType);
605
+ if (blob === undefined) return undefined;
606
+ return this.rememberContent({
607
+ runId,
608
+ path: contentPath,
609
+ mediaType,
610
+ bytes: blob.content,
611
+ sha256: match[1] as string,
612
+ });
613
+ }
614
+
615
+ clearConnection(_connectionId: string): void {
616
+ // Coordinator fencing is process-local in the host. Durable turn state is
617
+ // reconciled from the Pi branch after the next coordinator connects.
618
+ }
619
+
620
+ private retainedTerminalRunId(sessionId: string, now: number = Date.now()): string | undefined {
621
+ const messages = this.workflowMessages
622
+ .listSession(sessionId)
623
+ .filter((message) => message.kind === "terminal")
624
+ .reverse();
625
+ for (const message of messages) {
626
+ const run = this.state.connection
627
+ .prepare("SELECT status FROM runs WHERE run_id = ?")
628
+ .get(message.runId);
629
+ if (!isObjectRecord(run) || !isTerminalStatus(run.status)) continue;
630
+ const clear = this.state.connection
631
+ .prepare(
632
+ `SELECT cleared_run_id AS clearedRunId, cleared_at AS clearedAt
633
+ FROM session_terminal_views WHERE target_session_id = ?`,
634
+ )
635
+ .get(sessionId);
636
+ if (
637
+ isObjectRecord(clear) &&
638
+ clear.clearedRunId === message.runId &&
639
+ typeof clear.clearedAt === "number" &&
640
+ clear.clearedAt >= Date.parse(message.createdAt)
641
+ ) {
642
+ continue;
643
+ }
644
+ if (message.status === "pending") return message.runId;
645
+ if (message.status !== "sent") continue;
646
+ const turn = this.workflowMessages.latestTurnForMessage(message.workflowMessageId);
647
+ if (turn === undefined || turn.state === "started") return message.runId;
648
+ if (turn.endedAt !== null && Date.parse(turn.endedAt) + TERMINAL_VIEW_RETENTION_MS > now) {
649
+ return message.runId;
650
+ }
651
+ }
652
+ return undefined;
653
+ }
654
+
655
+ private isMessageEligible(message: WorkflowMessage): boolean {
656
+ if (message.status !== "pending") return false;
657
+ if (message.kind === "step" || message.kind === "decision") {
658
+ const request = this.state.connection
659
+ .prepare(
660
+ `SELECT i.status, r.paused FROM interactive_requests i
661
+ JOIN runs r ON r.run_id = i.run_id
662
+ WHERE i.request_id = ? AND i.run_id = ?`,
663
+ )
664
+ .get(message.sourceId, message.runId);
665
+ if (!isObjectRecord(request) || request.status !== "pending") return false;
666
+ return message.kind === "decision" || request.paused === 0;
667
+ }
668
+ if (message.kind === "notification") return true;
669
+ if (message.kind === "terminal") {
670
+ const run = this.state.connection
671
+ .prepare("SELECT status FROM runs WHERE run_id = ?")
672
+ .get(message.runId);
673
+ return isObjectRecord(run) && isTerminalStatus(run.status);
674
+ }
675
+ const source = this.state.connection
676
+ .prepare(
677
+ `SELECT f.run_id AS runId, f.order_number AS orderNumber, f.status
678
+ FROM workflow_follow_ups f WHERE f.follow_up_id = ?`,
679
+ )
680
+ .get(message.sourceId);
681
+ if (
682
+ !isObjectRecord(source) ||
683
+ source.status !== "queued" ||
684
+ typeof source.orderNumber !== "number" ||
685
+ typeof source.runId !== "string"
686
+ ) {
687
+ return false;
688
+ }
689
+ const leaf = this.terminalChainLeaf(source.runId);
690
+ if (leaf === undefined || leaf.status !== "completed") return false;
691
+ const terminal = this.workflowMessages
692
+ .listRun(leaf.runId)
693
+ .filter((candidate) => candidate.kind === "terminal" && candidate.status === "sent")
694
+ .at(-1);
695
+ if (terminal === undefined) return false;
696
+ const terminalTurn = this.workflowMessages.latestTurnForMessage(terminal.workflowMessageId);
697
+ if (terminalTurn?.state !== "ended") return false;
698
+ const prior = this.state.connection
699
+ .prepare(
700
+ `SELECT follow_up_id AS followUpId, status FROM workflow_follow_ups
701
+ WHERE run_id = ? AND order_number < ? ORDER BY order_number`,
702
+ )
703
+ .all(source.runId, source.orderNumber);
704
+ for (const item of prior) {
705
+ if (
706
+ !isObjectRecord(item) ||
707
+ typeof item.followUpId !== "string" ||
708
+ typeof item.status !== "string"
709
+ ) {
710
+ return false;
711
+ }
712
+ if (item.status === "removed" || item.status === "cancelled") continue;
713
+ const priorMessage = this.workflowMessages.latestForSource("followUp", item.followUpId);
714
+ if (
715
+ priorMessage === undefined ||
716
+ this.workflowMessages.latestTurnForMessage(priorMessage.workflowMessageId)?.state !==
717
+ "ended"
718
+ ) {
719
+ return false;
720
+ }
721
+ }
722
+ const reservation = this.state.connection
723
+ .prepare(
724
+ `SELECT 1 AS present FROM run_bindings b JOIN runs r ON r.run_id = b.run_id
725
+ WHERE b.origin_session_id = ? AND r.run_id <> ?
726
+ AND r.status IN ('queued', 'running', 'waiting') LIMIT 1`,
727
+ )
728
+ .get(message.targetSessionId, message.runId);
729
+ return reservation === undefined;
730
+ }
731
+
732
+ private terminalChainLeaf(runId: string): { runId: string; status: string } | undefined {
733
+ const row = this.state.connection
734
+ .prepare(
735
+ `WITH RECURSIVE chain(run_id, status, depth, created_at) AS (
736
+ SELECT run_id, status, 0, created_at FROM runs WHERE run_id = ?
737
+ UNION ALL
738
+ SELECT child.run_id, child.status, chain.depth + 1, child.created_at
739
+ FROM runs child JOIN chain ON child.parent_run_id = chain.run_id
740
+ )
741
+ SELECT run_id AS runId, status FROM chain
742
+ ORDER BY depth DESC, created_at DESC LIMIT 1`,
743
+ )
744
+ .get(runId);
745
+ return isObjectRecord(row) && typeof row.runId === "string" && typeof row.status === "string"
746
+ ? { runId: row.runId, status: row.status }
747
+ : undefined;
748
+ }
749
+
750
+ private openWorkflowMessage(messages: readonly WorkflowMessage[]): WorkflowMessage | undefined {
751
+ for (const message of [...messages].reverse()) {
752
+ if (message.status !== "sent") continue;
753
+ if (message.kind === "step") {
754
+ const request = this.state.connection
755
+ .prepare(
756
+ `SELECT i.status, r.paused FROM interactive_requests i
757
+ JOIN runs r ON r.run_id = i.run_id WHERE i.request_id = ?`,
758
+ )
759
+ .get(message.sourceId);
760
+ if (isObjectRecord(request) && request.status === "pending" && request.paused === 0) {
761
+ return message;
762
+ }
763
+ } else if (message.kind === "terminal" || message.kind === "followUp") {
764
+ const turn = this.workflowMessages.latestTurnForMessage(message.workflowMessageId);
765
+ if (turn === undefined || turn.state === "started") return message;
766
+ }
767
+ }
768
+ return undefined;
769
+ }
770
+
771
+ private workflowActivityRevision(): string {
772
+ const row = this.state.connection
773
+ .prepare(
774
+ `SELECT
775
+ COALESCE((SELECT max(updated_at) FROM workflow_messages), 0) AS messageUpdatedAt,
776
+ COALESCE((SELECT max(COALESCE(ended_at, started_at)) FROM workflow_turns), 0) AS turnUpdatedAt`,
777
+ )
778
+ .get();
779
+ return isObjectRecord(row) &&
780
+ typeof row.messageUpdatedAt === "number" &&
781
+ typeof row.turnUpdatedAt === "number"
782
+ ? `${row.messageUpdatedAt}:${row.turnUpdatedAt}${this.originActivityRevision === 0 ? "" : `-${this.originActivityRevision}`}`
783
+ : `0:0${this.originActivityRevision === 0 ? "" : `-${this.originActivityRevision}`}`;
784
+ }
785
+
786
+ private display(
787
+ queue: WorkflowRunQueueRecord | WorkflowRunQueueViewRecord,
788
+ state?: WorkflowRunDisplayState | WorkflowRunState | null,
789
+ ): WorkflowDisplay {
790
+ return reduceWorkflowDisplay({
791
+ queueStatus: queue.status,
792
+ durableStatus: state?.status,
793
+ paused: state?.paused === true,
794
+ ambiguous: this.hasAmbiguousEffect(queue.runId),
795
+ workerActive: this.hasLiveWorker(queue.runId),
796
+ originTurnActive: this.hasActivity(queue.runId),
797
+ pendingInteraction: this.hasPendingInteraction(queue.runId),
798
+ errorMessage: state?.error ?? queue.errorMessage,
799
+ });
800
+ }
801
+
802
+ private hasActivity(runId: string): boolean {
803
+ const row = this.state.connection
804
+ .prepare(
805
+ `SELECT t.target_session_id AS targetSessionId FROM workflow_turns t
806
+ JOIN workflow_messages m ON m.workflow_message_id = t.workflow_message_id
807
+ WHERE t.run_id = ? AND t.state = 'started' AND m.kind IN ('step', 'terminal') LIMIT 1`,
808
+ )
809
+ .get(runId);
810
+ return (
811
+ isObjectRecord(row) &&
812
+ typeof row.targetSessionId === "string" &&
813
+ this.hasActiveSessionTurn(row.targetSessionId)
814
+ );
815
+ }
816
+
817
+ private hasPendingInteraction(runId: string): boolean {
818
+ const row = this.state.connection
819
+ .prepare(
820
+ `SELECT 1 AS present FROM interactive_requests
821
+ WHERE run_id = ? AND status = 'pending' LIMIT 1`,
822
+ )
823
+ .get(runId);
824
+ return row !== undefined;
825
+ }
826
+
827
+ private hasAmbiguousEffect(runId: string): boolean {
828
+ const row = this.state.connection
829
+ .prepare(
830
+ `SELECT 1 AS present FROM effects e JOIN runs r ON r.resource_id = e.source_resource_id
831
+ WHERE r.run_id = ? AND e.status = 'ambiguous' LIMIT 1`,
832
+ )
833
+ .get(runId);
834
+ return row !== undefined;
835
+ }
836
+
837
+ private runVersion(runId: string): string {
838
+ const row = this.state.connection
839
+ .prepare(
840
+ `SELECT res.revision, r.status AS runStatus, r.paused,
841
+ q.updated_at AS updatedAt,
842
+ COALESCE(v.presentation_revision, 0) AS presentationRevision
843
+ FROM runs r JOIN resources res ON res.resource_id = r.resource_id
844
+ JOIN run_queue q ON q.run_id = r.run_id
845
+ LEFT JOIN viewer_runs v ON v.run_id = r.run_id
846
+ WHERE r.run_id = ?`,
847
+ )
848
+ .get(runId);
849
+ if (!isRunVersionRow(row)) return "missing";
850
+ return [
851
+ row.revision,
852
+ row.updatedAt,
853
+ row.presentationRevision,
854
+ row.runStatus,
855
+ row.paused,
856
+ this.workflowActivityRevision(),
857
+ this.hasLiveWorker(runId),
858
+ this.hasActivity(runId),
859
+ this.hasPendingInteraction(runId),
860
+ this.hasAmbiguousEffect(runId),
861
+ ].join(":");
862
+ }
863
+
864
+ private pendingSessionRevision(sessionId: string): string {
865
+ const row = this.state.connection
866
+ .prepare(
867
+ `SELECT count(*) AS count, COALESCE(sum(revision), 0) AS revisionSum,
868
+ COALESCE(max(updated_at), 0) AS updatedAt
869
+ FROM interactive_requests
870
+ WHERE target_session_id = ? AND status = 'pending'`,
871
+ )
872
+ .get(sessionId);
873
+ if (!isSessionRevisionRow(row)) throw new Error("Session view revision is invalid");
874
+ return `${row.count}:${row.revisionSum}:${row.updatedAt}`;
875
+ }
876
+
877
+ private presentationRevision(runId: string): number {
878
+ const row = this.state.connection
879
+ .prepare(`SELECT presentation_revision AS revision FROM viewer_runs WHERE run_id = ?`)
880
+ .get(runId);
881
+ return isRevisionRow(row) ? row.revision : 0;
882
+ }
883
+ }
884
+
885
+ export type WorkflowDisplayFacts = {
886
+ queueStatus: WorkflowRunQueueRecord["status"];
887
+ durableStatus: WorkflowRunState["status"] | undefined;
888
+ paused: boolean;
889
+ ambiguous: boolean;
890
+ workerActive: boolean;
891
+ originTurnActive: boolean;
892
+ pendingInteraction: boolean;
893
+ errorMessage: string | null;
894
+ };
895
+
896
+ export function reduceWorkflowDisplay(facts: WorkflowDisplayFacts): WorkflowDisplay {
897
+ let status: WorkflowDisplayStatus;
898
+ let activity: WorkflowDisplay["activity"] = null;
899
+ let reason: string | null = null;
900
+
901
+ if (facts.ambiguous) {
902
+ status = "ambiguous";
903
+ reason = "An external effect needs explicit recovery.";
904
+ } else if (facts.workerActive || facts.originTurnActive) {
905
+ status = "running";
906
+ activity = facts.workerActive ? "supervised_worker" : "origin_turn";
907
+ } else if (
908
+ facts.durableStatus === "completed" ||
909
+ facts.durableStatus === "failed" ||
910
+ facts.durableStatus === "timed_out" ||
911
+ facts.durableStatus === "cancelled"
912
+ ) {
913
+ status = facts.durableStatus;
914
+ reason = facts.errorMessage;
915
+ } else if (facts.paused) {
916
+ status = "paused";
917
+ reason = "The workflow is durably paused.";
918
+ } else if (facts.pendingInteraction || facts.durableStatus === "waiting") {
919
+ status = "waiting";
920
+ reason = "The workflow is waiting for origin-session input.";
921
+ } else if (facts.queueStatus === "parked" || facts.queueStatus === "queued") {
922
+ status = "queued";
923
+ reason = facts.queueStatus === "parked" ? "The workflow is ready to resume." : null;
924
+ } else if (facts.queueStatus === "done") {
925
+ status = "completed";
926
+ } else if (facts.queueStatus === "failed" || facts.queueStatus === "cancelled") {
927
+ status = facts.queueStatus;
928
+ reason = facts.errorMessage;
929
+ } else {
930
+ status = "running";
931
+ }
932
+
933
+ const controls: WorkflowDisplay["controls"] = [];
934
+ if (status === "running" || status === "waiting") controls.push("pause", "cancel");
935
+ else if (status === "paused") controls.push("resume", "cancel");
936
+ else if (status === "queued") {
937
+ if (facts.queueStatus === "parked") controls.push("resume");
938
+ controls.push("cancel");
939
+ }
940
+ if (status === "waiting") controls.push("answer");
941
+ if (status === "ambiguous") controls.push("review");
942
+ return { status, activity, controls, reason };
943
+ }
944
+
945
+ function manifest(
946
+ run: WorkflowRunQueueRecord | WorkflowRunQueueViewRecord,
947
+ status: WorkflowDisplayStatus,
948
+ ): JsonValue {
949
+ return {
950
+ schema: "pi-workflows.run-manifest.v1",
951
+ runId: run.runId,
952
+ workflowName: run.workflowName,
953
+ workflowSource: workflowRootSource(run.workflowSource),
954
+ startedAt: run.startedAt ?? run.createdAt,
955
+ ...(run.finishedAt === null ? {} : { finishedAt: run.finishedAt }),
956
+ status,
957
+ traceSchema: "pi-workflows.trace-event.v1",
958
+ paths: {
959
+ workflow: "host",
960
+ state: "host",
961
+ trace: "host",
962
+ },
963
+ };
964
+ }
965
+
966
+ function projectQueue(
967
+ run: WorkflowRunQueueRecord | WorkflowRunQueueViewRecord,
968
+ ): WorkflowRunQueueView {
969
+ return {
970
+ runId: run.runId,
971
+ workflowName: run.workflowName,
972
+ workflowSourceRef: run.workflowSourceRef,
973
+ initialized: run.initialized,
974
+ definitionDigest: run.definitionDigest,
975
+ status: run.status,
976
+ originSessionId: run.originSessionId,
977
+ executionMode: run.executionMode,
978
+ parentRunId: run.parentRunId,
979
+ rootRunId: run.rootRunId,
980
+ lineageKind: run.lineageKind,
981
+ restartNumber: run.restartNumber,
982
+ parentTerminalFingerprint: run.parentTerminalFingerprint,
983
+ errorCode: run.errorCode,
984
+ createdAt: run.createdAt,
985
+ updatedAt: run.updatedAt,
986
+ startedAt: run.startedAt,
987
+ finishedAt: run.finishedAt,
988
+ };
989
+ }
990
+
991
+ function workflowRootSource(value: unknown): JsonValue {
992
+ const sourceSet = toJson(value);
993
+ if (!isJsonObject(sourceSet)) throw new Error("Workflow queue source set is invalid");
994
+ const root = sourceSet.root;
995
+ if (root === undefined || !isJsonObject(root)) {
996
+ throw new Error("Workflow queue source set is invalid");
997
+ }
998
+ return root;
999
+ }
1000
+
1001
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
1002
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1003
+ }
1004
+
1005
+ function isTerminalStatus(
1006
+ value: unknown,
1007
+ ): value is "completed" | "failed" | "timed_out" | "cancelled" {
1008
+ return (
1009
+ value === "completed" || value === "failed" || value === "timed_out" || value === "cancelled"
1010
+ );
1011
+ }
1012
+
1013
+ function contentKey(runId: string, contentPath: string): string {
1014
+ return `${runId}\u0000${contentPath}`;
1015
+ }
1016
+
1017
+ function escapeArtifactSentinels(value: JsonValue): JsonValue {
1018
+ if (Array.isArray(value)) return value.map(escapeArtifactSentinels);
1019
+ if (!isJsonObject(value)) return value;
1020
+ const escaped = Object.fromEntries(
1021
+ Object.entries(value).map(([key, item]) => [key, escapeArtifactSentinels(item)]),
1022
+ ) as JsonValue;
1023
+ return Object.keys(value).length === 1 &&
1024
+ (Object.hasOwn(value, "$artifact") || Object.hasOwn(value, "$escaped"))
1025
+ ? { $escaped: escaped }
1026
+ : escaped;
1027
+ }
1028
+
1029
+ function projectFollowUpQueue(value: unknown, items: JsonValue[]): JsonValue {
1030
+ const queue = toJson(value);
1031
+ if (!isJsonObject(queue)) return queue;
1032
+ delete queue.followUps;
1033
+ queue.items = items;
1034
+ return queue;
1035
+ }
1036
+
1037
+ function refreshCacheEntry<K, V>(cache: Map<K, V>, key: K, value: V): void {
1038
+ cache.delete(key);
1039
+ cache.set(key, value);
1040
+ }
1041
+
1042
+ function rememberCacheEntry<K, V>(cache: Map<K, V>, key: K, value: V): void {
1043
+ refreshCacheEntry(cache, key, value);
1044
+ while (cache.size > VIEW_CACHE_ITEMS) {
1045
+ const oldest = cache.keys().next().value as K | undefined;
1046
+ if (oldest === undefined) break;
1047
+ cache.delete(oldest);
1048
+ }
1049
+ }
1050
+
1051
+ function viewRange(total: number, cursor?: number): { start: number; limit: number } {
1052
+ const start = workflowPageStart(total, cursor);
1053
+ return { start, limit: Math.min(VIEW_PAGE_ITEMS, Math.max(0, total - start)) };
1054
+ }
1055
+
1056
+ function byteBoundedForwardPage<T>(
1057
+ values: readonly T[],
1058
+ project: (value: T) => JsonValue,
1059
+ ): JsonValue[] {
1060
+ const items: JsonValue[] = [];
1061
+ let bytes = 0;
1062
+ for (const value of values) {
1063
+ if (items.length >= VIEW_PAGE_ITEMS) break;
1064
+ const item = project(value);
1065
+ const itemBytes = Buffer.byteLength(canonicalJson(item)) + (items.length === 0 ? 0 : 1);
1066
+ if (items.length > 0 && bytes + itemBytes > VIEW_PAGE_BYTES) break;
1067
+ items.push(item);
1068
+ bytes += itemBytes;
1069
+ }
1070
+ return items;
1071
+ }
1072
+
1073
+ function byteBoundedCandidatePage<T>(
1074
+ values: readonly T[],
1075
+ candidateStart: number,
1076
+ total: number,
1077
+ requestedCursor: number | undefined,
1078
+ project: (value: T) => JsonValue,
1079
+ ): { start: number; total: number; items: JsonValue[] } {
1080
+ if (values.length === 0) return { start: candidateStart, total, items: [] };
1081
+ const globalCursor = clampCursor(requestedCursor ?? Math.max(0, total - 1), total);
1082
+ const localCursor = Math.min(Math.max(0, globalCursor - candidateStart), values.length - 1);
1083
+ const page = byteBoundedPage(values, localCursor, project);
1084
+ return { start: candidateStart + page.start, total, items: page.items };
1085
+ }
1086
+
1087
+ function byteBoundedPage<T>(
1088
+ values: readonly T[],
1089
+ requestedCursor: number | undefined,
1090
+ project: (value: T) => JsonValue,
1091
+ ): { start: number; total: number; items: JsonValue[] } {
1092
+ const total = values.length;
1093
+ if (total === 0) return { start: 0, total: 0, items: [] };
1094
+ const cursor = clampCursor(requestedCursor ?? total - 1, total);
1095
+ const selected = project(values[cursor] as T);
1096
+ const selectedBytes = Buffer.byteLength(canonicalJson(selected));
1097
+ const indexed = new Map<number, JsonValue>([[cursor, selected]]);
1098
+ let pageBytes = selectedBytes;
1099
+ let left = cursor - 1;
1100
+ let right = cursor + 1;
1101
+ let leftBlocked = false;
1102
+ let rightBlocked = false;
1103
+ let preferLeft = true;
1104
+
1105
+ while (indexed.size < VIEW_PAGE_ITEMS && (!leftBlocked || !rightBlocked)) {
1106
+ const index = preferLeft ? left : right;
1107
+ const inRange = index >= 0 && index < total;
1108
+ if (!inRange) {
1109
+ if (preferLeft) leftBlocked = true;
1110
+ else rightBlocked = true;
1111
+ } else {
1112
+ const item = project(values[index] as T);
1113
+ const itemBytes = Buffer.byteLength(canonicalJson(item)) + 1;
1114
+ if (pageBytes + itemBytes > VIEW_PAGE_BYTES) {
1115
+ if (preferLeft) leftBlocked = true;
1116
+ else rightBlocked = true;
1117
+ } else {
1118
+ indexed.set(index, item);
1119
+ pageBytes += itemBytes;
1120
+ if (preferLeft) left -= 1;
1121
+ else right += 1;
1122
+ }
1123
+ }
1124
+ preferLeft = !preferLeft;
1125
+ }
1126
+
1127
+ const ordered = [...indexed.entries()].sort(
1128
+ ([leftIndex], [rightIndex]) => leftIndex - rightIndex,
1129
+ );
1130
+ return {
1131
+ start: ordered[0]?.[0] ?? cursor,
1132
+ total,
1133
+ items: ordered.map(([, item]) => item),
1134
+ };
1135
+ }
1136
+
1137
+ export function toCompactStepJson(step: WorkflowStepRecord): JsonValue {
1138
+ return toJson({
1139
+ attemptId: step.attemptId,
1140
+ nodeId: step.nodeId,
1141
+ nodeType: step.nodeType,
1142
+ outcome: step.outcome,
1143
+ startedAt: step.startedAt,
1144
+ finishedAt: step.finishedAt,
1145
+ prompt: null,
1146
+ output: null,
1147
+ ...(step.settingsScopeId === undefined ? {} : { settingsScopeId: step.settingsScopeId }),
1148
+ ...(step.settingsChangeNumber === undefined
1149
+ ? {}
1150
+ : { settingsChangeNumber: step.settingsChangeNumber }),
1151
+ ...(step.settingsHash === undefined ? {} : { settingsHash: step.settingsHash }),
1152
+ });
1153
+ }
1154
+
1155
+ export function workflowPageStart(total: number, cursor?: number): number {
1156
+ if (total <= 256) return 0;
1157
+ if (cursor === undefined) return total - 256;
1158
+ const center = clampCursor(cursor, total);
1159
+ return Math.min(Math.max(0, center - 128), total - 256);
1160
+ }
1161
+
1162
+ function clampCursor(cursor: number, total: number): number {
1163
+ return total === 0 ? 0 : Math.min(cursor, total - 1);
1164
+ }
1165
+
1166
+ function toJson(value: unknown): JsonValue {
1167
+ return parseJson(canonicalJson(value));
1168
+ }
1169
+
1170
+ function isJsonObject(value: unknown): value is { [key: string]: JsonValue } {
1171
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1172
+ }
1173
+
1174
+ function isRunVersionRow(value: unknown): value is {
1175
+ revision: number;
1176
+ updatedAt: number;
1177
+ presentationRevision: number;
1178
+ runStatus: string;
1179
+ paused: number;
1180
+ } {
1181
+ return (
1182
+ typeof value === "object" &&
1183
+ value !== null &&
1184
+ typeof (value as { revision?: unknown }).revision === "number" &&
1185
+ typeof (value as { updatedAt?: unknown }).updatedAt === "number" &&
1186
+ typeof (value as { presentationRevision?: unknown }).presentationRevision === "number" &&
1187
+ typeof (value as { runStatus?: unknown }).runStatus === "string" &&
1188
+ typeof (value as { paused?: unknown }).paused === "number"
1189
+ );
1190
+ }
1191
+
1192
+ function isSessionRevisionRow(value: unknown): value is {
1193
+ count: number;
1194
+ revisionSum: number;
1195
+ updatedAt: number;
1196
+ } {
1197
+ return (
1198
+ typeof value === "object" &&
1199
+ value !== null &&
1200
+ typeof (value as { count?: unknown }).count === "number" &&
1201
+ typeof (value as { revisionSum?: unknown }).revisionSum === "number" &&
1202
+ typeof (value as { updatedAt?: unknown }).updatedAt === "number"
1203
+ );
1204
+ }
1205
+
1206
+ function isRevisionRow(value: unknown): value is { revision: number } {
1207
+ return (
1208
+ typeof value === "object" &&
1209
+ value !== null &&
1210
+ typeof (value as { revision?: unknown }).revision === "number"
1211
+ );
1212
+ }