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