@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,20 @@
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, { type Socket } from "node:net";
5
4
  import path from "node:path";
5
+ import { WorkflowClient } from "../client/client.js";
6
+ import {
7
+ CLIENT_PROTOCOL_SCHEMA,
8
+ encodeProtocolLine,
9
+ clientSocketPath,
10
+ NdjsonFrameDecoder,
11
+ parseClientRequest,
12
+ type ClientEvent,
13
+ type ClientOutcome,
14
+ type ClientRequest,
15
+ type ClientResponse,
16
+ } from "../client/protocol.js";
17
+ import type { WorkflowRunView } from "../client/view.js";
6
18
  import { applyStatusPatch } from "../controllers/conditions.js";
7
19
  import { ResourceConflictError } from "../controllers/errors.js";
8
20
  import {
@@ -31,6 +43,7 @@ import {
31
43
  } from "../controllers/workflows.js";
32
44
  import { StateDatabase, workflowStatePath } from "../state/database.js";
33
45
  import { canonicalJson, type JsonValue } from "../state/json.js";
46
+ import { pruneState } from "../state/prune.js";
34
47
  import { recordViewerDeltas } from "../state/viewer.js";
35
48
  import { errorMessage } from "../workflows/errors.js";
36
49
  import { HumanDecisionStore } from "../workflows/human-decision.js";
@@ -49,7 +62,6 @@ import type {
49
62
  WorkflowUpdateInput,
50
63
  } from "../workflows/types.js";
51
64
  import { validateWorkflowUpdate } from "../workflows/updates.js";
52
- import { WorkflowHostClient } from "./client.js";
53
65
  import type {
54
66
  ControllerWorkerLaunchEnvelope,
55
67
  ControllerWorkerMessage,
@@ -62,14 +74,6 @@ import {
62
74
  processParentPid,
63
75
  processStartIdentity,
64
76
  } from "./processes.js";
65
- import {
66
- encodeProtocolLine,
67
- hostSocketPath,
68
- NdjsonFrameDecoder,
69
- parseHostRequest,
70
- type HostRequest,
71
- type HostResponse,
72
- } from "./protocol.js";
73
77
  import {
74
78
  HostStateStore,
75
79
  type HostClaim,
@@ -77,11 +81,18 @@ import {
77
81
  type InteractiveSubmissionRecord,
78
82
  type WorkerLaunchEnvelope,
79
83
  } from "./state.js";
84
+ import {
85
+ HostViewStore,
86
+ WORKFLOW_PAGE_KINDS,
87
+ type OriginActivityReport,
88
+ type WorkflowPageKind,
89
+ } from "./view.js";
80
90
  import type { WorkerMessage, WorkerResponse } from "./worker-protocol.js";
81
91
  import { WorkflowWorkerSupervisor } from "./worker-supervisor.js";
82
92
 
83
93
  const HOST_LEASE_MS = 30_000;
84
94
  const HOST_RENEW_MS = 10_000;
95
+ const PACKAGE_VERSION = runtimePackageVersion();
85
96
  const CLAIM_POLL_MS = 2_000;
86
97
  const RUN_CLAIM_LEASE_MS = 30_000;
87
98
  const PRESENTATION_CLAIM_LEASE_MS = 10_000;
@@ -130,6 +141,22 @@ type DeliveryClaim = {
130
141
  expiresAt: number;
131
142
  };
132
143
 
144
+ type ClientSubscription = {
145
+ id: string;
146
+ kind: "runs" | "run" | "session";
147
+ target?: string;
148
+ revision: number;
149
+ limit?: number;
150
+ digest?: string;
151
+ };
152
+
153
+ type ClientConnection = {
154
+ id: string;
155
+ socket: Socket;
156
+ subscriptions: Map<string, ClientSubscription>;
157
+ publishing: boolean;
158
+ };
159
+
133
160
  type ActiveController = {
134
161
  key: string;
135
162
  projectPath: string;
@@ -156,22 +183,26 @@ export class WorkflowHost {
156
183
  private readonly queue: SqliteControllerStore;
157
184
  private readonly decisions: HumanDecisionStore;
158
185
  private readonly runStore: WorkflowRunStore;
186
+ private readonly views: HostViewStore;
159
187
  private readonly registry: HostProcessRegistry;
160
188
  private readonly activeRuns = new Map<string, ActiveRun>();
161
189
  private readonly workerDescendants = new Map<string, Set<number>>();
162
190
  private readonly activeControllers = new Map<string, ActiveController>();
163
191
  private readonly controlClaims = new Map<string, string>();
164
192
  private readonly activationTasks = new Map<string, Promise<void>>();
193
+ private readonly maintenanceCommands = new Map<string, Promise<ClientResponse>>();
165
194
  private readonly pendingStarts = new Set<string>();
166
195
  private readonly pendingRunClaims = new Map<string, string>();
167
196
  private readonly pendingResumes = new Set<string>();
168
197
  private readonly blockedRuns = new Set<string>();
169
198
  private readonly deliveryClaims = new Map<string, DeliveryClaim>();
170
199
  private readonly sockets = new Set<Socket>();
200
+ private readonly connections = new Map<Socket, ClientConnection>();
171
201
  private server: net.Server | null = null;
172
202
  private claim: HostClaim | null = null;
173
203
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
174
204
  private pollTimer: ReturnType<typeof setInterval> | null = null;
205
+ private viewTimer: ReturnType<typeof setInterval> | null = null;
175
206
  private stopping = false;
176
207
  private started = false;
177
208
  private controllerPollActive = false;
@@ -182,7 +213,7 @@ export class WorkflowHost {
182
213
  this.hostId = options.runnerId ?? `host-${randomUUID()}`;
183
214
  this.databasePath = path.resolve(options.databasePath ?? workflowStatePath());
184
215
  this.stateDirectory = path.join(path.dirname(this.databasePath), "host");
185
- this.socketPath = hostSocketPath(this.databasePath);
216
+ this.socketPath = clientSocketPath(this.databasePath);
186
217
  this.lockPath = path.join(this.stateDirectory, "host.lock.json");
187
218
  this.state = new StateDatabase({ filePath: this.databasePath });
188
219
  this.hostState = new HostStateStore(this.databasePath, { state: this.state });
@@ -202,6 +233,9 @@ export class WorkflowHost {
202
233
  },
203
234
  snapshotLifecycle: (context) => this.applyLifecycleProjection(context),
204
235
  });
236
+ this.views = new HostViewStore(this.state, this.queue, this.hostState, this.runStore, (runId) =>
237
+ this.activeRuns.has(runId),
238
+ );
205
239
  this.registry = options.registry ?? new HostProcessRegistry(this.stateDirectory);
206
240
  }
207
241
 
@@ -264,8 +298,10 @@ export class WorkflowHost {
264
298
  this.stopping = true;
265
299
  if (this.heartbeatTimer !== null) clearInterval(this.heartbeatTimer);
266
300
  if (this.pollTimer !== null) clearInterval(this.pollTimer);
301
+ if (this.viewTimer !== null) clearInterval(this.viewTimer);
267
302
  this.heartbeatTimer = null;
268
303
  this.pollTimer = null;
304
+ this.viewTimer = null;
269
305
  const server = this.server;
270
306
  this.server = null;
271
307
  await this.closeServer(server);
@@ -295,6 +331,7 @@ export class WorkflowHost {
295
331
  }),
296
332
  );
297
333
  await Promise.allSettled(this.activationTasks.values());
334
+ await Promise.allSettled(this.maintenanceCommands.values());
298
335
  this.registry.killAll();
299
336
  this.deliveryClaims.clear();
300
337
  if (this.claim !== null) this.hostState.releaseHost(this.claim);
@@ -378,6 +415,11 @@ export class WorkflowHost {
378
415
  void this.claimControllerOne();
379
416
  }, this.options.claimPollMs ?? CLAIM_POLL_MS);
380
417
  this.pollTimer.unref?.();
418
+ this.viewTimer = setInterval(() => {
419
+ this.views.expireActivity();
420
+ this.publishViews();
421
+ }, 250);
422
+ this.viewTimer.unref?.();
381
423
  }
