@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.
Files changed (124) hide show
  1. package/README.md +6 -6
  2. package/dist/client/activity.d.ts +2 -0
  3. package/dist/client/activity.js +6 -0
  4. package/dist/client/activity.js.map +1 -0
  5. package/dist/client/client.d.ts +101 -0
  6. package/dist/client/client.js +733 -0
  7. package/dist/client/client.js.map +1 -0
  8. package/dist/client/index.d.ts +3 -0
  9. package/dist/client/index.js +3 -0
  10. package/dist/client/index.js.map +1 -0
  11. package/dist/client/materialize.d.ts +7 -0
  12. package/dist/client/materialize.js +177 -0
  13. package/dist/client/materialize.js.map +1 -0
  14. package/dist/client/protocol.d.ts +60 -0
  15. package/dist/client/protocol.js +269 -0
  16. package/dist/client/protocol.js.map +1 -0
  17. package/dist/client/resolver.d.ts +23 -0
  18. package/dist/client/resolver.js +2 -0
  19. package/dist/client/resolver.js.map +1 -0
  20. package/dist/client/view.d.ts +118 -0
  21. package/dist/client/view.js +3 -0
  22. package/dist/client/view.js.map +1 -0
  23. package/dist/controllers/sqlite.d.ts +64 -0
  24. package/dist/controllers/sqlite.js +219 -3
  25. package/dist/controllers/sqlite.js.map +1 -1
  26. package/dist/extension/index.d.ts +1 -0
  27. package/dist/extension/index.js +384 -156
  28. package/dist/extension/index.js.map +1 -1
  29. package/dist/extension/session-delivery.d.ts +6 -0
  30. package/dist/extension/session-delivery.js +80 -25
  31. package/dist/extension/session-delivery.js.map +1 -1
  32. package/dist/extension/session-view.d.ts +21 -0
  33. package/dist/extension/session-view.js +127 -0
  34. package/dist/extension/session-view.js.map +1 -0
  35. package/dist/extension/widget.d.ts +2 -1
  36. package/dist/extension/widget.js +15 -7
  37. package/dist/extension/widget.js.map +1 -1
  38. package/dist/host/child-worker-supervisor.js +1 -1
  39. package/dist/host/child-worker-supervisor.js.map +1 -1
  40. package/dist/host/resolver-entry.d.ts +2 -23
  41. package/dist/host/resolver-entry.js +1 -1
  42. package/dist/host/resolver-entry.js.map +1 -1
  43. package/dist/host/runner.d.ts +12 -0
  44. package/dist/host/runner.js +565 -43
  45. package/dist/host/runner.js.map +1 -1
  46. package/dist/host/state.d.ts +8 -3
  47. package/dist/host/state.js +59 -27
  48. package/dist/host/state.js.map +1 -1
  49. package/dist/host/view.d.ts +73 -0
  50. package/dist/host/view.js +871 -0
  51. package/dist/host/view.js.map +1 -0
  52. package/dist/host/worker-protocol.js +1 -1
  53. package/dist/host/worker-protocol.js.map +1 -1
  54. package/dist/state/database.d.ts +1 -0
  55. package/dist/state/database.js +15 -0
  56. package/dist/state/database.js.map +1 -1
  57. package/dist/state/prune.d.ts +3 -1
  58. package/dist/state/prune.js +6 -8
  59. package/dist/state/prune.js.map +1 -1
  60. package/dist/state/schema.js +12 -1
  61. package/dist/state/schema.js.map +1 -1
  62. package/dist/viewer/backup.d.ts +2 -0
  63. package/dist/viewer/backup.js +28 -0
  64. package/dist/viewer/backup.js.map +1 -0
  65. package/dist/viewer/cli.d.ts +4 -0
  66. package/dist/viewer/cli.js +150 -170
  67. package/dist/viewer/cli.js.map +1 -1
  68. package/dist/viewer/tui.d.ts +5 -7
  69. package/dist/viewer/tui.js +188 -108
  70. package/dist/viewer/tui.js.map +1 -1
  71. package/dist/workflows/store.d.ts +62 -1
  72. package/dist/workflows/store.js +350 -44
  73. package/dist/workflows/store.js.map +1 -1
  74. package/docs/2026-09-01-restore-session-delivery-controls-plan.md +139 -0
  75. package/docs/2026-09-01-unified-workflow-client-plan.md +381 -0
  76. package/docs/2026-09-02-installed-live-e2e-plan.md +225 -0
  77. package/docs/SQLITE_STATE.md +13 -11
  78. package/docs/WORKFLOW_HOST.md +81 -64
  79. package/docs/WORKFLOW_STEP_MESSAGES.md +4 -4
  80. package/docs/development.md +2 -1
  81. package/docs/live-replay-protocol.md +70 -132
  82. package/docs/tui-viewer.md +10 -14
  83. package/docs/workflows.md +56 -3
  84. package/herdr-plugin.toml +1 -1
  85. package/package.json +9 -3
  86. package/protocol/client.v1.schema.json +137 -0
  87. package/protocol/fixtures/client-v1.json +23 -0
  88. package/src/client/activity.ts +6 -0
  89. package/src/client/client.ts +935 -0
  90. package/src/client/index.ts +24 -0
  91. package/src/client/materialize.ts +228 -0
  92. package/src/client/protocol.ts +327 -0
  93. package/src/client/resolver.ts +26 -0
  94. package/src/client/view.ts +138 -0
  95. package/src/controllers/sqlite.ts +342 -3
  96. package/src/extension/index.ts +482 -171
  97. package/src/extension/session-delivery.ts +88 -25
  98. package/src/extension/session-view.ts +154 -0
  99. package/src/extension/widget.ts +18 -9
  100. package/src/host/child-worker-supervisor.ts +1 -1
  101. package/src/host/resolver-entry.ts +11 -26
  102. package/src/host/runner.ts +749 -75
  103. package/src/host/state.ts +82 -44
  104. package/src/host/view.ts +1084 -0
  105. package/src/host/worker-protocol.ts +1 -1
  106. package/src/state/database.ts +13 -0
  107. package/src/state/prune.ts +11 -11
  108. package/src/state/schema.ts +12 -1
  109. package/src/viewer/backup.ts +29 -0
  110. package/src/viewer/cli.ts +171 -185
  111. package/src/viewer/tui.ts +196 -124
  112. package/src/workflows/store.ts +500 -45
  113. package/dist/host/client.d.ts +0 -48
  114. package/dist/host/client.js +0 -216
  115. package/dist/host/client.js.map +0 -1
  116. package/dist/host/protocol.d.ts +0 -38
  117. package/dist/host/protocol.js +0 -156
  118. package/dist/host/protocol.js.map +0 -1
  119. package/dist/viewer/watch.d.ts +0 -6
  120. package/dist/viewer/watch.js +0 -46
  121. package/dist/viewer/watch.js.map +0 -1
  122. package/src/host/client.ts +0 -293
  123. package/src/host/protocol.ts +0 -196
  124. package/src/viewer/watch.ts +0 -51
