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