@osolmaz/pi-workflows 0.15.3 → 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 (118) 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 +43 -0
  24. package/dist/controllers/sqlite.js +137 -2
  25. package/dist/controllers/sqlite.js.map +1 -1
  26. package/dist/extension/index.d.ts +1 -0
  27. package/dist/extension/index.js +278 -183
  28. package/dist/extension/index.js.map +1 -1
  29. package/dist/extension/session-view.d.ts +7 -2
  30. package/dist/extension/session-view.js +60 -52
  31. package/dist/extension/session-view.js.map +1 -1
  32. package/dist/extension/widget.d.ts +2 -1
  33. package/dist/extension/widget.js +14 -7
  34. package/dist/extension/widget.js.map +1 -1
  35. package/dist/host/child-worker-supervisor.js +1 -1
  36. package/dist/host/child-worker-supervisor.js.map +1 -1
  37. package/dist/host/resolver-entry.d.ts +2 -23
  38. package/dist/host/resolver-entry.js +1 -1
  39. package/dist/host/resolver-entry.js.map +1 -1
  40. package/dist/host/runner.d.ts +11 -0
  41. package/dist/host/runner.js +473 -17
  42. package/dist/host/runner.js.map +1 -1
  43. package/dist/host/state.d.ts +6 -3
  44. package/dist/host/state.js +52 -26
  45. package/dist/host/state.js.map +1 -1
  46. package/dist/host/view.d.ts +73 -0
  47. package/dist/host/view.js +871 -0
  48. package/dist/host/view.js.map +1 -0
  49. package/dist/host/worker-protocol.js +1 -1
  50. package/dist/host/worker-protocol.js.map +1 -1
  51. package/dist/state/database.d.ts +1 -0
  52. package/dist/state/database.js +15 -0
  53. package/dist/state/database.js.map +1 -1
  54. package/dist/state/prune.d.ts +3 -1
  55. package/dist/state/prune.js +6 -8
  56. package/dist/state/prune.js.map +1 -1
  57. package/dist/state/schema.js +12 -1
  58. package/dist/state/schema.js.map +1 -1
  59. package/dist/viewer/backup.d.ts +2 -0
  60. package/dist/viewer/backup.js +28 -0
  61. package/dist/viewer/backup.js.map +1 -0
  62. package/dist/viewer/cli.d.ts +4 -0
  63. package/dist/viewer/cli.js +150 -170
  64. package/dist/viewer/cli.js.map +1 -1
  65. package/dist/viewer/tui.d.ts +5 -7
  66. package/dist/viewer/tui.js +188 -108
  67. package/dist/viewer/tui.js.map +1 -1
  68. package/dist/workflows/store.d.ts +62 -1
  69. package/dist/workflows/store.js +350 -44
  70. package/dist/workflows/store.js.map +1 -1
  71. package/docs/2026-09-01-unified-workflow-client-plan.md +381 -0
  72. package/docs/2026-09-02-installed-live-e2e-plan.md +225 -0
  73. package/docs/SQLITE_STATE.md +13 -11
  74. package/docs/WORKFLOW_HOST.md +74 -61
  75. package/docs/development.md +2 -1
  76. package/docs/live-replay-protocol.md +70 -132
  77. package/docs/tui-viewer.md +10 -14
  78. package/docs/workflows.md +44 -1
  79. package/herdr-plugin.toml +1 -1
  80. package/package.json +9 -3
  81. package/protocol/client.v1.schema.json +137 -0
  82. package/protocol/fixtures/client-v1.json +23 -0
  83. package/src/client/activity.ts +6 -0
  84. package/src/client/client.ts +935 -0
  85. package/src/client/index.ts +24 -0
  86. package/src/client/materialize.ts +228 -0
  87. package/src/client/protocol.ts +327 -0
  88. package/src/client/resolver.ts +26 -0
  89. package/src/client/view.ts +138 -0
  90. package/src/controllers/sqlite.ts +213 -2
  91. package/src/extension/index.ts +349 -208
  92. package/src/extension/session-view.ts +73 -50
  93. package/src/extension/widget.ts +16 -8
  94. package/src/host/child-worker-supervisor.ts +1 -1
  95. package/src/host/resolver-entry.ts +11 -26
  96. package/src/host/runner.ts +648 -48
  97. package/src/host/state.ts +71 -43
  98. package/src/host/view.ts +1084 -0
  99. package/src/host/worker-protocol.ts +1 -1
  100. package/src/state/database.ts +13 -0
  101. package/src/state/prune.ts +11 -11
  102. package/src/state/schema.ts +12 -1
  103. package/src/viewer/backup.ts +29 -0
  104. package/src/viewer/cli.ts +171 -185
  105. package/src/viewer/tui.ts +196 -124
  106. package/src/workflows/store.ts +500 -45
  107. package/dist/host/client.d.ts +0 -48
  108. package/dist/host/client.js +0 -216
  109. package/dist/host/client.js.map +0 -1
  110. package/dist/host/protocol.d.ts +0 -38
  111. package/dist/host/protocol.js +0 -156
  112. package/dist/host/protocol.js.map +0 -1
  113. package/dist/viewer/watch.d.ts +0 -6
  114. package/dist/viewer/watch.js +0 -46
  115. package/dist/viewer/watch.js.map +0 -1
  116. package/src/host/client.ts +0 -293
  117. package/src/host/protocol.ts +0 -196
  118. 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() };