@@ -0,0 +1,935 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { once } from "node:events";
4
+ import fs from "node:fs";
5
+ import { createRequire } from "node:module";
6
+ import net, { type Socket } from "node:net";
7
+ import { fileURLToPath } from "node:url";
8
+ import { workflowStatePath } from "../state/database.js";
9
+ import { canonicalJson, parseJson, type JsonValue } from "../state/json.js";
10
+ import {
11
+ CLIENT_PROTOCOL_SCHEMA,
12
+ NdjsonFrameDecoder,
13
+ clientSocketPath,
14
+ encodeProtocolLine,
15
+ parseClientMessage,
16
+ type ClientEvent,
17
+ type ClientHello,
18
+ type ClientOperation,
19
+ type ClientRequest,
20
+ type ClientResponse,
21
+ } from "./protocol.js";
22
+ import type {
23
+ ResolvedControllerInitialization,
24
+ ResolvedSettingsChange,
25
+ ResolvedWorkflowLaunch,
26
+ } from "./resolver.js";
27
+ import type { WorkflowRunListPage, WorkflowRunSummary, WorkflowRunView } from "./view.js";
28
+
29
+ const CONNECT_TIMEOUT_MS = 2_000;
30
+ const START_TIMEOUT_MS = 10_000;
31
+ const RECONNECT_DELAY_MS = 250;
32
+ const RESOLVER_TIMEOUT_MS = 30_000;
33
+ const CLIENT_PACKAGE_VERSION = runtimePackageVersion();
34
+
35
+ type PendingRequest = {
36
+ resolve: (response: ClientResponse) => void;
37
+ reject: (error: Error) => void;
38
+ };
39
+
40
+ type Subscription = {
41
+ operation: "view.runs.watch" | "view.run.watch" | "view.session.watch";
42
+ runId?: string;
43
+ payload: JsonValue;
44
+ listener: (event: ClientEvent) => void;
45
+ runListGeneration: number;
46
+ };
47
+
48
+ export class WorkflowClientVersionError extends Error {
49
+ constructor(message: string) {
50
+ super(message);
51
+ this.name = "WorkflowClientVersionError";
52
+ }
53
+ }
54
+
55
+ export type WorkflowClientOptions = {
56
+ clientId?: string;
57
+ databasePath?: string;
58
+ hostEntryPath?: string;
59
+ env?: Record<string, string>;
60
+ };
61
+
62
+ export class WorkflowClient {
63
+ readonly clientId: string;
64
+ readonly databasePath: string;
65
+ readonly endpoint: string;
66
+
67
+ private readonly hostEntryPath: string | undefined;
68
+ private readonly env: Record<string, string> | undefined;
69
+ private socket: Socket | null = null;
70
+ private connectTask: Promise<ClientHello> | null = null;
71
+ private hello: ClientHello | null = null;
72
+ private closed = false;
73
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
74
+ private readonly pending = new Map<string, PendingRequest>();
75
+ private readonly subscriptions = new Map<string, Subscription>();
76
+
77
+ constructor(options: WorkflowClientOptions = {}) {
78
+ this.clientId = options.clientId ?? `client-${randomUUID()}`;
79
+ this.databasePath = options.databasePath ?? workflowStatePath();
80
+ this.endpoint = clientSocketPath(this.databasePath);
81
+ this.hostEntryPath = options.hostEntryPath;
82
+ this.env = options.env;
83
+ }
84
+
85
+ get connectionId(): string | undefined {
86
+ return this.hello?.connectionId;
87
+ }
88
+
89
+ get packageVersion(): string | undefined {
90
+ return this.hello?.packageVersion;
91
+ }
92
+
93
+ async connect(): Promise<ClientHello> {
94
+ if (this.closed) throw new Error("Workflow client is closed");
95
+ if (this.hello !== null && this.socket !== null && !this.socket.destroyed) return this.hello;
96
+ this.connectTask ??= this.openConnection();
97
+ try {
98
+ return await this.connectTask;
99
+ } finally {
100
+ this.connectTask = null;
101
+ }
102
+ }
103
+
104
+ async request(options: {
105
+ operation: ClientOperation;
106
+ requestId?: string;
107
+ idempotencyKey?: string;
108
+ runId?: string;
109
+ expectedRevision?: number;
110
+ payload?: JsonValue;
111
+ signal?: AbortSignal;
112
+ }): Promise<ClientResponse> {
113
+ await this.connect();
114
+ return await this.requestConnected(options);
115
+ }
116
+
117
+ async requestDurable(options: {
118
+ operation: ClientOperation;
119
+ requestId?: string;
120
+ idempotencyKey: string;
121
+ runId?: string;
122
+ expectedRevision?: number;
123
+ payload?: JsonValue;
124
+ signal?: AbortSignal;
125
+ }): Promise<ClientResponse> {
126
+ try {
127
+ return await this.request(options);
128
+ } catch (error) {
129
+ if (
130
+ this.closed ||
131
+ options.signal?.aborted === true ||
132
+ error instanceof WorkflowClientVersionError
133
+ ) {
134
+ throw error;
135
+ }
136
+ this.resetConnection();
137
+ await this.ensureAvailable();
138
+ return await this.request({ ...options, requestId: randomUUID() });
139
+ }
140
+ }
141
+
142
+ async getRun(runId: string): Promise<WorkflowRunView | null> {
143
+ const response = await this.request({ operation: "view.run.get", runId });
144
+ if (response.outcome === "notFound") return null;
145
+ if (response.outcome !== "accepted" || !isWorkflowRunView(response.receipt, runId)) {
146
+ throw new Error(response.error ?? "Workflow host returned an invalid run view");
147
+ }
148
+ return response.receipt;
149
+ }
150
+
151
+ async watchRuns(
152
+ listener: (event: ClientEvent) => void,
153
+ options: { subscriptionId?: string; limit?: number } = {},
154
+ ): Promise<() => Promise<void>> {
155
+ return await this.subscribe(
156
+ "view.runs.watch",
157
+ undefined,
158
+ {
159
+ subscriptionId: options.subscriptionId ?? randomUUID(),
160
+ ...(options.limit === undefined ? {} : { limit: options.limit }),
161
+ },
162
+ listener,
163
+ );
164
+ }
165
+
166
+ async watchRun(
167
+ runId: string,
168
+ listener: (event: ClientEvent) => void,
169
+ options: { subscriptionId?: string; revision?: number } = {},
170
+ ): Promise<() => Promise<void>> {
171
+ return await this.subscribe(
172
+ "view.run.watch",
173
+ runId,
174
+ {
175
+ subscriptionId: options.subscriptionId ?? randomUUID(),
176
+ ...(options.revision === undefined ? {} : { revision: options.revision }),
177
+ },
178
+ listener,
179
+ );
180
+ }
181
+
182
+ async watchSession(
183
+ sessionId: string,
184
+ listener: (event: ClientEvent) => void,
185
+ options: { subscriptionId?: string } = {},
186
+ ): Promise<() => Promise<void>> {
187
+ return await this.subscribe(
188
+ "view.session.watch",
189
+ undefined,
190
+ { subscriptionId: options.subscriptionId ?? randomUUID(), sessionId },
191
+ listener,
192
+ );
193
+ }
194
+
195
+ async ensureAvailable(): Promise<ClientHello> {
196
+ try {
197
+ return await this.connect();
198
+ } catch (error) {
199
+ if (error instanceof WorkflowClientVersionError) throw error;
200
+ this.resetConnection();
201
+ this.startDetached();
202
+ }
203
+ const deadline = Date.now() + START_TIMEOUT_MS;
204
+ let lastError: unknown;
205
+ while (Date.now() < deadline) {
206
+ await delay(50);
207
+ try {
208
+ return await this.connect();
209
+ } catch (error) {
210
+ lastError = error;
211
+ this.resetConnection();
212
+ }
213
+ }
214
+ throw new Error(
215
+ `Workflow host did not become ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
216
+ );
217
+ }
218
+
219
+ async ensureRunning(): Promise<ClientResponse> {
220
+ await this.ensureAvailable();
221
+ return await this.request({ operation: "host.status" });
222
+ }
223
+
224
+ async readContent(
225
+ runId: string,
226
+ contentPath: string,
227
+ ): Promise<{
228
+ mediaType: string;
229
+ content: Buffer;
230
+ }> {
231
+ await this.ensureAvailable();
232
+ const chunks: Buffer[] = [];
233
+ let offset = 0;
234
+ let expectedBytes: number | undefined;
235
+ let expectedSha256: string | undefined;
236
+ let mediaType: string | undefined;
237
+ for (;;) {
238
+ const response = await this.request({
239
+ operation: "view.content",
240
+ runId,
241
+ payload: { path: contentPath, offset },
242
+ });
243
+ if (response.outcome !== "accepted" || !isRecord(response.receipt)) {
244
+ throw new Error(response.error ?? `Workflow content is unavailable: ${contentPath}`);
245
+ }
246
+ const receipt = response.receipt;
247
+ if (
248
+ receipt.path !== contentPath ||
249
+ receipt.offset !== offset ||
250
+ typeof receipt.data !== "string" ||
251
+ typeof receipt.mediaType !== "string" ||
252
+ typeof receipt.sha256 !== "string" ||
253
+ !Number.isSafeInteger(receipt.bytes) ||
254
+ (receipt.bytes as number) < 0 ||
255
+ !Number.isSafeInteger(receipt.nextOffset) ||
256
+ (receipt.nextOffset as number) < offset ||
257
+ typeof receipt.complete !== "boolean"
258
+ ) {
259
+ throw new Error("Workflow content receipt is invalid");
260
+ }
261
+ expectedBytes ??= receipt.bytes as number;
262
+ expectedSha256 ??= receipt.sha256;
263
+ mediaType ??= receipt.mediaType;
264
+ if (
265
+ expectedBytes !== receipt.bytes ||
266
+ expectedSha256 !== receipt.sha256 ||
267
+ mediaType !== receipt.mediaType
268
+ ) {
269
+ throw new Error("Workflow content identity changed during transfer");
270
+ }
271
+ const chunk = Buffer.from(receipt.data, "base64");
272
+ if (offset + chunk.byteLength !== receipt.nextOffset) {
273
+ throw new Error("Workflow content chunk offset is invalid");
274
+ }
275
+ chunks.push(chunk);
276
+ offset = receipt.nextOffset as number;
277
+ if (receipt.complete) break;
278
+ }
279
+ const content = Buffer.concat(chunks);
280
+ if (content.byteLength !== expectedBytes) throw new Error("Workflow content is incomplete");
281
+ const digest = createHash("sha256").update(content).digest("hex");
282
+ if (digest !== expectedSha256) throw new Error("Workflow content digest does not match");
283
+ return { mediaType: mediaType as string, content };
284
+ }
285
+
286
+ async hydrateContent(runId: string, value: JsonValue): Promise<JsonValue> {
287
+ return await this.hydrateContentValue(runId, value, new Map());
288
+ }
289
+
290
+ private async hydrateContentValue(
291
+ runId: string,
292
+ value: JsonValue,
293
+ reads: Map<string, Promise<{ mediaType: string; content: Buffer }>>,
294
+ ): Promise<JsonValue> {
295
+ if (isEscapedContent(value)) {
296
+ const escaped = value.$escaped;
297
+ if (isRecord(escaped)) {
298
+ const entries = await Promise.all(
299
+ Object.entries(escaped).map(
300
+ async ([key, item]) =>
301
+ [key, await this.hydrateContentValue(runId, item as JsonValue, reads)] as const,
302
+ ),
303
+ );
304
+ return Object.fromEntries(entries) as JsonValue;
305
+ }
306
+ return escaped;
307
+ }
308
+ if (isContentReference(value)) {
309
+ const contentPath = value.$artifact.path;
310
+ let read = reads.get(contentPath);
311
+ if (read === undefined) {
312
+ read = this.readContent(runId, contentPath);
313
+ reads.set(contentPath, read);
314
+ }
315
+ const loaded = await read;
316
+ const digest = createHash("sha256").update(loaded.content).digest("hex");
317
+ if (
318
+ loaded.mediaType !== value.$artifact.mediaType ||
319
+ loaded.content.byteLength !== value.$artifact.bytes ||
320
+ digest !== value.$artifact.sha256
321
+ ) {
322
+ throw new Error("Workflow content reference does not match its content");
323
+ }
324
+ const decoded =
325
+ loaded.mediaType === "application/json"
326
+ ? parseJson(loaded.content.toString("utf8"))
327
+ : loaded.content.toString("utf8");
328
+ return value.$artifact.opaque === true
329
+ ? decoded
330
+ : await this.hydrateContentValue(runId, decoded, reads);
331
+ }
332
+ if (Array.isArray(value)) {
333
+ return await Promise.all(
334
+ value.map(async (item) => await this.hydrateContentValue(runId, item, reads)),
335
+ );
336
+ }
337
+ if (isRecord(value)) {
338
+ const entries = await Promise.all(
339
+ Object.entries(value).map(
340
+ async ([key, item]) =>
341
+ [key, await this.hydrateContentValue(runId, item as JsonValue, reads)] as const,
342
+ ),
343
+ );
344
+ return Object.fromEntries(entries) as JsonValue;
345
+ }
346
+ return value;
347
+ }
348
+
349
+ async resolveWorkflow(options: {
350
+ cwd: string;
351
+ workflowRef: string;
352
+ timeoutMs?: number;
353
+ }): Promise<ResolvedWorkflowLaunch> {
354
+ return (await this.runResolver(
355
+ {
356
+ schema: "pi-workflows.resolve-request.v1",
357
+ cwd: options.cwd,
358
+ workflowRef: options.workflowRef,
359
+ },
360
+ options.cwd,
361
+ "pi-workflows.resolved-launch.v1",
362
+ options.timeoutMs,
363
+ )) as unknown as ResolvedWorkflowLaunch;
364
+ }
365
+
366
+ async resolveControllerInitialization(options: {
367
+ cwd: string;
368
+ controllerName: string;
369
+ spec: JsonValue;
370
+ timeoutMs?: number;
371
+ }): Promise<ResolvedControllerInitialization> {
372
+ return (await this.runResolver(
373
+ {
374
+ schema: "pi-workflows.controller-initialization-request.v1",
375
+ cwd: options.cwd,
376
+ controllerName: options.controllerName,
377
+ spec: options.spec,
378
+ },
379
+ options.cwd,
380
+ "pi-workflows.resolved-controller-initialization.v1",
381
+ options.timeoutMs,
382
+ )) as unknown as ResolvedControllerInitialization;
383
+ }
384
+
385
+ async resolveSettingsChange(options: {
386
+ cwd: string;
387
+ workflowRef: string;
388
+ definitionDigest: string;
389
+ mountPath: string;
390
+ current: JsonValue;
391
+ patch: JsonValue;
392
+ actorId: string;
393
+ timeoutMs?: number;
394
+ }): Promise<ResolvedSettingsChange> {
395
+ return (await this.runResolver(
396
+ {
397
+ schema: "pi-workflows.settings-validation-request.v1",
398
+ cwd: options.cwd,
399
+ workflowRef: options.workflowRef,
400
+ definitionDigest: options.definitionDigest,
401
+ mountPath: options.mountPath,
402
+ current: options.current,
403
+ patch: options.patch,
404
+ actorId: options.actorId,
405
+ },
406
+ options.cwd,
407
+ "pi-workflows.resolved-settings-change.v1",
408
+ options.timeoutMs,
409
+ )) as unknown as ResolvedSettingsChange;
410
+ }
411
+
412
+ async close(): Promise<void> {
413
+ if (this.closed) return;
414
+ this.closed = true;
415
+ if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer);
416
+ this.reconnectTimer = null;
417
+ this.subscriptions.clear();
418
+ const socket = this.socket;
419
+ this.resetConnection(new Error("Workflow client closed"));
420
+ if (socket !== null && !socket.destroyed) {
421
+ socket.end();
422
+ await Promise.race([once(socket, "close").then(() => undefined), delay(250)]);
423
+ socket.destroy();
424
+ }
425
+ }
426
+
427
+ private async subscribe(
428
+ operation: Subscription["operation"],
429
+ runId: string | undefined,
430
+ payload: JsonValue,
431
+ listener: (event: ClientEvent) => void,
432
+ ): Promise<() => Promise<void>> {
433
+ if (!isRecord(payload) || typeof payload.subscriptionId !== "string") {
434
+ throw new Error("Workflow subscription requires a subscriptionId");
435
+ }
436
+ const subscriptionId = payload.subscriptionId;
437
+ await this.connect();
438
+ this.subscriptions.set(subscriptionId, {
439
+ operation,
440
+ ...(runId === undefined ? {} : { runId }),
441
+ payload,
442
+ listener,
443
+ runListGeneration: 0,
444
+ });
445
+ try {
446
+ const response = await this.requestConnected({
447
+ operation,
448
+ ...(runId === undefined ? {} : { runId }),
449
+ payload,
450
+ });
451
+ if (response.outcome !== "accepted" && response.outcome !== "adopted") {
452
+ throw new Error(response.error ?? `Workflow subscription was ${response.outcome}`);
453
+ }
454
+ } catch (error) {
455
+ this.subscriptions.delete(subscriptionId);
456
+ throw error;
457
+ }
458
+ return async () => {
459
+ if (!this.subscriptions.delete(subscriptionId)) return;
460
+ if (this.socket !== null && !this.socket.destroyed) {
461
+ await this.request({
462
+ operation: "view.run.unwatch",
463
+ ...(runId === undefined ? {} : { runId }),
464
+ payload: { subscriptionId },
465
+ });
466
+ }
467
+ };
468
+ }
469
+
470
+ private async requestConnected(options: {
471
+ operation: ClientOperation;
472
+ requestId?: string;
473
+ idempotencyKey?: string;
474
+ runId?: string;
475
+ expectedRevision?: number;
476
+ payload?: JsonValue;
477
+ signal?: AbortSignal;
478
+ }): Promise<ClientResponse> {
479
+ const request: ClientRequest = {
480
+ schema: CLIENT_PROTOCOL_SCHEMA,
481
+ type: "request",
482
+ requestId: options.requestId ?? randomUUID(),
483
+ clientId: this.clientId,
484
+ operation: options.operation,
485
+ idempotencyKey: options.idempotencyKey ?? randomUUID(),
486
+ ...(options.runId === undefined ? {} : { runId: options.runId }),
487
+ ...(options.expectedRevision === undefined
488
+ ? {}
489
+ : { expectedRevision: options.expectedRevision }),
490
+ payload: options.payload ?? {},
491
+ };
492
+ return await this.send(request, options.signal);
493
+ }
494
+
495
+ private async send(request: ClientRequest, signal?: AbortSignal): Promise<ClientResponse> {
496
+ const socket = this.socket;
497
+ if (socket === null || socket.destroyed) throw new Error("Workflow host is unavailable");
498
+ if (signal?.aborted === true) throw abortReason(signal);
499
+ if (this.pending.has(request.requestId)) {
500
+ throw new Error(`Workflow request is already pending: ${request.requestId}`);
501
+ }
502
+ const response = new Promise<ClientResponse>((resolve, reject) => {
503
+ const removeAbort = (): void => signal?.removeEventListener("abort", onAbort);
504
+ const onAbort = (): void => {
505
+ if (!this.pending.delete(request.requestId)) return;
506
+ removeAbort();
507
+ reject(abortReason(signal as AbortSignal));
508
+ };
509
+ this.pending.set(request.requestId, {
510
+ resolve: (value) => {
511
+ removeAbort();
512
+ resolve(value);
513
+ },
514
+ reject: (error) => {
515
+ removeAbort();
516
+ reject(error);
517
+ },
518
+ });
519
+ signal?.addEventListener("abort", onAbort, { once: true });
520
+ });
521
+ try {
522
+ if (!socket.write(encodeProtocolLine(request))) await waitForSocketDrain(socket, signal);
523
+ } catch (error) {
524
+ const pending = this.pending.get(request.requestId);
525
+ this.pending.delete(request.requestId);
526
+ pending?.reject(toError(error));
527
+ }
528
+ return await response;
529
+ }
530
+
531
+ private async openConnection(): Promise<ClientHello> {
532
+ const socket = net.createConnection(this.endpoint);
533
+ const decoder = new NdjsonFrameDecoder();
534
+ this.socket = socket;
535
+ let helloResolve!: (hello: ClientHello) => void;
536
+ let helloReject!: (error: Error) => void;
537
+ const helloPromise = new Promise<ClientHello>((resolve, reject) => {
538
+ helloResolve = resolve;
539
+ helloReject = reject;
540
+ });
541
+ let receivedHello = false;
542
+
543
+ socket.on("data", (chunk: Buffer) => {
544
+ try {
545
+ for (const frame of decoder.push(chunk)) {
546
+ const message = parseClientMessage(frame);
547
+ if (!receivedHello) {
548
+ if (message.type !== "hello") throw new Error("Workflow host did not send hello first");
549
+ if (message.packageVersion !== CLIENT_PACKAGE_VERSION) {
550
+ throw new WorkflowClientVersionError(
551
+ `Workflow client version mismatch: host ${message.packageVersion}, client ${CLIENT_PACKAGE_VERSION}. Install matching pi-workflows and piw packages.`,
552
+ );
553
+ }
554
+ receivedHello = true;
555
+ this.hello = message;
556
+ helloResolve(message);
557
+ continue;
558
+ }
559
+ if (message.type === "response") {
560
+ const pending = this.pending.get(message.requestId);
561
+ if (pending === undefined) continue;
562
+ this.pending.delete(message.requestId);
563
+ pending.resolve(message);
564
+ } else if (message.type === "event") {
565
+ const subscription = this.subscriptions.get(message.subscriptionId);
566
+ if (
567
+ subscription !== undefined &&
568
+ subscription.operation === "view.run.watch" &&
569
+ isRecord(subscription.payload) &&
570
+ isRecord(message.payload) &&
571
+ Number.isSafeInteger(message.payload.revision)
572
+ ) {
573
+ subscription.payload = {
574
+ ...subscription.payload,
575
+ revision: message.payload.revision as number,
576
+ };
577
+ }
578
+ if (subscription !== undefined) {
579
+ void this.deliverSubscriptionEvent(subscription, message).catch(() => {
580
+ // A stale paged list is replaced by the next subscription snapshot.
581
+ });
582
+ }
583
+ } else {
584
+ throw new Error(`Unexpected workflow client message: ${message.type}`);
585
+ }
586
+ }
587
+ } catch (error) {
588
+ helloReject(toError(error));
589
+ socket.destroy();
590
+ }
591
+ });
592
+ socket.once("error", (error) => {
593
+ helloReject(error);
594
+ });
595
+ socket.once("close", () => {
596
+ if (!receivedHello) helloReject(new Error("Workflow host closed before hello"));
597
+ if (this.socket === socket) {
598
+ this.resetConnection(new Error("Workflow host connection closed"));
599
+ this.scheduleReconnect();
600
+ }
601
+ });
602
+
603
+ let connectTimer: ReturnType<typeof setTimeout> | undefined;
604
+ try {
605
+ await Promise.race([
606
+ once(socket, "connect"),
607
+ helloPromise.then(() => undefined),
608
+ new Promise<never>((_, reject) => {
609
+ connectTimer = setTimeout(
610
+ () => reject(new Error("Workflow host connection timed out")),
611
+ CONNECT_TIMEOUT_MS,
612
+ );
613
+ connectTimer.unref?.();
614
+ }),
615
+ ]);
616
+ const hello = await Promise.race([
617
+ helloPromise,
618
+ delay(CONNECT_TIMEOUT_MS).then(() => {
619
+ throw new Error("Workflow host hello timed out");
620
+ }),
621
+ ]);
622
+ await this.restoreSubscriptions();
623
+ return hello;
624
+ } catch (error) {
625
+ socket.destroy();
626
+ throw error;
627
+ } finally {
628
+ if (connectTimer !== undefined) clearTimeout(connectTimer);
629
+ }
630
+ }
631
+
632
+ private async deliverSubscriptionEvent(
633
+ subscription: Subscription,
634
+ event: ClientEvent,
635
+ ): Promise<void> {
636
+ if (subscription.operation !== "view.runs.watch" || event.event !== "runs") {
637
+ subscription.listener(event);
638
+ return;
639
+ }
640
+ const first = parseWorkflowRunListPage(event.payload);
641
+ const subscriptionId = requireSubscriptionId(subscription.payload);
642
+ const generation = subscription.runListGeneration + 1;
643
+ subscription.runListGeneration = generation;
644
+ if (first.start !== 0) throw new Error("Workflow run list snapshot must start at zero");
645
+ const items: WorkflowRunSummary[] = [...first.items];
646
+ let cursor = first.start + first.items.length;
647
+ while (cursor < first.total) {
648
+ if (
649
+ subscription.runListGeneration !== generation ||
650
+ this.subscriptions.get(subscriptionId) !== subscription
651
+ )
652
+ return;
653
+ if (items.length === 0) throw new Error("Workflow run list page made no progress");
654
+ const response = await this.requestConnected({
655
+ operation: "view.runs.page",
656
+ payload: {
657
+ cursor,
658
+ revision: first.revision,
659
+ ...(isRecord(subscription.payload) && typeof subscription.payload.limit === "number"
660
+ ? { limit: subscription.payload.limit }
661
+ : {}),
662
+ },
663
+ });
664
+ if (response.outcome === "conflict") return;
665
+ if (response.outcome !== "accepted") {
666
+ throw new Error(response.error ?? `Workflow run list page was ${response.outcome}`);
667
+ }
668
+ const page = parseWorkflowRunListPage(response.receipt);
669
+ if (
670
+ page.revision !== first.revision ||
671
+ page.total !== first.total ||
672
+ page.start !== cursor ||
673
+ page.items.length === 0
674
+ ) {
675
+ throw new Error("Workflow run list page does not continue the snapshot");
676
+ }
677
+ items.push(...page.items);
678
+ cursor += page.items.length;
679
+ }
680
+ if (
681
+ subscription.runListGeneration !== generation ||
682
+ this.subscriptions.get(subscriptionId) !== subscription
683
+ )
684
+ return;
685
+ subscription.listener({ ...event, payload: items as unknown as JsonValue });
686
+ }
687
+
688
+ private async restoreSubscriptions(): Promise<void> {
689
+ for (const subscription of this.subscriptions.values()) {
690
+ const response = await this.requestConnected({
691
+ operation: subscription.operation,
692
+ ...(subscription.runId === undefined ? {} : { runId: subscription.runId }),
693
+ payload: subscription.payload,
694
+ });
695
+ if (response.outcome !== "accepted" && response.outcome !== "adopted") {
696
+ throw new Error(response.error ?? `Workflow subscription was ${response.outcome}`);
697
+ }
698
+ }
699
+ }
700
+
701
+ private resetConnection(reason = new Error("Workflow host is unavailable")): void {
702
+ const socket = this.socket;
703
+ this.socket = null;
704
+ this.hello = null;
705
+ this.connectTask = null;
706
+ if (socket !== null && !socket.destroyed) socket.destroy();
707
+ for (const pending of this.pending.values()) pending.reject(reason);
708
+ this.pending.clear();
709
+ if (!this.closed) {
710
+ for (const [subscriptionId, subscription] of this.subscriptions) {
711
+ try {
712
+ subscription.listener({
713
+ schema: CLIENT_PROTOCOL_SCHEMA,
714
+ type: "event",
715
+ subscriptionId,
716
+ event: "unavailable",
717
+ payload: { message: "Workflow host connection is unavailable." },
718
+ });
719
+ } catch {
720
+ // One renderer cannot block reconnection for other subscriptions.
721
+ }
722
+ }
723
+ }
724
+ }
725
+
726
+ private scheduleReconnect(): void {
727
+ if (this.closed || this.subscriptions.size === 0 || this.reconnectTimer !== null) return;
728
+ this.reconnectTimer = setTimeout(() => {
729
+ this.reconnectTimer = null;
730
+ void this.connect().catch(() => this.scheduleReconnect());
731
+ }, RECONNECT_DELAY_MS);
732
+ this.reconnectTimer.unref?.();
733
+ }
734
+
735
+ private async runResolver(
736
+ request: JsonValue,
737
+ cwd: string,
738
+ expectedSchema: string,
739
+ timeoutMs = RESOLVER_TIMEOUT_MS,
740
+ ): Promise<JsonValue> {
741
+ const builtEntry = fileURLToPath(new URL("../host/resolver-entry.js", import.meta.url));
742
+ const sourceEntry = fileURLToPath(new URL("../host/resolver-entry.ts", import.meta.url));
743
+ const args = fs.existsSync(builtEntry)
744
+ ? [builtEntry]
745
+ : ["--import", createRequire(import.meta.url).resolve("tsx"), sourceEntry];
746
+ const child = spawn(process.execPath, args, {
747
+ cwd,
748
+ detached: process.platform !== "win32",
749
+ stdio: ["pipe", "pipe", "pipe"],
750
+ env: { ...process.env, ...this.env },
751
+ });
752
+ let stdout: Buffer<ArrayBufferLike> = Buffer.alloc(0);
753
+ let stderr: Buffer<ArrayBufferLike> = Buffer.alloc(0);
754
+ let outputError: Error | undefined;
755
+ const append = (current: Buffer, chunk: Buffer): Buffer => {
756
+ const next = Buffer.concat([current, chunk]);
757
+ if (next.byteLength > 1024 * 1024) {
758
+ outputError = new Error("Workflow resolver output exceeds 1 MiB");
759
+ stopProcessGroup(child.pid);
760
+ return current;
761
+ }
762
+ return next;
763
+ };
764
+ child.stdout.on("data", (chunk: Buffer) => {
765
+ stdout = append(stdout, chunk);
766
+ });
767
+ child.stderr.on("data", (chunk: Buffer) => {
768
+ stderr = append(stderr, chunk);
769
+ });
770
+ child.stdin.end(canonicalJson(request));
771
+ const timeout = setTimeout(() => stopProcessGroup(child.pid), timeoutMs);
772
+ timeout.unref?.();
773
+ const [code, signal] = (await once(child, "exit")) as [number | null, NodeJS.Signals | null];
774
+ clearTimeout(timeout);
775
+ if (outputError !== undefined) throw outputError;
776
+ if (code !== 0) {
777
+ const detail = stderr.toString("utf8").trim().slice(0, 2_000);
778
+ throw new Error(
779
+ detail || `Workflow resolver exited before completion (code ${code}, signal ${signal})`,
780
+ );
781
+ }
782
+ const value = parseJson(stdout.toString("utf8").trimEnd());
783
+ if (!isRecord(value) || value.schema !== expectedSchema) {
784
+ throw new Error("Workflow resolver returned an invalid result envelope");
785
+ }
786
+ return value as JsonValue;
787
+ }
788
+
789
+ private startDetached(): void {
790
+ const builtEntry = fileURLToPath(new URL("../host/host-entry.js", import.meta.url));
791
+ const sourceEntry = fileURLToPath(new URL("../host/host-entry.ts", import.meta.url));
792
+ const entry = this.hostEntryPath ?? builtEntry;
793
+ const args =
794
+ this.hostEntryPath === undefined && !fs.existsSync(builtEntry)
795
+ ? ["--import", createRequire(import.meta.url).resolve("tsx"), sourceEntry]
796
+ : [entry];
797
+ const child = spawn(process.execPath, [...args, "--database", this.databasePath], {
798
+ detached: true,
799
+ stdio: "ignore",
800
+ env: { ...process.env, ...this.env },
801
+ });
802
+ child.unref();
803
+ }
804
+ }
805
+
806
+ function requireSubscriptionId(value: JsonValue): string {
807
+ if (!isRecord(value) || typeof value.subscriptionId !== "string") {
808
+ throw new Error("Workflow subscription requires a subscriptionId");
809
+ }
810
+ return value.subscriptionId;
811
+ }
812
+
813
+ function parseWorkflowRunListPage(value: unknown): WorkflowRunListPage {
814
+ if (
815
+ !isRecord(value) ||
816
+ value.schema !== "pi-workflows.run-list-page.v1" ||
817
+ typeof value.revision !== "string" ||
818
+ !Number.isSafeInteger(value.start) ||
819
+ !Number.isSafeInteger(value.total) ||
820
+ (value.start as number) < 0 ||
821
+ (value.total as number) < 0 ||
822
+ !Array.isArray(value.items) ||
823
+ !value.items.every(
824
+ (item) =>
825
+ isRecord(item) && typeof item.runId === "string" && typeof item.workflowName === "string",
826
+ )
827
+ ) {
828
+ throw new Error("Workflow host returned an invalid run list page");
829
+ }
830
+ return value as unknown as WorkflowRunListPage;
831
+ }
832
+
833
+ function isWorkflowRunView(value: unknown, runId: string): value is WorkflowRunView {
834
+ return (
835
+ isRecord(value) &&
836
+ value.schema === "pi-workflows.run-view.v1" &&
837
+ value.runId === runId &&
838
+ Number.isSafeInteger(value.revision)
839
+ );
840
+ }
841
+
842
+ function abortReason(signal: AbortSignal): Error {
843
+ return signal.reason instanceof Error
844
+ ? signal.reason
845
+ : new Error("Workflow request was cancelled");
846
+ }
847
+
848
+ function waitForSocketDrain(socket: Socket, signal?: AbortSignal): Promise<void> {
849
+ if (signal?.aborted === true) return Promise.reject(abortReason(signal));
850
+ if (socket.destroyed) return Promise.reject(new Error("Workflow host connection closed"));
851
+ return new Promise((resolve, reject) => {
852
+ const cleanup = (): void => {
853
+ socket.off("drain", onDrain);
854
+ socket.off("close", onClose);
855
+ socket.off("error", onError);
856
+ signal?.removeEventListener("abort", onAbort);
857
+ };
858
+ const settle = (error?: Error): void => {
859
+ cleanup();
860
+ if (error === undefined) resolve();
861
+ else reject(error);
862
+ };
863
+ const onDrain = (): void => settle();
864
+ const onClose = (): void => settle(new Error("Workflow host connection closed"));
865
+ const onError = (error: Error): void => settle(error);
866
+ const onAbort = (): void => settle(abortReason(signal as AbortSignal));
867
+ socket.once("drain", onDrain);
868
+ socket.once("close", onClose);
869
+ socket.once("error", onError);
870
+ signal?.addEventListener("abort", onAbort, { once: true });
871
+ if (signal?.aborted === true) onAbort();
872
+ else if (socket.destroyed) onClose();
873
+ });
874
+ }
875
+
876
+ function runtimePackageVersion(): string {
877
+ const parsed = JSON.parse(
878
+ fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
879
+ ) as { version?: unknown };
880
+ if (typeof parsed.version !== "string" || parsed.version.length === 0) {
881
+ throw new Error("Pi Workflows package version is missing");
882
+ }
883
+ return parsed.version;
884
+ }
885
+
886
+ function stopProcessGroup(pid: number | undefined): void {
887
+ if (pid === undefined) return;
888
+ try {
889
+ if (process.platform !== "win32") process.kill(-pid, "SIGKILL");
890
+ else process.kill(pid, "SIGKILL");
891
+ } catch {
892
+ // The resolver has already exited.
893
+ }
894
+ }
895
+
896
+ function delay(ms: number): Promise<void> {
897
+ return new Promise((resolve) => setTimeout(resolve, ms));
898
+ }
899
+
900
+ type ContentReference = {
901
+ $artifact: {
902
+ path: string;
903
+ mediaType: string;
904
+ bytes: number;
905
+ sha256: string;
906
+ opaque?: boolean;
907
+ };
908
+ };
909
+
910
+ function isEscapedContent(value: JsonValue): value is JsonValue & { $escaped: JsonValue } {
911
+ return isRecord(value) && Object.keys(value).length === 1 && Object.hasOwn(value, "$escaped");
912
+ }
913
+
914
+ function isContentReference(value: JsonValue): value is ContentReference {
915
+ if (!isRecord(value) || Object.keys(value).length !== 1 || !isRecord(value.$artifact)) {
916
+ return false;
917
+ }
918
+ const artifact = value.$artifact;
919
+ return (
920
+ typeof artifact.path === "string" &&
921
+ typeof artifact.mediaType === "string" &&
922
+ Number.isSafeInteger(artifact.bytes) &&
923
+ (artifact.bytes as number) >= 0 &&
924
+ typeof artifact.sha256 === "string" &&
925
+ (artifact.opaque === undefined || typeof artifact.opaque === "boolean")
926
+ );
927
+ }
928
+
929
+ function isRecord(value: unknown): value is Record<string, unknown> {
930
+ return typeof value === "object" && value !== null && !Array.isArray(value);
931
+ }
932
+
933
+ function toError(error: unknown): Error {
934
+ return error instanceof Error ? error : new Error(String(error));
935
+ }