@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
@@ -1,8 +1,9 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { once } from "node:events";
3
2
  import fs from "node:fs";
4
3
  import net, {} from "node:net";
5
4
  import path from "node:path";
5
+ import { WorkflowClient } from "../client/client.js";
6
+ import { CLIENT_PROTOCOL_SCHEMA, encodeProtocolLine, clientSocketPath, NdjsonFrameDecoder, parseClientRequest, } from "../client/protocol.js";
6
7
  import { applyStatusPatch } from "../controllers/conditions.js";
7
8
  import { ResourceConflictError } from "../controllers/errors.js";
8
9
  import { controllerFileStem, controllerSearchDirs, discoverControllers, } from "../controllers/loader.js";
@@ -10,20 +11,21 @@ import { SqliteControllerStore, } from "../controllers/sqlite.js";
10
11
  import { ControllerWorkflowCoordinator, } from "../controllers/workflows.js";
11
12
  import { StateDatabase, workflowStatePath } from "../state/database.js";
12
13
  import { canonicalJson } from "../state/json.js";
14
+ import { pruneState } from "../state/prune.js";
13
15
  import { recordViewerDeltas } from "../state/viewer.js";
14
16
  import { errorMessage } from "../workflows/errors.js";
15
17
  import { HumanDecisionStore } from "../workflows/human-decision.js";
16
18
  import {} from "../workflows/settings.js";
17
19
  import { WorkflowRunStore } from "../workflows/store.js";
18
20
  import { validateWorkflowUpdate } from "../workflows/updates.js";
19
- import { WorkflowHostClient } from "./client.js";
20
21
  import { ControllerWorkerSupervisor } from "./controller-worker-supervisor.js";
21
22
  import { HostProcessRegistry, matchesProcessIdentity, processParentPid, processStartIdentity, } from "./processes.js";
22
- import { encodeProtocolLine, hostSocketPath, NdjsonFrameDecoder, parseHostRequest, } from "./protocol.js";
23
23
  import { HostStateStore, } from "./state.js";
24
+ import { HostViewStore, WORKFLOW_PAGE_KINDS, } from "./view.js";
24
25
  import { WorkflowWorkerSupervisor } from "./worker-supervisor.js";
25
26
  const HOST_LEASE_MS = 30_000;
26
27
  const HOST_RENEW_MS = 10_000;
28
+ const PACKAGE_VERSION = runtimePackageVersion();
27
29
  const CLAIM_POLL_MS = 2_000;
28
30
  const RUN_CLAIM_LEASE_MS = 30_000;
29
31
  const PRESENTATION_CLAIM_LEASE_MS = 10_000;
@@ -45,22 +47,26 @@ export class WorkflowHost {
45
47
  queue;
46
48
  decisions;
47
49
  runStore;
50
+ views;
48
51
  registry;
49
52
  activeRuns = new Map();
50
53
  workerDescendants = new Map();
51
54
  activeControllers = new Map();
52
55
  controlClaims = new Map();
53
56
  activationTasks = new Map();
57
+ maintenanceCommands = new Map();
54
58
  pendingStarts = new Set();
55
59
  pendingRunClaims = new Map();
56
60
  pendingResumes = new Set();
57
61
  blockedRuns = new Set();
58
62
  deliveryClaims = new Map();
59
63
  sockets = new Set();
64
+ connections = new Map();
60
65
  server = null;
61
66
  claim = null;
62
67
  heartbeatTimer = null;
63
68
  pollTimer = null;
69
+ viewTimer = null;
64
70
  stopping = false;
65
71
  started = false;
66
72
  controllerPollActive = false;
@@ -70,7 +76,7 @@ export class WorkflowHost {
70
76
  this.hostId = options.runnerId ?? `host-${randomUUID()}`;
71
77
  this.databasePath = path.resolve(options.databasePath ?? workflowStatePath());
72
78
  this.stateDirectory = path.join(path.dirname(this.databasePath), "host");
73
- this.socketPath = hostSocketPath(this.databasePath);
79
+ this.socketPath = clientSocketPath(this.databasePath);
74
80
  this.lockPath = path.join(this.stateDirectory, "host.lock.json");
75
81
  this.state = new StateDatabase({ filePath: this.databasePath });
76
82
  this.hostState = new HostStateStore(this.databasePath, { state: this.state });
@@ -90,6 +96,7 @@ export class WorkflowHost {
90
96
  },
91
97
  snapshotLifecycle: (context) => this.applyLifecycleProjection(context),
92
98
  });
99
+ this.views = new HostViewStore(this.state, this.queue, this.hostState, this.runStore, (runId) => this.activeRuns.has(runId));
93
100
  this.registry = options.registry ?? new HostProcessRegistry(this.stateDirectory);
94
101
  }
95
102
  get endpoint() {
@@ -156,8 +163,11 @@ export class WorkflowHost {
156
163
  clearInterval(this.heartbeatTimer);
157
164
  if (this.pollTimer !== null)
158
165
  clearInterval(this.pollTimer);
166
+ if (this.viewTimer !== null)
167
+ clearInterval(this.viewTimer);
159
168
  this.heartbeatTimer = null;
160
169
  this.pollTimer = null;
170
+ this.viewTimer = null;
161
171
  const server = this.server;
162
172
  this.server = null;
163
173
  await this.closeServer(server);
@@ -179,6 +189,7 @@ export class WorkflowHost {
179
189
  }
180
190
  }));