382
424
 
383
425
  private async listen(): Promise<void> {
@@ -412,8 +454,22 @@ export class WorkflowHost {
412
454
 
413
455
  private handleConnection(socket: Socket): void {
414
456
  this.sockets.add(socket);
457
+ const connection: ClientConnection = {
458
+ id: `connection-${randomUUID()}`,
459
+ socket,
460
+ subscriptions: new Map(),
461
+ publishing: false,
462
+ };
463
+ this.connections.set(socket, connection);
464
+ socket.write(
465
+ encodeProtocolLine({
466
+ schema: CLIENT_PROTOCOL_SCHEMA,
467
+ type: "hello",
468
+ connectionId: connection.id,
469
+ packageVersion: PACKAGE_VERSION,
470
+ }),
471
+ );
415
472
  const decoder = new NdjsonFrameDecoder();
416
- let processing = Promise.resolve();
417
473
  socket.on("data", (chunk: Buffer) => {
418
474
  let frames: Buffer[];
419
475
  try {
@@ -423,15 +479,14 @@ export class WorkflowHost {
423
479
  return;
424
480
  }
425
481
  for (const frame of frames) {
426
- processing = processing
427
- .then(async () => {
428
- const request = parseHostRequest(frame);
429
- const response = this.handleRequest(request);
430
- if (!socket.write(encodeProtocolLine(response))) await once(socket, "drain");
431
- })
432
- .catch(() => {
433
- socket.destroy();
434
- });
482
+ void (async () => {
483
+ const request = parseClientRequest(frame);
484
+ const response = await this.handleClientRequest(connection, request);
485
+ if (!socket.write(encodeProtocolLine(response))) await waitForSocketDrain(socket);
486
+ this.publishConnection(connection);
487
+ })().catch(() => {
488
+ socket.destroy();
489
+ });
435
490
  }
436
491
  });
437
492
  socket.on("error", (error) => {
@@ -440,27 +495,331 @@ export class WorkflowHost {
440
495
  });
441
496
  socket.on("close", () => {
442
497
  this.sockets.delete(socket);
498
+ this.connections.delete(socket);
499
+ this.views.clearConnection(connection.id);
500
+ });
501
+ }
502
+
503
+ private async handleClientRequest(
504
+ connection: ClientConnection,
505
+ request: ClientRequest,
506
+ ): Promise<ClientResponse> {
507
+ try {
508
+ switch (request.operation) {
509
+ case "view.runs.watch":
510
+ this.addSubscription(connection, request, "runs");
511
+ return clientResponse(request.requestId, "accepted", { subscribed: true });
512
+ case "view.runs.page": {
513
+ const payload = requireRecord(request.payload, "view.runs.page payload");
514
+ const revision = requireString(payload.revision, "revision");
515
+ const page = this.views.list(
516
+ requireNonNegativeInteger(payload.cursor, "cursor"),
517
+ payload.limit === undefined
518
+ ? undefined
519
+ : requirePositiveInteger(payload.limit, "limit"),
520
+ );
521
+ return page.revision === revision
522
+ ? clientResponse(request.requestId, "accepted", toJsonValue(page))
523
+ : clientResponse(
524
+ request.requestId,
525
+ "conflict",
526
+ toJsonValue(page),
527
+ "Workflow run list changed while paging",
528
+ );
529
+ }
530
+ case "view.run.get": {
531
+ const view = this.views.run(requireRunId(request));
532
+ return view === null
533
+ ? clientResponse(request.requestId, "notFound", undefined, "Workflow run not found")
534
+ : clientResponse(
535
+ request.requestId,
536
+ "accepted",
537
+ toJsonValue(view),
538
+ undefined,
539
+ view.revision,
540
+ );
541
+ }
542
+ case "view.run.watch": {
543
+ const runId = requireRunId(request);
544
+ if (this.views.run(runId) === null) {
545
+ return clientResponse(
546
+ request.requestId,
547
+ "notFound",
548
+ undefined,
549
+ "Workflow run not found",
550
+ );
551
+ }
552
+ this.addSubscription(connection, request, "run", runId);
553
+ return clientResponse(request.requestId, "accepted", { subscribed: true });
554
+ }
555
+ case "view.session.watch": {
556
+ const payload = requireRecord(request.payload, "view.session.watch payload");
557
+ this.addSubscription(
558
+ connection,
559
+ request,
560
+ "session",
561
+ requireString(payload.sessionId, "sessionId"),
562
+ );
563
+ return clientResponse(request.requestId, "accepted", { subscribed: true });
564
+ }
565
+ case "view.run.unwatch": {
566
+ const payload = requireRecord(request.payload, "view.run.unwatch payload");
567
+ connection.subscriptions.delete(requireString(payload.subscriptionId, "subscriptionId"));
568
+ return clientResponse(request.requestId, "accepted", { subscribed: false });
569
+ }
570
+ case "view.page": {
571
+ const payload = requireRecord(request.payload, "view.page payload");
572
+ const kind = requireString(payload.kind, "kind");
573
+ if (!WORKFLOW_PAGE_KINDS.includes(kind as WorkflowPageKind)) {
574
+ throw new Error("view.page kind is invalid");
575
+ }
576
+ const view = this.views.page(requireRunId(request), {
577
+ kind: kind as WorkflowPageKind,
578
+ cursor: requireNonNegativeInteger(payload.cursor, "cursor"),
579
+ });
580
+ return view === null
581
+ ? clientResponse(request.requestId, "notFound", undefined, "Workflow run not found")
582
+ : clientResponse(
583
+ request.requestId,
584
+ "accepted",
585
+ runPageReceipt(
586
+ view,
587
+ kind as WorkflowPageKind,
588
+ requireNonNegativeInteger(payload.cursor, "cursor"),
589
+ ),
590
+ undefined,
591
+ view.revision,
592
+ );
593
+ }
594
+ case "view.content": {
595
+ const payload = requireRecord(request.payload, "view.content payload");
596
+ const content = this.views.content(
597
+ requireRunId(request),
598
+ requireString(payload.path, "path"),
599
+ requireNonNegativeInteger(payload.offset, "offset"),
600
+ );
601
+ return content === null
602
+ ? clientResponse(request.requestId, "notFound", undefined, "Workflow content not found")
603
+ : clientResponse(request.requestId, "accepted", content);
604
+ }
605
+ case "activity.report": {
606
+ this.views.reportActivity(connection.id, parseActivityReport(request.payload));
607
+ return clientResponse(request.requestId, "accepted", { recorded: true });
608
+ }
609
+ case "interaction.submit":
610
+ return await this.submitInteractionAndWait(request);
611
+ case "state.status":
612
+ return clientResponse(request.requestId, "accepted", this.stateStatusReceipt());
613
+ case "state.verify":
614
+ this.state.integrityCheck();
615
+ return clientResponse(request.requestId, "accepted", { valid: true });
616
+ case "state.backup":
617
+ return await this.executeMaintenanceCommand(request, async () => {
618
+ const payload = requireRecord(request.payload, "state.backup payload");
619
+ const destination = requireAbsolutePath(payload.destination, "destination");
620
+ await this.state.backup(destination);
621
+ return { destination };
622
+ });
623
+ case "state.prune":
624
+ return await this.executeMaintenanceCommand(request, async () => {
625
+ const payload = requireRecord(request.payload, "state.prune payload");
626
+ const before = requireString(payload.before, "before");
627
+ const apply = requireBoolean(payload.apply, "apply");
628
+ const backupPath =
629
+ payload.backupPath === undefined
630
+ ? undefined
631
+ : requireAbsolutePath(payload.backupPath, "backupPath");
632
+ const report = await pruneState(this.state, this.databasePath, {
633
+ before,
634
+ apply,
635
+ ...(backupPath === undefined ? {} : { backupPath }),
636
+ });
637
+ return toJsonValue(report);
638
+ });
639
+ default:
640
+ return this.handleRequest(request);
641
+ }
642
+ } catch (error) {
643
+ return clientResponse(request.requestId, "rejected", undefined, errorMessage(error));
644
+ }
645
+ }
646
+
647
+ private async executeMaintenanceCommand(
648
+ request: ClientRequest,
649
+ operation: () => Promise<JsonValue>,
650
+ ): Promise<ClientResponse> {
651
+ const adopted = this.hostState.adoptCommand(request);
652
+ if (adopted !== undefined) return adopted;
653
+ const claim = this.claim;
654
+ if (claim === null || this.stopping) {
655
+ return clientResponse(
656
+ request.requestId,
657
+ "unavailable",
658
+ undefined,
659
+ "Workflow host is stopping",
660
+ );
661
+ }
662
+ const key = canonicalJson([request.clientId, request.idempotencyKey]);
663
+ const active = this.maintenanceCommands.get(key);
664
+ if (active !== undefined) {
665
+ await active;
666
+ return (
667
+ this.hostState.adoptCommand(request) ??
668
+ clientResponse(
669
+ request.requestId,
670
+ "unavailable",
671
+ undefined,
672
+ "Workflow maintenance command did not complete durably",
673
+ )
674
+ );
675
+ }
676
+
677
+ const execution = (async (): Promise<ClientResponse> => {
678
+ let result: Omit<ClientResponse, "schema" | "type" | "requestId">;
679
+ try {
680
+ result = { outcome: "accepted", receipt: await operation() };
681
+ } catch (error) {
682
+ result = { outcome: "rejected", error: errorMessage(error) };
683
+ }
684
+ return this.hostState.executeCommand(request, claim.epoch, () => result);
685
+ })();
686
+ this.maintenanceCommands.set(key, execution);
687
+ try {
688
+ return await execution;
689
+ } finally {
690
+ if (this.maintenanceCommands.get(key) === execution) {
691
+ this.maintenanceCommands.delete(key);
692
+ }
693
+ }
694
+ }
695
+
696
+ private addSubscription(
697
+ connection: ClientConnection,
698
+ request: ClientRequest,
699
+ kind: ClientSubscription["kind"],
700
+ target?: string,
701
+ ): void {
702
+ const payload = requireRecord(request.payload, `${request.operation} payload`);
703
+ const id = requireString(payload.subscriptionId, "subscriptionId");
704
+ const limit =
705
+ kind === "runs" && payload.limit !== undefined
706
+ ? requirePositiveInteger(payload.limit, "limit")
707
+ : undefined;
708
+ connection.subscriptions.set(id, {
709
+ id,
710
+ kind,
711
+ ...(target === undefined ? {} : { target }),
712
+ ...(limit === undefined ? {} : { limit }),
713
+ revision: 0,
443
714
  });
444
715
  }
445
716
 
446
- private handleRequest(request: HostRequest): HostResponse {
717
+ private publishViews(): void {
718
+ for (const connection of this.connections.values()) void this.publishConnection(connection);
719
+ }
720
+
721
+ private async publishConnection(connection: ClientConnection): Promise<void> {
722
+ if (connection.publishing || connection.socket.destroyed) return;
723
+ connection.publishing = true;
724
+ try {
725
+ for (const subscription of connection.subscriptions.values()) {
726
+ const payload =
727
+ subscription.kind === "runs"
728
+ ? toJsonValue(this.views.list(0, subscription.limit))
729
+ : subscription.kind === "run"
730
+ ? toJsonValue(this.views.run(subscription.target ?? ""))
731
+ : toJsonValue(this.views.session(subscription.target ?? ""));
732
+ const digest = createHash("sha256").update(canonicalJson(payload)).digest("hex");
733
+ if (subscription.digest === digest) continue;
734
+ subscription.digest = digest;
735
+ subscription.revision += 1;
736
+ const event: ClientEvent = {
737
+ schema: CLIENT_PROTOCOL_SCHEMA,
738
+ type: "event",
739
+ subscriptionId: subscription.id,
740
+ event:
741
+ subscription.kind === "runs"
742
+ ? "runs"
743
+ : subscription.kind === "run"
744
+ ? "run_snapshot"
745
+ : "session_snapshot",
746
+ revision: subscription.revision,
747
+ ...(subscription.kind === "run" && subscription.target !== undefined
748
+ ? { runId: subscription.target }
749
+ : {}),
750
+ payload,
751
+ };
752
+ if (!connection.socket.write(encodeProtocolLine(event))) {
753
+ await waitForSocketDrain(connection.socket);
754
+ if (connection.socket.destroyed) return;
755
+ }
756
+ }
757
+ } catch (error) {
758
+ this.log(`client view error: ${errorMessage(error)}`);
759
+ connection.socket.destroy();
760
+ } finally {
761
+ connection.publishing = false;
762
+ }
763
+ }
764
+
765
+ private async submitInteractionAndWait(request: ClientRequest): Promise<ClientResponse> {
766
+ const started = this.submitInteraction(request);
767
+ if (started.outcome !== "accepted" && started.outcome !== "adopted") {
768
+ return clientResponse(request.requestId, started.outcome, started.receipt, started.error);
769
+ }
770
+ const payload = requireRecord(request.payload, "interaction payload");
771
+ const requestId = requireString(payload.requestId, "requestId");
772
+ const startedReceipt = requireRecord(started.receipt, "interaction submission receipt");
773
+ const submissionId = requireString(startedReceipt.submissionId, "submissionId");
774
+ for (;;) {
775
+ if (this.stopping) {
776
+ return clientResponse(
777
+ request.requestId,
778
+ "unavailable",
779
+ undefined,
780
+ "Workflow host stopped while validating the submission",
781
+ );
782
+ }
783
+ const submission = this.hostState.interactionSubmission(requestId, submissionId);
784
+ if (submission?.outcome === "accepted" || submission?.outcome === "adopted") {
785
+ return clientResponse(
786
+ request.requestId,
787
+ started.outcome,
788
+ submission.receipt ?? { requestId, submissionId },
789
+ );
790
+ }
791
+ if (submission?.outcome === "rejected") {
792
+ const receipt = submission.receipt ?? { requestId, submissionId };
793
+ const detail =
794
+ isObjectRecord(receipt) && typeof receipt.error === "string"
795
+ ? receipt.error
796
+ : "Workflow step output failed validation";
797
+ return clientResponse(request.requestId, "rejected", receipt, detail);
798
+ }
799
+ await hostDelay(25);
800
+ }
801
+ }
802
+
803
+ private handleRequest(request: ClientRequest): ClientResponse {
447
804
  if (this.claim === null || this.stopping) {
448
805
  return {
449
- schema: "pi-workflows.host-response.v1",
806
+ schema: CLIENT_PROTOCOL_SCHEMA,
807
+ type: "response",
450
808
  requestId: request.requestId,
451
809
  outcome: "unavailable",
452
810
  error: "Workflow host is stopping",
453
811
  };
454
812
  }
455
813
  const afterCommit: Array<() => void> = [];
456
- let response: HostResponse;
814
+ let response: ClientResponse;
457
815
  try {
458
816
  response = this.hostState.executeCommand(request, this.claim.epoch, () =>
459
817
  this.executeOperation(request, afterCommit),
460
818
  );
461
819
  } catch (error) {
462
820
  response = {
463
- schema: "pi-workflows.host-response.v1",
821
+ schema: CLIENT_PROTOCOL_SCHEMA,
822
+ type: "response",
464
823
  requestId: request.requestId,
465
824
  outcome: "rejected",
466
825
  error: errorMessage(error),
@@ -471,9 +830,9 @@ export class WorkflowHost {
471
830
  }
472
831
 
473
832
  private executeOperation(
474
- request: HostRequest,
833
+ request: ClientRequest,
475
834
  afterCommit: Array<() => void>,
476
- ): Omit<HostResponse, "schema" | "requestId"> {
835
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
477
836
  switch (request.operation) {
478
837
  case "host.status":
479
838
  return { outcome: "accepted", receipt: this.statusReceipt() };
@@ -526,14 +885,37 @@ export class WorkflowHost {
526
885
  case "run.pause": {
527
886
  const runId = requireRunId(request);
528
887
  const active = this.activeRuns.get(runId);
529
- if (active === undefined) return { outcome: "rejected", error: "Run is not active" };
530
- this.commitActivePause(active);
531
- active.control = "pause";
532
- afterCommit.push(() => void active.supervisor.stop("cancelled"));
533
- return { outcome: "accepted", receipt: { runId, status: "parked", paused: true } };
888
+ if (active !== undefined) {
889
+ if (active.control === "pause") {
890
+ return { outcome: "adopted", receipt: { runId, status: "parked", paused: true } };
891
+ }
892
+ if (active.control === "handoff") {
893
+ const paused = this.queue.pauseParkedWorkflowRun({ runId });
894
+ return paused
895
+ ? { outcome: "accepted", receipt: { runId, status: "parked", paused: true } }
896
+ : { outcome: "rejected", error: "Run handoff is not pausable" };
897
+ }
898
+ if (active.control !== undefined) {
899
+ return { outcome: "rejected", error: `Run is already handling ${active.control}` };
900
+ }
901
+ this.commitActivePause(active);
902
+ active.control = "pause";
903
+ afterCommit.push(() => void active.supervisor.stop("cancelled"));
904
+ return { outcome: "accepted", receipt: { runId, status: "parked", paused: true } };
905
+ }
906
+ const paused = this.queue.pauseParkedWorkflowRun({ runId });
907
+ return paused
908
+ ? { outcome: "accepted", receipt: { runId, status: "parked", paused: true } }
909
+ : { outcome: "rejected", error: "Run is not pausable" };
534
910
  }
535
911
  case "run.resume": {
536
912
  const runId = requireRunId(request);
913
+ if (this.queue.resumePausedInteraction({ runId })) {
914
+ return {
915
+ outcome: "accepted",
916
+ receipt: { runId, status: "parked", paused: false, waitingForInteraction: true },
917
+ };
918
+ }
537
919
  if (this.activeRuns.has(runId) || this.pendingStarts.has(runId)) {
538
920
  return { outcome: "adopted", receipt: { runId, active: true } };
539
921
  }
@@ -569,21 +951,49 @@ export class WorkflowHost {
569
951
  return this.executeControllerOperation(request);
570
952
  case "interaction.update": {
571
953
  const payload = requireRecord(request.payload, "interaction update payload");
954
+ if (payload.validatePresentation === true) {
955
+ const interaction = this.hostState.getInteraction(
956
+ requireString(payload.requestId, "requestId"),
957
+ );
958
+ const expectedRevision = requireNonNegativeInteger(
959
+ request.expectedRevision,
960
+ "expectedRevision",
961
+ );
962
+ const expiresAt = interaction?.presentationClaimExpiresAt;
963
+ const live =
964
+ interaction !== undefined &&
965
+ interaction.runId === requireRunId(request) &&
966
+ interaction.status === "presenting" &&
967
+ interaction.presenterId === request.clientId &&
968
+ interaction.revision === expectedRevision &&
969
+ interaction.presentationSessionEntryId === null &&
970
+ typeof expiresAt === "string" &&
971
+ Date.parse(expiresAt) > Date.now() &&
972
+ !this.queue.isWorkflowRunPaused(interaction.runId);
973
+ return { outcome: "accepted", receipt: { live } };
974
+ }
572
975
  if (payload.claimPresentation === true) {
573
- const interaction = this.hostState.claimInteractionPresentation({
574
- requestId: requireString(payload.requestId, "requestId"),
575
- expectedRevision: requireNonNegativeInteger(
576
- request.expectedRevision,
577
- "expectedRevision",
578
- ),
579
- presenterId: request.clientId,
580
- leaseMs: PRESENTATION_CLAIM_LEASE_MS,
581
- });
582
- return {
583
- outcome: "accepted",
584
- revision: interaction.revision,
585
- receipt: interaction as unknown as JsonValue,
586
- };
976
+ try {
977
+ const interaction = this.hostState.claimInteractionPresentation({
978
+ requestId: requireString(payload.requestId, "requestId"),
979
+ expectedRevision: requireNonNegativeInteger(
980
+ request.expectedRevision,
981
+ "expectedRevision",
982
+ ),
983
+ presenterId: request.clientId,
984
+ leaseMs: PRESENTATION_CLAIM_LEASE_MS,
985
+ });
986
+ return {
987
+ outcome: "accepted",
988
+ revision: interaction.revision,
989
+ receipt: interaction as unknown as JsonValue,
990
+ };
991
+ } catch (error) {
992
+ if (errorMessage(error) === "Interactive request presentation claim conflict") {
993
+ return { outcome: "conflict", error: errorMessage(error) };
994
+ }
995
+ throw error;
996
+ }
587
997
  }
588
998
  if (typeof payload.sessionEntryId === "string") {
589
999
  const interaction = this.hostState.markInteractionPresented({
@@ -606,12 +1016,26 @@ export class WorkflowHost {
606
1016
  return this.submitInteraction(request);
607
1017
  case "decision.answer":
608
1018
  return this.answerDecision(request, afterCommit);
1019
+ case "view.runs.watch":
1020
+ case "view.runs.page":
1021
+ case "view.run.get":
1022
+ case "view.run.watch":
1023
+ case "view.run.unwatch":
1024
+ case "view.page":
1025
+ case "view.content":
1026
+ case "view.session.watch":
1027
+ case "activity.report":
1028
+ case "state.status":
1029
+ case "state.verify":
1030
+ case "state.backup":
1031
+ case "state.prune":
1032
+ throw new Error(`${request.operation} must use the live client connection`);
609
1033
  }
610
1034
  }
611
1035
 
612
1036
  private executeControllerOperation(
613
- request: HostRequest,
614
- ): Omit<HostResponse, "schema" | "requestId"> {
1037
+ request: ClientRequest,
1038
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
615
1039
  const payload = requireRecord(request.payload, "controller payload");
616
1040
  const projectPath = path.resolve(requireString(payload.projectPath, "projectPath"));
617
1041
  if (payload.projectPath !== projectPath) {
@@ -722,17 +1146,51 @@ export class WorkflowHost {
722
1146
  };
723
1147
  }
724
1148
 
725
- private rememberDeliveryClaim(options: Omit<DeliveryClaim, "expiresAt">): string {
1149
+ private stateStatusReceipt(): JsonValue {
1150
+ const count = (sql: string, ...params: unknown[]): number => {
1151
+ const row = this.state.connection.prepare(sql).get(...params) as
1152
+ | { count?: unknown }
1153
+ | undefined;
1154
+ return typeof row?.count === "number" ? row.count : 0;
1155
+ };
1156
+ const now = Date.now();
1157
+ return {
1158
+ sizeBytes: fs.statSync(this.databasePath).size,
1159
+ counts: {
1160
+ resources: count("SELECT COUNT(*) AS count FROM resources"),
1161
+ runs: count("SELECT COUNT(*) AS count FROM runs"),
1162
+ controllers: count("SELECT COUNT(*) AS count FROM controller_resources"),
1163
+ decisions: count("SELECT COUNT(*) AS count FROM human_decisions"),
1164
+ settingsScopes: count("SELECT COUNT(*) AS count FROM workflow_settings"),
1165
+ pendingInteractions: count(
1166
+ "SELECT COUNT(*) AS count FROM interactive_requests WHERE status IN ('pending', 'presenting')",
1167
+ ),
1168
+ pendingFollowUps: count(
1169
+ "SELECT COUNT(*) AS count FROM workflow_follow_ups WHERE status IN ('queued', 'pending_presentation', 'ready')",
1170
+ ),
1171
+ activeLeases: count(
1172
+ "SELECT COUNT(*) AS count FROM leases WHERE owner_id IS NOT NULL AND expires_at > ?",
1173
+ now,
1174
+ ),
1175
+ unsettledEffects: count(
1176
+ "SELECT COUNT(*) AS count FROM effects WHERE status IN ('pending', 'applying', 'ambiguous')",
1177
+ ),
1178
+ },
1179
+ };
1180
+ }
1181
+
1182
+ private rememberDeliveryClaim(options: Omit<DeliveryClaim, "expiresAt">): {
1183
+ claimId: string;
1184
+ claimExpiresAt: string;
1185
+ } {
726
1186
  const now = Date.now();
727
1187
  for (const [claimId, claim] of this.deliveryClaims) {
728
1188
  if (claim.expiresAt <= now) this.deliveryClaims.delete(claimId);
729
1189
  }
730
1190
  const claimId = randomUUID();
731
- this.deliveryClaims.set(claimId, {
732
- ...options,
733
- expiresAt: now + DELIVERY_CLAIM_LEASE_MS,
734
- });
735
- return claimId;
1191
+ const expiresAt = now + DELIVERY_CLAIM_LEASE_MS;
1192
+ this.deliveryClaims.set(claimId, { ...options, expiresAt });
1193
+ return { claimId, claimExpiresAt: new Date(expiresAt).toISOString() };
736
1194
  }
737
1195
 
738
1196
  private deliveryClaim(
@@ -757,8 +1215,41 @@ export class WorkflowHost {
757
1215
  return claim;
758
1216
  }
759
1217
 
760
- private claimNotification(command: HostRequest): Omit<HostResponse, "schema" | "requestId"> {
1218
+ private validateDelivery(
1219
+ command: ClientRequest,
1220
+ payload: Record<string, unknown>,
1221
+ kind: DeliveryClaim["kind"],
1222
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
1223
+ const resourceId = requireString(payload.resourceId, "resourceId");
1224
+ const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
1225
+ const claimId = requireString(payload.claimId, "claimId");
1226
+ const claim = this.deliveryClaim(claimId, command.clientId, kind, resourceId, targetSessionId);
1227
+ if (claim === undefined) {
1228
+ return { outcome: "accepted", receipt: { claimId, live: false } };
1229
+ }
1230
+ const live =
1231
+ kind === "notification"
1232
+ ? this.queue.isWorkflowNotificationClaimLive({
1233
+ notificationId: resourceId,
1234
+ targetSessionId,
1235
+ claimToken: claim.token,
1236
+ })
1237
+ : this.queue.isWorkflowTurnIntentClaimLive({
1238
+ intentId: resourceId,
1239
+ targetSessionId,
1240
+ claimToken: claim.token,
1241
+ });
1242
+ if (!live) this.deliveryClaims.delete(claimId);
1243
+ return { outcome: "accepted", receipt: { claimId, live } };
1244
+ }
1245
+
1246
+ private claimNotification(
1247
+ command: ClientRequest,
1248
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
761
1249
  const payload = requireRecord(command.payload, "notification claim payload");
1250
+ if (payload.validateClaim === true) {
1251
+ return this.validateDelivery(command, payload, "notification");
1252
+ }
762
1253
  const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
763
1254
  const token = randomUUID();
764
1255
  const notification = this.queue.claimPendingWorkflowNotifications({
@@ -770,7 +1261,7 @@ export class WorkflowHost {
770
1261
  if (notification === undefined) {
771
1262
  return { outcome: "accepted", receipt: { notification: null } };
772
1263
  }
773
- const claimId = this.rememberDeliveryClaim({
1264
+ const claim = this.rememberDeliveryClaim({
774
1265
  clientId: command.clientId,
775
1266
  token,
776
1267
  targetSessionId,
@@ -779,11 +1270,13 @@ export class WorkflowHost {
779
1270
  });
780
1271
  return {
781
1272
  outcome: "accepted",
782
- receipt: { claimId, notification } as unknown as JsonValue,
1273
+ receipt: { ...claim, notification } as unknown as JsonValue,
783
1274
  };
784
1275
  }
785
1276
 
786
- private deliverNotification(command: HostRequest): Omit<HostResponse, "schema" | "requestId"> {
1277
+ private deliverNotification(
1278
+ command: ClientRequest,
1279
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
787
1280
  const payload = requireRecord(command.payload, "notification delivery payload");
788
1281
  const notificationId = requireString(payload.notificationId, "notificationId");
789
1282
  const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
@@ -809,8 +1302,11 @@ export class WorkflowHost {
809
1302
  : { outcome: "conflict", error: "Notification delivery claim is stale" };
810
1303
  }
811
1304
 
812
- private claimTurn(command: HostRequest): Omit<HostResponse, "schema" | "requestId"> {
1305
+ private claimTurn(command: ClientRequest): Omit<ClientResponse, "schema" | "type" | "requestId"> {
813
1306
  const payload = requireRecord(command.payload, "turn claim payload");
1307
+ if (payload.validateClaim === true) {
1308
+ return this.validateDelivery(command, payload, "turn");
1309
+ }
814
1310
  const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
815
1311
  const token = randomUUID();
816
1312
  const intent = this.queue.claimEligibleWorkflowTurnIntents({
@@ -822,7 +1318,7 @@ export class WorkflowHost {
822
1318
  if (intent === undefined) {
823
1319
  return { outcome: "accepted", receipt: { turn: null } };
824
1320
  }
825
- const claimId = this.rememberDeliveryClaim({
1321
+ const claim = this.rememberDeliveryClaim({
826
1322
  clientId: command.clientId,
827
1323
  token,
828
1324
  targetSessionId,
@@ -831,11 +1327,13 @@ export class WorkflowHost {
831
1327
  });
832
1328
  return {
833
1329
  outcome: "accepted",
834
- receipt: { claimId, turn: intent } as unknown as JsonValue,
1330
+ receipt: { ...claim, turn: intent } as unknown as JsonValue,
835
1331
  };
836
1332
  }
837
1333
 
838
- private resolveTurn(command: HostRequest): Omit<HostResponse, "schema" | "requestId"> {
1334
+ private resolveTurn(
1335
+ command: ClientRequest,
1336
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
839
1337
  const payload = requireRecord(command.payload, "turn resolution payload");
840
1338
  const intentId = requireString(payload.intentId, "intentId");
841
1339
  const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
@@ -858,9 +1356,9 @@ export class WorkflowHost {
858
1356
  }
859
1357
 
860
1358
  private answerCheckpoint(
861
- command: HostRequest,
1359
+ command: ClientRequest,
862
1360
  afterCommit: Array<() => void>,
863
- ): Omit<HostResponse, "schema" | "requestId"> {
1361
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
864
1362
  const parentRunId = requireRunId(command);
865
1363
  const payload = requireRecord(command.payload, "checkpoint answer payload");
866
1364
  const continuationRunId = requireString(payload.continuationRunId, "continuationRunId");
@@ -904,15 +1402,18 @@ export class WorkflowHost {
904
1402
  }
905
1403
 
906
1404
  private answerDecision(
907
- command: HostRequest,
1405
+ command: ClientRequest,
908
1406
  afterCommit: Array<() => void>,
909
- ): Omit<HostResponse, "schema" | "requestId"> {
1407
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
910
1408
  const payload = requireRecord(command.payload, "decision answer payload");
911
1409
  const requestId = requireString(payload.requestId, "requestId");
912
1410
  const interaction = this.hostState.getInteraction(requestId);
913
1411
  if (interaction === undefined || interaction.kind !== "decision") {
914
1412
  return { outcome: "notFound", error: `Decision request not found: ${requestId}` };
915
1413
  }
1414
+ if (this.queue.isWorkflowRunPaused(interaction.runId)) {
1415
+ return { outcome: "conflict", error: "Workflow run is paused" };
1416
+ }
916
1417
  const request = interaction.contract as unknown as HumanDecisionRequest;
917
1418
  const response = payload.response as HumanDecisionResponse;
918
1419
  const accepted = this.decisions.acceptSync(request, {
@@ -1028,9 +1529,9 @@ export class WorkflowHost {
1028
1529
  }
1029
1530
 
1030
1531
  private startRun(
1031
- request: HostRequest,
1532
+ request: ClientRequest,
1032
1533
  afterCommit: Array<() => void>,
1033
- ): Omit<HostResponse, "schema" | "requestId"> {
1534
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
1034
1535
  const runId = requireRunId(request);
1035
1536
  const payload = requireRecord(request.payload, "run.start payload");
1036
1537
  const projectPath = requireAbsolutePath(payload.projectPath, "projectPath");
@@ -1076,8 +1577,8 @@ export class WorkflowHost {
1076
1577
  }
1077
1578
 
1078
1579
  private publishInteractionUpdate(
1079
- request: HostRequest,
1080
- ): Omit<HostResponse, "schema" | "requestId"> {
1580
+ request: ClientRequest,
1581
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
1081
1582
  const runId = requireRunId(request);
1082
1583
  const payload = requireRecord(request.payload, "interaction update payload");
1083
1584
  const requestId = requireString(payload.requestId, "requestId");
@@ -1085,6 +1586,9 @@ export class WorkflowHost {
1085
1586
  if (interaction === undefined || interaction.runId !== runId) {
1086
1587
  return { outcome: "notFound", error: `Interactive request not found: ${requestId}` };
1087
1588
  }
1589
+ if (this.queue.isWorkflowRunPaused(runId)) {
1590
+ return { outcome: "conflict", error: "Workflow run is paused" };
1591
+ }
1088
1592
  const attemptId = requireString(payload.attempt, "attempt");
1089
1593
  const nodeId = requireString(payload.step, "step");
1090
1594
  const storedContract = requireRecord(interaction.contract, "interactive contract");
@@ -1141,13 +1645,18 @@ export class WorkflowHost {
1141
1645
  }
1142
1646
  }
1143
1647
 
1144
- private submitInteraction(request: HostRequest): Omit<HostResponse, "schema" | "requestId"> {
1648
+ private submitInteraction(
1649
+ request: ClientRequest,
1650
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
1145
1651
  const payload = requireRecord(request.payload, "interaction payload");
1146
1652
  const requestId = requireString(payload.requestId, "requestId");
1147
1653
  const current = this.hostState.getInteraction(requestId);
1148
1654
  if (current === undefined || current.runId !== requireRunId(request)) {
1149
1655
  return { outcome: "notFound", error: `Interactive request not found: ${requestId}` };
1150
1656
  }
1657
+ if (this.queue.isWorkflowRunPaused(current.runId)) {
1658
+ return { outcome: "conflict", error: "Workflow run is paused" };
1659
+ }
1151
1660
  const attemptId = requireString(payload.attempt, "attempt");
1152
1661
  const nodeId = requireString(payload.step, "step");
1153
1662
  const storedContract = requireRecord(current.contract, "interactive contract");
@@ -1173,6 +1682,9 @@ export class WorkflowHost {
1173
1682
  },
1174
1683
  });
1175
1684
  const interaction = submission.interaction;
1685
+ const receipt = isObjectRecord(submission.receipt)
1686
+ ? { ...submission.receipt, requestId, submissionId: submission.submissionId }
1687
+ : { requestId, submissionId: submission.submissionId, receipt: submission.receipt };
1176
1688
  if (this.activeRuns.has(interaction.runId) || this.activationTasks.has(interaction.runId)) {
1177
1689
  this.pendingResumes.add(interaction.runId);
1178
1690
  } else {
@@ -1191,7 +1703,7 @@ export class WorkflowHost {
1191
1703
  return {
1192
1704
  outcome: submission.outcome,
1193
1705
  revision: interaction.revision,
1194
- receipt: submission.receipt,
1706
+ receipt,
1195
1707
  };
1196
1708
  }
1197
1709
 
@@ -1504,7 +2016,7 @@ export class WorkflowHost {
1504
2016
  }
1505
2017
  const projectPath = this.queue.workflowRunProjectPath(request.runId);
1506
2018
  if (projectPath === undefined) throw new Error("Workflow run project is missing");
1507
- const resolver = new WorkflowHostClient({
2019
+ const resolver = new WorkflowClient({
1508
2020
  databasePath: this.databasePath,
1509
2021
  ...(this.options.env === undefined ? {} : { env: this.options.env }),
1510
2022
  });
@@ -1554,7 +2066,7 @@ export class WorkflowHost {
1554
2066
  ): Promise<WorkflowSchedulerResult> {
1555
2067
  const existing = this.queue.getWorkflowRun(request.runId);
1556
2068
  if (existing !== undefined) return controllerWorkflowResult(existing);
1557
- const resolver = new WorkflowHostClient({
2069
+ const resolver = new WorkflowClient({
1558
2070
  databasePath: this.databasePath,
1559
2071
  ...(this.options.env === undefined ? {} : { env: this.options.env }),
1560
2072
  });
@@ -2850,17 +3362,157 @@ function controllerPathAllowed(
2850
3362
  );
2851
3363
  }
2852
3364
 
3365
+ function waitForSocketDrain(socket: Socket): Promise<void> {
3366
+ if (socket.destroyed) return Promise.resolve();
3367
+ return new Promise((resolve, reject) => {
3368
+ const cleanup = (): void => {
3369
+ socket.off("drain", onDrain);
3370
+ socket.off("close", onClose);
3371
+ socket.off("error", onError);
3372
+ };
3373
+ const onDrain = (): void => {
3374
+ cleanup();
3375
+ resolve();
3376
+ };
3377
+ const onClose = (): void => {
3378
+ cleanup();
3379
+ resolve();
3380
+ };
3381
+ const onError = (error: Error): void => {
3382
+ cleanup();
3383
+ reject(error);
3384
+ };
3385
+ socket.once("drain", onDrain);
3386
+ socket.once("close", onClose);
3387
+ socket.once("error", onError);
3388
+ if (socket.destroyed) onClose();
3389
+ });
3390
+ }
3391
+
3392
+ function hostDelay(ms: number): Promise<void> {
3393
+ return new Promise((resolve) => setTimeout(resolve, ms));
3394
+ }
3395
+
3396
+ function runPageReceipt(view: WorkflowRunView, kind: WorkflowPageKind, cursor: number): JsonValue {
3397
+ const base = {
3398
+ schema: "pi-workflows.run-page.v1",
3399
+ runId: view.runId,
3400
+ revision: view.revision,
3401
+ kind,
3402
+ cursor,
3403
+ };
3404
+ if (kind === "steps") {
3405
+ const state = requireRecord(view.state, "run state view");
3406
+ return {
3407
+ ...base,
3408
+ start: view.stepStart,
3409
+ total: view.stepTotal,
3410
+ items: Array.isArray(state.steps) ? state.steps : [],
3411
+ graphSteps: view.graphSteps,
3412
+ graphCursor: view.graphCursor,
3413
+ takenTransitions: view.takenTransitions,
3414
+ };
3415
+ }
3416
+ if (kind === "trace" || kind === "trace_at_step") {
3417
+ return { ...base, ...requireRecord(view.tracePage, "trace page") } as JsonValue;
3418
+ }
3419
+ if (kind === "session_entries" || kind === "session_events") {
3420
+ const session = requireRecord(view.session, "session view");
3421
+ const page = requireRecord(
3422
+ kind === "session_entries" ? session.entryPage : session.eventPage,
3423
+ "session page",
3424
+ );
3425
+ return {
3426
+ ...base,
3427
+ ...page,
3428
+ ...(kind === "session_events" ? { replayCheckpoint: session.replayCheckpoint } : {}),
3429
+ } as JsonValue;
3430
+ }
3431
+ if (kind === "settings") {
3432
+ return {
3433
+ ...base,
3434
+ start: view.settingsStart,
3435
+ total: view.settingsTotal,
3436
+ items: view.settingsScopes,
3437
+ };
3438
+ }
3439
+ if (kind === "follow_ups") {
3440
+ const queue = isObjectRecord(view.followUpQueue) ? view.followUpQueue : {};
3441
+ return {
3442
+ ...base,
3443
+ start: view.followUpStart,
3444
+ total: view.followUpTotal,
3445
+ items: Array.isArray(queue.items) ? queue.items : [],
3446
+ };
3447
+ }
3448
+ return {
3449
+ ...base,
3450
+ start: view.updateStart,
3451
+ total: view.updateTotal,
3452
+ items: view.updates,
3453
+ };
3454
+ }
3455
+
3456
+ function clientResponse(
3457
+ requestId: string,
3458
+ outcome: ClientOutcome,
3459
+ receipt?: JsonValue,
3460
+ error?: string,
3461
+ revision?: number,
3462
+ ): ClientResponse {
3463
+ return {
3464
+ schema: CLIENT_PROTOCOL_SCHEMA,
3465
+ type: "response",
3466
+ requestId,
3467
+ outcome,
3468
+ ...(revision === undefined ? {} : { revision }),
3469
+ ...(receipt === undefined ? {} : { receipt }),
3470
+ ...(error === undefined ? {} : { error: boundedClientError(error) }),
3471
+ };
3472
+ }
3473
+
3474
+ function boundedClientError(error: string): string {
3475
+ const singleLine = error.replaceAll(/[\r\n\t]+/gu, " ").trim();
3476
+ if (singleLine.length === 0) return "Workflow request failed";
3477
+ return singleLine.length <= 1_000 ? singleLine : `${singleLine.slice(0, 997)}...`;
3478
+ }
3479
+
3480
+ function parseActivityReport(payload: JsonValue): OriginActivityReport {
3481
+ const value = requireRecord(payload, "activity.report payload");
3482
+ const state = requireString(value.state, "state");
3483
+ if (state !== "started" && state !== "refresh" && state !== "settled") {
3484
+ throw new Error("activity state must be started, refresh, or settled");
3485
+ }
3486
+ return {
3487
+ sessionId: requireString(value.sessionId, "sessionId"),
3488
+ runId: requireString(value.runId, "runId"),
3489
+ requestId: requireString(value.requestId, "requestId"),
3490
+ deliveryId: requireString(value.deliveryId, "deliveryId"),
3491
+ sessionEntryId: requireString(value.sessionEntryId, "sessionEntryId"),
3492
+ sequence: requireNonNegativeInteger(value.sequence, "sequence"),
3493
+ state,
3494
+ };
3495
+ }
3496
+
3497
+ function toJsonValue(value: unknown): JsonValue {
3498
+ return JSON.parse(canonicalJson(value)) as JsonValue;
3499
+ }
3500
+
2853
3501
  function payloadLimit(payload: JsonValue): number {
2854
3502
  if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return 100;
2855
3503
  const limit = (payload as Record<string, unknown>).limit;
2856
3504
  return typeof limit === "number" && Number.isSafeInteger(limit) && limit > 0 ? limit : 100;
2857
3505
  }
2858
3506
 
2859
- function requireRunId(request: HostRequest): string {
3507
+ function requireRunId(request: ClientRequest): string {
2860
3508
  if (request.runId === undefined) throw new Error(`${request.operation} requires runId`);
2861
3509
  return request.runId;
2862
3510
  }
2863
3511
 
3512
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
3513
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3514
+ }
3515
+
2864
3516
  function requireRecord(value: unknown, name: string): Record<string, unknown> {
2865
3517
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
2866
3518
  throw new Error(`${name} must be an object`);
@@ -2881,6 +3533,18 @@ function requireAbsolutePath(value: unknown, name: string): string {
2881
3533
  return parsed;
2882
3534
  }
2883
3535
 
3536
+ function requireBoolean(value: unknown, name: string): boolean {
3537
+ if (typeof value !== "boolean") throw new Error(`${name} must be a boolean`);
3538
+ return value;
3539
+ }
3540
+
3541
+ function requirePositiveInteger(value: unknown, name: string): number {
3542
+ if (!Number.isSafeInteger(value) || (value as number) <= 0) {
3543
+ throw new Error(`${name} must be a positive integer`);
3544
+ }
3545
+ return value as number;
3546
+ }
3547
+
2884
3548
  function requireNonNegativeInteger(value: unknown, name: string): number {
2885
3549
  if (!Number.isSafeInteger(value) || (value as number) < 0) {
2886
3550
  throw new Error(`${name} must be a non-negative integer`);
@@ -2896,6 +3560,16 @@ function isRevisionRow(value: unknown): value is { revision: number } {
2896
3560
  );
2897
3561
  }
2898
3562
 
3563
+ function runtimePackageVersion(): string {
3564
+ const parsed = JSON.parse(
3565
+ fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
3566
+ ) as { version?: unknown };
3567
+ if (typeof parsed.version !== "string" || parsed.version.length === 0) {
3568
+ throw new Error("Package version is missing");
3569
+ }
3570
+ return parsed.version;
3571
+ }
3572
+
2899
3573
  function isLockRecord(
2900
3574
  value: unknown,
2901
3575
  ): value is { schema: string; pid: number; startIdentity: string; hostId: string } {