@@ -592,6 +951,27 @@ export class WorkflowHost {
592
951
  return this.executeControllerOperation(request);
593
952
  case "interaction.update": {
594
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
+ }
595
975
  if (payload.claimPresentation === true) {
596
976
  try {
597
977
  const interaction = this.hostState.claimInteractionPresentation({
@@ -636,12 +1016,26 @@ export class WorkflowHost {
636
1016
  return this.submitInteraction(request);
637
1017
  case "decision.answer":
638
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`);
639
1033
  }
640
1034
  }
641
1035
 
642
1036
  private executeControllerOperation(
643
- request: HostRequest,
644
- ): Omit<HostResponse, "schema" | "requestId"> {
1037
+ request: ClientRequest,
1038
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
645
1039
  const payload = requireRecord(request.payload, "controller payload");
646
1040
  const projectPath = path.resolve(requireString(payload.projectPath, "projectPath"));
647
1041
  if (payload.projectPath !== projectPath) {
@@ -752,6 +1146,39 @@ export class WorkflowHost {
752
1146
  };
753
1147
  }
754
1148
 
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
+
755
1182
  private rememberDeliveryClaim(options: Omit<DeliveryClaim, "expiresAt">): {
756
1183
  claimId: string;
757
1184
  claimExpiresAt: string;
@@ -789,10 +1216,10 @@ export class WorkflowHost {
789
1216
  }
790
1217
 
791
1218
  private validateDelivery(
792
- command: HostRequest,
1219
+ command: ClientRequest,
793
1220
  payload: Record<string, unknown>,
794
1221
  kind: DeliveryClaim["kind"],
795
- ): Omit<HostResponse, "schema" | "requestId"> {
1222
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
796
1223
  const resourceId = requireString(payload.resourceId, "resourceId");
797
1224
  const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
798
1225
  const claimId = requireString(payload.claimId, "claimId");
@@ -816,7 +1243,9 @@ export class WorkflowHost {
816
1243
  return { outcome: "accepted", receipt: { claimId, live } };
817
1244
  }
818
1245
 
819
- private claimNotification(command: HostRequest): Omit<HostResponse, "schema" | "requestId"> {
1246
+ private claimNotification(
1247
+ command: ClientRequest,
1248
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
820
1249
  const payload = requireRecord(command.payload, "notification claim payload");
821
1250
  if (payload.validateClaim === true) {
822
1251
  return this.validateDelivery(command, payload, "notification");
@@ -845,7 +1274,9 @@ export class WorkflowHost {
845
1274
  };
846
1275
  }
847
1276
 
848
- private deliverNotification(command: HostRequest): Omit<HostResponse, "schema" | "requestId"> {
1277
+ private deliverNotification(
1278
+ command: ClientRequest,
1279
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
849
1280
  const payload = requireRecord(command.payload, "notification delivery payload");
850
1281
  const notificationId = requireString(payload.notificationId, "notificationId");
851
1282
  const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
@@ -871,7 +1302,7 @@ export class WorkflowHost {
871
1302
  : { outcome: "conflict", error: "Notification delivery claim is stale" };
872
1303
  }
873
1304
 
874
- private claimTurn(command: HostRequest): Omit<HostResponse, "schema" | "requestId"> {
1305
+ private claimTurn(command: ClientRequest): Omit<ClientResponse, "schema" | "type" | "requestId"> {
875
1306
  const payload = requireRecord(command.payload, "turn claim payload");
876
1307
  if (payload.validateClaim === true) {
877
1308
  return this.validateDelivery(command, payload, "turn");
@@ -900,7 +1331,9 @@ export class WorkflowHost {
900
1331
  };
901
1332
  }
902
1333
 
903
- private resolveTurn(command: HostRequest): Omit<HostResponse, "schema" | "requestId"> {
1334
+ private resolveTurn(
1335
+ command: ClientRequest,
1336
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
904
1337
  const payload = requireRecord(command.payload, "turn resolution payload");
905
1338
  const intentId = requireString(payload.intentId, "intentId");
906
1339
  const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
@@ -923,9 +1356,9 @@ export class WorkflowHost {
923
1356
  }
924
1357
 
925
1358
  private answerCheckpoint(
926
- command: HostRequest,
1359
+ command: ClientRequest,
927
1360
  afterCommit: Array<() => void>,
928
- ): Omit<HostResponse, "schema" | "requestId"> {
1361
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
929
1362
  const parentRunId = requireRunId(command);
930
1363
  const payload = requireRecord(command.payload, "checkpoint answer payload");
931
1364
  const continuationRunId = requireString(payload.continuationRunId, "continuationRunId");
@@ -969,9 +1402,9 @@ export class WorkflowHost {
969
1402
  }
970
1403
 
971
1404
  private answerDecision(
972
- command: HostRequest,
1405
+ command: ClientRequest,
973
1406
  afterCommit: Array<() => void>,
974
- ): Omit<HostResponse, "schema" | "requestId"> {
1407
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
975
1408
  const payload = requireRecord(command.payload, "decision answer payload");
976
1409
  const requestId = requireString(payload.requestId, "requestId");
977
1410
  const interaction = this.hostState.getInteraction(requestId);
@@ -1096,9 +1529,9 @@ export class WorkflowHost {
1096
1529
  }
1097
1530
 
1098
1531
  private startRun(
1099
- request: HostRequest,
1532
+ request: ClientRequest,
1100
1533
  afterCommit: Array<() => void>,
1101
- ): Omit<HostResponse, "schema" | "requestId"> {
1534
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
1102
1535
  const runId = requireRunId(request);
1103
1536
  const payload = requireRecord(request.payload, "run.start payload");
1104
1537
  const projectPath = requireAbsolutePath(payload.projectPath, "projectPath");
@@ -1144,8 +1577,8 @@ export class WorkflowHost {
1144
1577
  }
1145
1578
 
1146
1579
  private publishInteractionUpdate(
1147
- request: HostRequest,
1148
- ): Omit<HostResponse, "schema" | "requestId"> {
1580
+ request: ClientRequest,
1581
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
1149
1582
  const runId = requireRunId(request);
1150
1583
  const payload = requireRecord(request.payload, "interaction update payload");
1151
1584
  const requestId = requireString(payload.requestId, "requestId");
@@ -1212,7 +1645,9 @@ export class WorkflowHost {
1212
1645
  }
1213
1646
  }
1214
1647
 
1215
- private submitInteraction(request: HostRequest): Omit<HostResponse, "schema" | "requestId"> {
1648
+ private submitInteraction(
1649
+ request: ClientRequest,
1650
+ ): Omit<ClientResponse, "schema" | "type" | "requestId"> {
1216
1651
  const payload = requireRecord(request.payload, "interaction payload");
1217
1652
  const requestId = requireString(payload.requestId, "requestId");
1218
1653
  const current = this.hostState.getInteraction(requestId);
@@ -1247,6 +1682,9 @@ export class WorkflowHost {
1247
1682
  },
1248
1683
  });
1249
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 };
1250
1688
  if (this.activeRuns.has(interaction.runId) || this.activationTasks.has(interaction.runId)) {
1251
1689
  this.pendingResumes.add(interaction.runId);
1252
1690
  } else {
@@ -1265,7 +1703,7 @@ export class WorkflowHost {
1265
1703
  return {
1266
1704
  outcome: submission.outcome,
1267
1705
  revision: interaction.revision,
1268
- receipt: submission.receipt,
1706
+ receipt,
1269
1707
  };
1270
1708
  }
1271
1709
 
@@ -1578,7 +2016,7 @@ export class WorkflowHost {
1578
2016
  }
1579
2017
  const projectPath = this.queue.workflowRunProjectPath(request.runId);
1580
2018
  if (projectPath === undefined) throw new Error("Workflow run project is missing");
1581
- const resolver = new WorkflowHostClient({
2019
+ const resolver = new WorkflowClient({
1582
2020
  databasePath: this.databasePath,
1583
2021
  ...(this.options.env === undefined ? {} : { env: this.options.env }),
1584
2022
  });
@@ -1628,7 +2066,7 @@ export class WorkflowHost {
1628
2066
  ): Promise<WorkflowSchedulerResult> {
1629
2067
  const existing = this.queue.getWorkflowRun(request.runId);
1630
2068
  if (existing !== undefined) return controllerWorkflowResult(existing);
1631
- const resolver = new WorkflowHostClient({
2069
+ const resolver = new WorkflowClient({
1632
2070
  databasePath: this.databasePath,
1633
2071
  ...(this.options.env === undefined ? {} : { env: this.options.env }),
1634
2072
  });
@@ -2924,17 +3362,157 @@ function controllerPathAllowed(
2924
3362
  );
2925
3363
  }
2926
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
+
2927
3501
  function payloadLimit(payload: JsonValue): number {
2928
3502
  if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return 100;
2929
3503
  const limit = (payload as Record<string, unknown>).limit;
2930
3504
  return typeof limit === "number" && Number.isSafeInteger(limit) && limit > 0 ? limit : 100;
2931
3505
  }
2932
3506
 
2933
- function requireRunId(request: HostRequest): string {
3507
+ function requireRunId(request: ClientRequest): string {
2934
3508
  if (request.runId === undefined) throw new Error(`${request.operation} requires runId`);
2935
3509
  return request.runId;
2936
3510
  }
2937
3511
 
3512
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
3513
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3514
+ }
3515
+
2938
3516
  function requireRecord(value: unknown, name: string): Record<string, unknown> {
2939
3517
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
2940
3518
  throw new Error(`${name} must be an object`);
@@ -2955,6 +3533,18 @@ function requireAbsolutePath(value: unknown, name: string): string {
2955
3533
  return parsed;
2956
3534
  }
2957
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
+
2958
3548
  function requireNonNegativeInteger(value: unknown, name: string): number {
2959
3549
  if (!Number.isSafeInteger(value) || (value as number) < 0) {
2960
3550
  throw new Error(`${name} must be a non-negative integer`);
@@ -2970,6 +3560,16 @@ function isRevisionRow(value: unknown): value is { revision: number } {
2970
3560
  );
2971
3561
  }
2972
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
+
2973
3573
  function isLockRecord(
2974
3574
  value: unknown,
2975
3575
  ): value is { schema: string; pid: number; startIdentity: string; hostId: string } {