181
191
  await Promise.allSettled(this.activationTasks.values());
192
+ await Promise.allSettled(this.maintenanceCommands.values());
182
193
  this.registry.killAll();
183
194
  this.deliveryClaims.clear();
184
195
  if (this.claim !== null)
@@ -258,6 +269,11 @@ export class WorkflowHost {
258
269
  void this.claimControllerOne();
259
270
  }, this.options.claimPollMs ?? CLAIM_POLL_MS);
260
271
  this.pollTimer.unref?.();
272
+ this.viewTimer = setInterval(() => {
273
+ this.views.expireActivity();
274
+ this.publishViews();
275
+ }, 250);
276
+ this.viewTimer.unref?.();
261
277
  }
262
278
  async listen() {
263
279
  if (process.platform !== "win32")
@@ -293,8 +309,20 @@ export class WorkflowHost {
293
309
  }
294
310
  handleConnection(socket) {
295
311
  this.sockets.add(socket);
312
+ const connection = {
313
+ id: `connection-${randomUUID()}`,
314
+ socket,
315
+ subscriptions: new Map(),
316
+ publishing: false,
317
+ };
318
+ this.connections.set(socket, connection);
319
+ socket.write(encodeProtocolLine({
320
+ schema: CLIENT_PROTOCOL_SCHEMA,
321
+ type: "hello",
322
+ connectionId: connection.id,
323
+ packageVersion: PACKAGE_VERSION,
324
+ }));
296
325
  const decoder = new NdjsonFrameDecoder();
297
- let processing = Promise.resolve();
298
326
  socket.on("data", (chunk) => {
299
327
  let frames;
300
328
  try {
@@ -305,14 +333,13 @@ export class WorkflowHost {
305
333
  return;
306
334
  }
307
335
  for (const frame of frames) {
308
- processing = processing
309
- .then(async () => {
310
- const request = parseHostRequest(frame);
311
- const response = this.handleRequest(request);
336
+ void (async () => {
337
+ const request = parseClientRequest(frame);
338
+ const response = await this.handleClientRequest(connection, request);
312
339
  if (!socket.write(encodeProtocolLine(response)))
313
- await once(socket, "drain");
314
- })
315
- .catch(() => {
340
+ await waitForSocketDrain(socket);
341
+ this.publishConnection(connection);
342
+ })().catch(() => {
316
343
  socket.destroy();
317
344
  });
318
345
  }
@@ -323,12 +350,243 @@ export class WorkflowHost {
323
350
  });
324
351
  socket.on("close", () => {
325
352
  this.sockets.delete(socket);
353
+ this.connections.delete(socket);
354
+ this.views.clearConnection(connection.id);
355
+ });
356
+ }
357
+ async handleClientRequest(connection, request) {
358
+ try {
359
+ switch (request.operation) {
360
+ case "view.runs.watch":
361
+ this.addSubscription(connection, request, "runs");
362
+ return clientResponse(request.requestId, "accepted", { subscribed: true });
363
+ case "view.runs.page": {
364
+ const payload = requireRecord(request.payload, "view.runs.page payload");
365
+ const revision = requireString(payload.revision, "revision");
366
+ const page = this.views.list(requireNonNegativeInteger(payload.cursor, "cursor"), payload.limit === undefined
367
+ ? undefined
368
+ : requirePositiveInteger(payload.limit, "limit"));
369
+ return page.revision === revision
370
+ ? clientResponse(request.requestId, "accepted", toJsonValue(page))
371
+ : clientResponse(request.requestId, "conflict", toJsonValue(page), "Workflow run list changed while paging");
372
+ }
373
+ case "view.run.get": {
374
+ const view = this.views.run(requireRunId(request));
375
+ return view === null
376
+ ? clientResponse(request.requestId, "notFound", undefined, "Workflow run not found")
377
+ : clientResponse(request.requestId, "accepted", toJsonValue(view), undefined, view.revision);
378
+ }
379
+ case "view.run.watch": {
380
+ const runId = requireRunId(request);
381
+ if (this.views.run(runId) === null) {
382
+ return clientResponse(request.requestId, "notFound", undefined, "Workflow run not found");
383
+ }
384
+ this.addSubscription(connection, request, "run", runId);
385
+ return clientResponse(request.requestId, "accepted", { subscribed: true });
386
+ }
387
+ case "view.session.watch": {
388
+ const payload = requireRecord(request.payload, "view.session.watch payload");
389
+ this.addSubscription(connection, request, "session", requireString(payload.sessionId, "sessionId"));
390
+ return clientResponse(request.requestId, "accepted", { subscribed: true });
391
+ }
392
+ case "view.run.unwatch": {
393
+ const payload = requireRecord(request.payload, "view.run.unwatch payload");
394
+ connection.subscriptions.delete(requireString(payload.subscriptionId, "subscriptionId"));
395
+ return clientResponse(request.requestId, "accepted", { subscribed: false });
396
+ }
397
+ case "view.page": {
398
+ const payload = requireRecord(request.payload, "view.page payload");
399
+ const kind = requireString(payload.kind, "kind");
400
+ if (!WORKFLOW_PAGE_KINDS.includes(kind)) {
401
+ throw new Error("view.page kind is invalid");
402
+ }
403
+ const view = this.views.page(requireRunId(request), {
404
+ kind: kind,
405
+ cursor: requireNonNegativeInteger(payload.cursor, "cursor"),
406
+ });
407
+ return view === null
408
+ ? clientResponse(request.requestId, "notFound", undefined, "Workflow run not found")
409
+ : clientResponse(request.requestId, "accepted", runPageReceipt(view, kind, requireNonNegativeInteger(payload.cursor, "cursor")), undefined, view.revision);
410
+ }
411
+ case "view.content": {
412
+ const payload = requireRecord(request.payload, "view.content payload");
413
+ const content = this.views.content(requireRunId(request), requireString(payload.path, "path"), requireNonNegativeInteger(payload.offset, "offset"));
414
+ return content === null
415
+ ? clientResponse(request.requestId, "notFound", undefined, "Workflow content not found")
416
+ : clientResponse(request.requestId, "accepted", content);
417
+ }
418
+ case "activity.report": {
419
+ this.views.reportActivity(connection.id, parseActivityReport(request.payload));
420
+ return clientResponse(request.requestId, "accepted", { recorded: true });
421
+ }
422
+ case "interaction.submit":
423
+ return await this.submitInteractionAndWait(request);
424
+ case "state.status":
425
+ return clientResponse(request.requestId, "accepted", this.stateStatusReceipt());
426
+ case "state.verify":
427
+ this.state.integrityCheck();
428
+ return clientResponse(request.requestId, "accepted", { valid: true });
429
+ case "state.backup":
430
+ return await this.executeMaintenanceCommand(request, async () => {
431
+ const payload = requireRecord(request.payload, "state.backup payload");
432
+ const destination = requireAbsolutePath(payload.destination, "destination");
433
+ await this.state.backup(destination);
434
+ return { destination };
435
+ });
436
+ case "state.prune":
437
+ return await this.executeMaintenanceCommand(request, async () => {
438
+ const payload = requireRecord(request.payload, "state.prune payload");
439
+ const before = requireString(payload.before, "before");
440
+ const apply = requireBoolean(payload.apply, "apply");
441
+ const backupPath = payload.backupPath === undefined
442
+ ? undefined
443
+ : requireAbsolutePath(payload.backupPath, "backupPath");
444
+ const report = await pruneState(this.state, this.databasePath, {
445
+ before,
446
+ apply,
447
+ ...(backupPath === undefined ? {} : { backupPath }),
448
+ });
449
+ return toJsonValue(report);
450
+ });
451
+ default:
452
+ return this.handleRequest(request);
453
+ }
454
+ }
455
+ catch (error) {
456
+ return clientResponse(request.requestId, "rejected", undefined, errorMessage(error));
457
+ }
458
+ }
459
+ async executeMaintenanceCommand(request, operation) {
460
+ const adopted = this.hostState.adoptCommand(request);
461
+ if (adopted !== undefined)
462
+ return adopted;
463
+ const claim = this.claim;
464
+ if (claim === null || this.stopping) {
465
+ return clientResponse(request.requestId, "unavailable", undefined, "Workflow host is stopping");
466
+ }
467
+ const key = canonicalJson([request.clientId, request.idempotencyKey]);
468
+ const active = this.maintenanceCommands.get(key);
469
+ if (active !== undefined) {
470
+ await active;
471
+ return (this.hostState.adoptCommand(request) ??
472
+ clientResponse(request.requestId, "unavailable", undefined, "Workflow maintenance command did not complete durably"));
473
+ }
474
+ const execution = (async () => {
475
+ let result;
476
+ try {
477
+ result = { outcome: "accepted", receipt: await operation() };
478
+ }
479
+ catch (error) {
480
+ result = { outcome: "rejected", error: errorMessage(error) };
481
+ }
482
+ return this.hostState.executeCommand(request, claim.epoch, () => result);
483
+ })();
484
+ this.maintenanceCommands.set(key, execution);
485
+ try {
486
+ return await execution;
487
+ }
488
+ finally {
489
+ if (this.maintenanceCommands.get(key) === execution) {
490
+ this.maintenanceCommands.delete(key);
491
+ }
492
+ }
493
+ }
494
+ addSubscription(connection, request, kind, target) {
495
+ const payload = requireRecord(request.payload, `${request.operation} payload`);
496
+ const id = requireString(payload.subscriptionId, "subscriptionId");
497
+ const limit = kind === "runs" && payload.limit !== undefined
498
+ ? requirePositiveInteger(payload.limit, "limit")
499
+ : undefined;
500
+ connection.subscriptions.set(id, {
501
+ id,
502
+ kind,
503
+ ...(target === undefined ? {} : { target }),
504
+ ...(limit === undefined ? {} : { limit }),
505
+ revision: 0,
326
506
  });
327
507
  }
508
+ publishViews() {
509
+ for (const connection of this.connections.values())
510
+ void this.publishConnection(connection);
511
+ }
512
+ async publishConnection(connection) {
513
+ if (connection.publishing || connection.socket.destroyed)
514
+ return;
515
+ connection.publishing = true;
516
+ try {
517
+ for (const subscription of connection.subscriptions.values()) {
518
+ const payload = subscription.kind === "runs"
519
+ ? toJsonValue(this.views.list(0, subscription.limit))
520
+ : subscription.kind === "run"
521
+ ? toJsonValue(this.views.run(subscription.target ?? ""))
522
+ : toJsonValue(this.views.session(subscription.target ?? ""));
523
+ const digest = createHash("sha256").update(canonicalJson(payload)).digest("hex");
524
+ if (subscription.digest === digest)
525
+ continue;
526
+ subscription.digest = digest;
527
+ subscription.revision += 1;
528
+ const event = {
529
+ schema: CLIENT_PROTOCOL_SCHEMA,
530
+ type: "event",
531
+ subscriptionId: subscription.id,
532
+ event: subscription.kind === "runs"
533
+ ? "runs"
534
+ : subscription.kind === "run"
535
+ ? "run_snapshot"
536
+ : "session_snapshot",
537
+ revision: subscription.revision,
538
+ ...(subscription.kind === "run" && subscription.target !== undefined
539
+ ? { runId: subscription.target }
540
+ : {}),
541
+ payload,
542
+ };
543
+ if (!connection.socket.write(encodeProtocolLine(event))) {
544
+ await waitForSocketDrain(connection.socket);
545
+ if (connection.socket.destroyed)
546
+ return;
547
+ }
548
+ }
549
+ }
550
+ catch (error) {
551
+ this.log(`client view error: ${errorMessage(error)}`);
552
+ connection.socket.destroy();
553
+ }
554
+ finally {
555
+ connection.publishing = false;
556
+ }
557
+ }
558
+ async submitInteractionAndWait(request) {
559
+ const started = this.submitInteraction(request);
560
+ if (started.outcome !== "accepted" && started.outcome !== "adopted") {
561
+ return clientResponse(request.requestId, started.outcome, started.receipt, started.error);
562
+ }
563
+ const payload = requireRecord(request.payload, "interaction payload");
564
+ const requestId = requireString(payload.requestId, "requestId");
565
+ const startedReceipt = requireRecord(started.receipt, "interaction submission receipt");
566
+ const submissionId = requireString(startedReceipt.submissionId, "submissionId");
567
+ for (;;) {
568
+ if (this.stopping) {
569
+ return clientResponse(request.requestId, "unavailable", undefined, "Workflow host stopped while validating the submission");
570
+ }
571
+ const submission = this.hostState.interactionSubmission(requestId, submissionId);
572
+ if (submission?.outcome === "accepted" || submission?.outcome === "adopted") {
573
+ return clientResponse(request.requestId, started.outcome, submission.receipt ?? { requestId, submissionId });
574
+ }
575
+ if (submission?.outcome === "rejected") {
576
+ const receipt = submission.receipt ?? { requestId, submissionId };
577
+ const detail = isObjectRecord(receipt) && typeof receipt.error === "string"
578
+ ? receipt.error
579
+ : "Workflow step output failed validation";
580
+ return clientResponse(request.requestId, "rejected", receipt, detail);
581
+ }
582
+ await hostDelay(25);
583
+ }
584
+ }
328
585
  handleRequest(request) {
329
586
  if (this.claim === null || this.stopping) {
330
587
  return {
331
- schema: "pi-workflows.host-response.v1",
588
+ schema: CLIENT_PROTOCOL_SCHEMA,
589
+ type: "response",
332
590
  requestId: request.requestId,
333
591
  outcome: "unavailable",
334
592
  error: "Workflow host is stopping",
@@ -341,7 +599,8 @@ export class WorkflowHost {
341
599
  }
342
600
  catch (error) {
343
601
  response = {
344
- schema: "pi-workflows.host-response.v1",
602
+ schema: CLIENT_PROTOCOL_SCHEMA,
603
+ type: "response",
345
604
  requestId: request.requestId,
346
605
  outcome: "rejected",
347
606
  error: errorMessage(error),
@@ -402,15 +661,37 @@ export class WorkflowHost {
402
661
  case "run.pause": {
403
662
  const runId = requireRunId(request);
404
663
  const active = this.activeRuns.get(runId);
405
- if (active === undefined)
406
- return { outcome: "rejected", error: "Run is not active" };
407
- this.commitActivePause(active);
408
- active.control = "pause";
409
- afterCommit.push(() => void active.supervisor.stop("cancelled"));
410
- return { outcome: "accepted", receipt: { runId, status: "parked", paused: true } };
664
+ if (active !== undefined) {
665
+ if (active.control === "pause") {
666
+ return { outcome: "adopted", receipt: { runId, status: "parked", paused: true } };
667
+ }
668
+ if (active.control === "handoff") {
669
+ const paused = this.queue.pauseParkedWorkflowRun({ runId });
670
+ return paused
671
+ ? { outcome: "accepted", receipt: { runId, status: "parked", paused: true } }
672
+ : { outcome: "rejected", error: "Run handoff is not pausable" };
673
+ }
674
+ if (active.control !== undefined) {
675
+ return { outcome: "rejected", error: `Run is already handling ${active.control}` };
676
+ }
677
+ this.commitActivePause(active);
678
+ active.control = "pause";
679
+ afterCommit.push(() => void active.supervisor.stop("cancelled"));
680
+ return { outcome: "accepted", receipt: { runId, status: "parked", paused: true } };
681
+ }
682
+ const paused = this.queue.pauseParkedWorkflowRun({ runId });
683
+ return paused
684
+ ? { outcome: "accepted", receipt: { runId, status: "parked", paused: true } }
685
+ : { outcome: "rejected", error: "Run is not pausable" };
411
686
  }
412
687
  case "run.resume": {
413
688
  const runId = requireRunId(request);
689
+ if (this.queue.resumePausedInteraction({ runId })) {
690
+ return {
691
+ outcome: "accepted",
692
+ receipt: { runId, status: "parked", paused: false, waitingForInteraction: true },
693
+ };
694
+ }
414
695
  if (this.activeRuns.has(runId) || this.pendingStarts.has(runId)) {
415
696
  return { outcome: "adopted", receipt: { runId, active: true } };
416
697
  }
@@ -447,18 +728,41 @@ export class WorkflowHost {
447
728
  return this.executeControllerOperation(request);
448
729
  case "interaction.update": {
449
730
  const payload = requireRecord(request.payload, "interaction update payload");
731
+ if (payload.validatePresentation === true) {
732
+ const interaction = this.hostState.getInteraction(requireString(payload.requestId, "requestId"));
733
+ const expectedRevision = requireNonNegativeInteger(request.expectedRevision, "expectedRevision");
734
+ const expiresAt = interaction?.presentationClaimExpiresAt;
735
+ const live = interaction !== undefined &&
736
+ interaction.runId === requireRunId(request) &&
737
+ interaction.status === "presenting" &&
738
+ interaction.presenterId === request.clientId &&
739
+ interaction.revision === expectedRevision &&
740
+ interaction.presentationSessionEntryId === null &&
741
+ typeof expiresAt === "string" &&
742
+ Date.parse(expiresAt) > Date.now() &&
743
+ !this.queue.isWorkflowRunPaused(interaction.runId);
744
+ return { outcome: "accepted", receipt: { live } };
745
+ }
450
746
  if (payload.claimPresentation === true) {
451
- const interaction = this.hostState.claimInteractionPresentation({
452
- requestId: requireString(payload.requestId, "requestId"),
453
- expectedRevision: requireNonNegativeInteger(request.expectedRevision, "expectedRevision"),
454
- presenterId: request.clientId,
455
- leaseMs: PRESENTATION_CLAIM_LEASE_MS,
456
- });
457
- return {
458
- outcome: "accepted",
459
- revision: interaction.revision,
460
- receipt: interaction,
461
- };
747
+ try {
748
+ const interaction = this.hostState.claimInteractionPresentation({
749
+ requestId: requireString(payload.requestId, "requestId"),
750
+ expectedRevision: requireNonNegativeInteger(request.expectedRevision, "expectedRevision"),
751
+ presenterId: request.clientId,
752
+ leaseMs: PRESENTATION_CLAIM_LEASE_MS,
753
+ });
754
+ return {
755
+ outcome: "accepted",
756
+ revision: interaction.revision,
757
+ receipt: interaction,
758
+ };
759
+ }
760
+ catch (error) {
761
+ if (errorMessage(error) === "Interactive request presentation claim conflict") {
762
+ return { outcome: "conflict", error: errorMessage(error) };
763
+ }
764
+ throw error;
765
+ }
462
766
  }
463
767
  if (typeof payload.sessionEntryId === "string") {
464
768
  const interaction = this.hostState.markInteractionPresented({
@@ -478,6 +782,20 @@ export class WorkflowHost {
478
782
  return this.submitInteraction(request);
479
783
  case "decision.answer":
480
784
  return this.answerDecision(request, afterCommit);
785
+ case "view.runs.watch":
786
+ case "view.runs.page":
787
+ case "view.run.get":
788
+ case "view.run.watch":
789
+ case "view.run.unwatch":
790
+ case "view.page":
791
+ case "view.content":
792
+ case "view.session.watch":
793
+ case "activity.report":
794
+ case "state.status":
795
+ case "state.verify":
796
+ case "state.backup":
797
+ case "state.prune":
798
+ throw new Error(`${request.operation} must use the live client connection`);
481
799
  }
482
800
  }
483
801
  executeControllerOperation(request) {
@@ -578,6 +896,27 @@ export class WorkflowHost {
578
896
  )`),
579
897
  };
580
898
  }
899
+ stateStatusReceipt() {
900
+ const count = (sql, ...params) => {
901
+ const row = this.state.connection.prepare(sql).get(...params);
902
+ return typeof row?.count === "number" ? row.count : 0;
903
+ };
904
+ const now = Date.now();
905
+ return {
906
+ sizeBytes: fs.statSync(this.databasePath).size,
907
+ counts: {
908
+ resources: count("SELECT COUNT(*) AS count FROM resources"),
909
+ runs: count("SELECT COUNT(*) AS count FROM runs"),
910
+ controllers: count("SELECT COUNT(*) AS count FROM controller_resources"),
911
+ decisions: count("SELECT COUNT(*) AS count FROM human_decisions"),
912
+ settingsScopes: count("SELECT COUNT(*) AS count FROM workflow_settings"),
913
+ pendingInteractions: count("SELECT COUNT(*) AS count FROM interactive_requests WHERE status IN ('pending', 'presenting')"),
914
+ pendingFollowUps: count("SELECT COUNT(*) AS count FROM workflow_follow_ups WHERE status IN ('queued', 'pending_presentation', 'ready')"),
915
+ activeLeases: count("SELECT COUNT(*) AS count FROM leases WHERE owner_id IS NOT NULL AND expires_at > ?", now),
916
+ unsettledEffects: count("SELECT COUNT(*) AS count FROM effects WHERE status IN ('pending', 'applying', 'ambiguous')"),
917
+ },
918
+ };
919
+ }
581
920
  rememberDeliveryClaim(options) {
582
921
  const now = Date.now();
583
922
  for (const [claimId, claim] of this.deliveryClaims) {
@@ -585,11 +924,9 @@ export class WorkflowHost {
585
924
  this.deliveryClaims.delete(claimId);
586
925
  }
587
926
  const claimId = randomUUID();
588
- this.deliveryClaims.set(claimId, {
589
- ...options,
590
- expiresAt: now + DELIVERY_CLAIM_LEASE_MS,
591
- });
592
- return claimId;
927
+ const expiresAt = now + DELIVERY_CLAIM_LEASE_MS;
928
+ this.deliveryClaims.set(claimId, { ...options, expiresAt });
929
+ return { claimId, claimExpiresAt: new Date(expiresAt).toISOString() };
593
930
  }
594
931
  deliveryClaim(claimId, clientId, kind, resourceId, targetSessionId) {
595
932
  const claim = this.deliveryClaims.get(claimId);
@@ -604,8 +941,34 @@ export class WorkflowHost {
604
941
  }
605
942
  return claim;
606
943
  }
944
+ validateDelivery(command, payload, kind) {
945
+ const resourceId = requireString(payload.resourceId, "resourceId");
946
+ const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
947
+ const claimId = requireString(payload.claimId, "claimId");
948
+ const claim = this.deliveryClaim(claimId, command.clientId, kind, resourceId, targetSessionId);
949
+ if (claim === undefined) {
950
+ return { outcome: "accepted", receipt: { claimId, live: false } };
951
+ }
952
+ const live = kind === "notification"
953
+ ? this.queue.isWorkflowNotificationClaimLive({
954
+ notificationId: resourceId,
955
+ targetSessionId,
956
+ claimToken: claim.token,
957
+ })
958
+ : this.queue.isWorkflowTurnIntentClaimLive({
959
+ intentId: resourceId,
960
+ targetSessionId,
961
+ claimToken: claim.token,
962
+ });
963
+ if (!live)
964
+ this.deliveryClaims.delete(claimId);
965
+ return { outcome: "accepted", receipt: { claimId, live } };
966
+ }
607
967
  claimNotification(command) {
608
968
  const payload = requireRecord(command.payload, "notification claim payload");
969
+ if (payload.validateClaim === true) {
970
+ return this.validateDelivery(command, payload, "notification");
971
+ }
609
972
  const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
610
973
  const token = randomUUID();
611
974
  const notification = this.queue.claimPendingWorkflowNotifications({
@@ -617,7 +980,7 @@ export class WorkflowHost {
617
980
  if (notification === undefined) {
618
981
  return { outcome: "accepted", receipt: { notification: null } };
619
982
  }
620
- const claimId = this.rememberDeliveryClaim({
983
+ const claim = this.rememberDeliveryClaim({
621
984
  clientId: command.clientId,
622
985
  token,
623
986
  targetSessionId,
@@ -626,7 +989,7 @@ export class WorkflowHost {
626
989
  });
627
990
  return {
628
991
  outcome: "accepted",
629
- receipt: { claimId, notification },
992
+ receipt: { ...claim, notification },
630
993
  };
631
994
  }
632
995
  deliverNotification(command) {
@@ -650,6 +1013,9 @@ export class WorkflowHost {
650
1013
  }
651
1014
  claimTurn(command) {
652
1015
  const payload = requireRecord(command.payload, "turn claim payload");
1016
+ if (payload.validateClaim === true) {
1017
+ return this.validateDelivery(command, payload, "turn");
1018
+ }
653
1019
  const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
654
1020
  const token = randomUUID();
655
1021
  const intent = this.queue.claimEligibleWorkflowTurnIntents({
@@ -661,7 +1027,7 @@ export class WorkflowHost {
661
1027
  if (intent === undefined) {
662
1028
  return { outcome: "accepted", receipt: { turn: null } };
663
1029
  }
664
- const claimId = this.rememberDeliveryClaim({
1030
+ const claim = this.rememberDeliveryClaim({
665
1031
  clientId: command.clientId,
666
1032
  token,
667
1033
  targetSessionId,
@@ -670,7 +1036,7 @@ export class WorkflowHost {
670
1036
  });
671
1037
  return {
672
1038
  outcome: "accepted",
673
- receipt: { claimId, turn: intent },
1039
+ receipt: { ...claim, turn: intent },
674
1040
  };
675
1041
  }
676
1042
  resolveTurn(command) {
@@ -740,6 +1106,9 @@ export class WorkflowHost {
740
1106
  if (interaction === undefined || interaction.kind !== "decision") {
741
1107
  return { outcome: "notFound", error: `Decision request not found: ${requestId}` };
742
1108
  }
1109
+ if (this.queue.isWorkflowRunPaused(interaction.runId)) {
1110
+ return { outcome: "conflict", error: "Workflow run is paused" };
1111
+ }
743
1112
  const request = interaction.contract;
744
1113
  const response = payload.response;
745
1114
  const accepted = this.decisions.acceptSync(request, {
@@ -878,6 +1247,9 @@ export class WorkflowHost {
878
1247
  if (interaction === undefined || interaction.runId !== runId) {
879
1248
  return { outcome: "notFound", error: `Interactive request not found: ${requestId}` };
880
1249
  }
1250
+ if (this.queue.isWorkflowRunPaused(runId)) {
1251
+ return { outcome: "conflict", error: "Workflow run is paused" };
1252
+ }
881
1253
  const attemptId = requireString(payload.attempt, "attempt");
882
1254
  const nodeId = requireString(payload.step, "step");
883
1255
  const storedContract = requireRecord(interaction.contract, "interactive contract");
@@ -931,6 +1303,9 @@ export class WorkflowHost {
931
1303
  if (current === undefined || current.runId !== requireRunId(request)) {
932
1304
  return { outcome: "notFound", error: `Interactive request not found: ${requestId}` };
933
1305
  }
1306
+ if (this.queue.isWorkflowRunPaused(current.runId)) {
1307
+ return { outcome: "conflict", error: "Workflow run is paused" };
1308
+ }
934
1309
  const attemptId = requireString(payload.attempt, "attempt");
935
1310
  const nodeId = requireString(payload.step, "step");
936
1311
  const storedContract = requireRecord(current.contract, "interactive contract");
@@ -954,6 +1329,9 @@ export class WorkflowHost {
954
1329
  },
955
1330
  });
956
1331
  const interaction = submission.interaction;
1332
+ const receipt = isObjectRecord(submission.receipt)
1333
+ ? { ...submission.receipt, requestId, submissionId: submission.submissionId }
1334
+ : { requestId, submissionId: submission.submissionId, receipt: submission.receipt };
957
1335
  if (this.activeRuns.has(interaction.runId) || this.activationTasks.has(interaction.runId)) {
958
1336
  this.pendingResumes.add(interaction.runId);
959
1337
  }
@@ -973,7 +1351,7 @@ export class WorkflowHost {
973
1351
  return {
974
1352
  outcome: submission.outcome,
975
1353
  revision: interaction.revision,
976
- receipt: submission.receipt,
1354
+ receipt,
977
1355
  };
978
1356
  }
979
1357
  async claimControllerOne() {
@@ -1256,7 +1634,7 @@ export class WorkflowHost {
1256
1634
  const projectPath = this.queue.workflowRunProjectPath(request.runId);
1257
1635
  if (projectPath === undefined)
1258
1636
  throw new Error("Workflow run project is missing");
1259
- const resolver = new WorkflowHostClient({
1637
+ const resolver = new WorkflowClient({
1260
1638
  databasePath: this.databasePath,
1261
1639
  ...(this.options.env === undefined ? {} : { env: this.options.env }),
1262
1640
  });
@@ -1303,7 +1681,7 @@ export class WorkflowHost {
1303
1681
  const existing = this.queue.getWorkflowRun(request.runId);
1304
1682
  if (existing !== undefined)
1305
1683
  return controllerWorkflowResult(existing);
1306
- const resolver = new WorkflowHostClient({
1684
+ const resolver = new WorkflowClient({
1307
1685
  databasePath: this.databasePath,
1308
1686
  ...(this.options.env === undefined ? {} : { env: this.options.env }),
1309
1687
  });
@@ -2364,6 +2742,129 @@ function controllerPathAllowed(projectPath, controllerName, controllerPath) {
2364
2742
  }
2365
2743
  return controllerSearchDirs({ cwd: projectPath }).some(({ dir }) => path.dirname(controllerPath) === path.resolve(dir));
2366
2744
  }
2745
+ function waitForSocketDrain(socket) {
2746
+ if (socket.destroyed)
2747
+ return Promise.resolve();
2748
+ return new Promise((resolve, reject) => {
2749
+ const cleanup = () => {
2750
+ socket.off("drain", onDrain);
2751
+ socket.off("close", onClose);
2752
+ socket.off("error", onError);
2753
+ };
2754
+ const onDrain = () => {
2755
+ cleanup();
2756
+ resolve();
2757
+ };
2758
+ const onClose = () => {
2759
+ cleanup();
2760
+ resolve();
2761
+ };
2762
+ const onError = (error) => {
2763
+ cleanup();
2764
+ reject(error);
2765
+ };
2766
+ socket.once("drain", onDrain);
2767
+ socket.once("close", onClose);
2768
+ socket.once("error", onError);
2769
+ if (socket.destroyed)
2770
+ onClose();
2771
+ });
2772
+ }
2773
+ function hostDelay(ms) {
2774
+ return new Promise((resolve) => setTimeout(resolve, ms));
2775
+ }
2776
+ function runPageReceipt(view, kind, cursor) {
2777
+ const base = {
2778
+ schema: "pi-workflows.run-page.v1",
2779
+ runId: view.runId,
2780
+ revision: view.revision,
2781
+ kind,
2782
+ cursor,
2783
+ };
2784
+ if (kind === "steps") {
2785
+ const state = requireRecord(view.state, "run state view");
2786
+ return {
2787
+ ...base,
2788
+ start: view.stepStart,
2789
+ total: view.stepTotal,
2790
+ items: Array.isArray(state.steps) ? state.steps : [],
2791
+ graphSteps: view.graphSteps,
2792
+ graphCursor: view.graphCursor,
2793
+ takenTransitions: view.takenTransitions,
2794
+ };
2795
+ }
2796
+ if (kind === "trace" || kind === "trace_at_step") {
2797
+ return { ...base, ...requireRecord(view.tracePage, "trace page") };
2798
+ }
2799
+ if (kind === "session_entries" || kind === "session_events") {
2800
+ const session = requireRecord(view.session, "session view");
2801
+ const page = requireRecord(kind === "session_entries" ? session.entryPage : session.eventPage, "session page");
2802
+ return {
2803
+ ...base,
2804
+ ...page,
2805
+ ...(kind === "session_events" ? { replayCheckpoint: session.replayCheckpoint } : {}),
2806
+ };
2807
+ }
2808
+ if (kind === "settings") {
2809
+ return {
2810
+ ...base,
2811
+ start: view.settingsStart,
2812
+ total: view.settingsTotal,
2813
+ items: view.settingsScopes,
2814
+ };
2815
+ }
2816
+ if (kind === "follow_ups") {
2817
+ const queue = isObjectRecord(view.followUpQueue) ? view.followUpQueue : {};
2818
+ return {
2819
+ ...base,
2820
+ start: view.followUpStart,
2821
+ total: view.followUpTotal,
2822
+ items: Array.isArray(queue.items) ? queue.items : [],
2823
+ };
2824
+ }
2825
+ return {
2826
+ ...base,
2827
+ start: view.updateStart,
2828
+ total: view.updateTotal,
2829
+ items: view.updates,
2830
+ };
2831
+ }
2832
+ function clientResponse(requestId, outcome, receipt, error, revision) {
2833
+ return {
2834
+ schema: CLIENT_PROTOCOL_SCHEMA,
2835
+ type: "response",
2836
+ requestId,
2837
+ outcome,
2838
+ ...(revision === undefined ? {} : { revision }),
2839
+ ...(receipt === undefined ? {} : { receipt }),
2840
+ ...(error === undefined ? {} : { error: boundedClientError(error) }),
2841
+ };
2842
+ }
2843
+ function boundedClientError(error) {
2844
+ const singleLine = error.replaceAll(/[\r\n\t]+/gu, " ").trim();
2845
+ if (singleLine.length === 0)
2846
+ return "Workflow request failed";
2847
+ return singleLine.length <= 1_000 ? singleLine : `${singleLine.slice(0, 997)}...`;
2848
+ }
2849
+ function parseActivityReport(payload) {
2850
+ const value = requireRecord(payload, "activity.report payload");
2851
+ const state = requireString(value.state, "state");
2852
+ if (state !== "started" && state !== "refresh" && state !== "settled") {
2853
+ throw new Error("activity state must be started, refresh, or settled");
2854
+ }
2855
+ return {
2856
+ sessionId: requireString(value.sessionId, "sessionId"),
2857
+ runId: requireString(value.runId, "runId"),
2858
+ requestId: requireString(value.requestId, "requestId"),
2859
+ deliveryId: requireString(value.deliveryId, "deliveryId"),
2860
+ sessionEntryId: requireString(value.sessionEntryId, "sessionEntryId"),
2861
+ sequence: requireNonNegativeInteger(value.sequence, "sequence"),
2862
+ state,
2863
+ };
2864
+ }
2865
+ function toJsonValue(value) {
2866
+ return JSON.parse(canonicalJson(value));
2867
+ }
2367
2868
  function payloadLimit(payload) {
2368
2869
  if (typeof payload !== "object" || payload === null || Array.isArray(payload))
2369
2870
  return 100;
@@ -2375,6 +2876,9 @@ function requireRunId(request) {
2375
2876
  throw new Error(`${request.operation} requires runId`);
2376
2877
  return request.runId;
2377
2878
  }
2879
+ function isObjectRecord(value) {
2880
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2881
+ }
2378
2882
  function requireRecord(value, name) {
2379
2883
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
2380
2884
  throw new Error(`${name} must be an object`);
@@ -2393,6 +2897,17 @@ function requireAbsolutePath(value, name) {
2393
2897
  throw new Error(`${name} must be an absolute path`);
2394
2898
  return parsed;
2395
2899
  }
2900
+ function requireBoolean(value, name) {
2901
+ if (typeof value !== "boolean")
2902
+ throw new Error(`${name} must be a boolean`);
2903
+ return value;
2904
+ }
2905
+ function requirePositiveInteger(value, name) {
2906
+ if (!Number.isSafeInteger(value) || value <= 0) {
2907
+ throw new Error(`${name} must be a positive integer`);
2908
+ }
2909
+ return value;
2910
+ }
2396
2911
  function requireNonNegativeInteger(value, name) {
2397
2912
  if (!Number.isSafeInteger(value) || value < 0) {
2398
2913
  throw new Error(`${name} must be a non-negative integer`);
@@ -2404,6 +2919,13 @@ function isRevisionRow(value) {
2404
2919
  value !== null &&
2405
2920
  typeof value.revision === "number");
2406
2921
  }
2922
+ function runtimePackageVersion() {
2923
+ const parsed = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
2924
+ if (typeof parsed.version !== "string" || parsed.version.length === 0) {
2925
+ throw new Error("Package version is missing");
2926
+ }
2927
+ return parsed.version;
2928
+ }
2407
2929
  function isLockRecord(value) {
2408
2930
  return (typeof value === "object" &&
2409
2931
  value !== null &&