@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,9 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { once } from "node:events";
3
2
  import fs from "node:fs";
4
3
  import net, {} from "node:net";
5
4
  import path from "node:path";
5
+ import { WorkflowClient } from "../client/client.js";
6
+ import { CLIENT_PROTOCOL_SCHEMA, encodeProtocolLine, clientSocketPath, NdjsonFrameDecoder, parseClientRequest, } from "../client/protocol.js";
6
7
  import { applyStatusPatch } from "../controllers/conditions.js";
7
8
  import { ResourceConflictError } from "../controllers/errors.js";
8
9
  import { controllerFileStem, controllerSearchDirs, discoverControllers, } from "../controllers/loader.js";
@@ -10,20 +11,21 @@ import { SqliteControllerStore, } from "../controllers/sqlite.js";
10
11
  import { ControllerWorkflowCoordinator, } from "../controllers/workflows.js";
11
12
  import { StateDatabase, workflowStatePath } from "../state/database.js";
12
13
  import { canonicalJson } from "../state/json.js";
14
+ import { pruneState } from "../state/prune.js";
13
15
  import { recordViewerDeltas } from "../state/viewer.js";
14
16
  import { errorMessage } from "../workflows/errors.js";
15
17
  import { HumanDecisionStore } from "../workflows/human-decision.js";
16
18
  import {} from "../workflows/settings.js";
17
19
  import { WorkflowRunStore } from "../workflows/store.js";
18
20
  import { validateWorkflowUpdate } from "../workflows/updates.js";
19
- import { WorkflowHostClient } from "./client.js";
20
21
  import { ControllerWorkerSupervisor } from "./controller-worker-supervisor.js";
21
22
  import { HostProcessRegistry, matchesProcessIdentity, processParentPid, processStartIdentity, } from "./processes.js";
22
- import { encodeProtocolLine, hostSocketPath, NdjsonFrameDecoder, parseHostRequest, } from "./protocol.js";
23
23
  import { HostStateStore, } from "./state.js";
24
+ import { HostViewStore, WORKFLOW_PAGE_KINDS, } from "./view.js";
24
25
  import { WorkflowWorkerSupervisor } from "./worker-supervisor.js";
25
26
  const HOST_LEASE_MS = 30_000;
26
27
  const HOST_RENEW_MS = 10_000;
28
+ const PACKAGE_VERSION = runtimePackageVersion();
27
29
  const CLAIM_POLL_MS = 2_000;
28
30
  const RUN_CLAIM_LEASE_MS = 30_000;
29
31
  const PRESENTATION_CLAIM_LEASE_MS = 10_000;
@@ -45,22 +47,26 @@ export class WorkflowHost {
45
47
  queue;
46
48
  decisions;
47
49
  runStore;
50
+ views;
48
51
  registry;
49
52
  activeRuns = new Map();
50
53
  workerDescendants = new Map();
51
54
  activeControllers = new Map();
52
55
  controlClaims = new Map();
53
56
  activationTasks = new Map();
57
+ maintenanceCommands = new Map();
54
58
  pendingStarts = new Set();
55
59
  pendingRunClaims = new Map();
56
60
  pendingResumes = new Set();
57
61
  blockedRuns = new Set();
58
62
  deliveryClaims = new Map();
59
63
  sockets = new Set();
64
+ connections = new Map();
60
65
  server = null;
61
66
  claim = null;
62
67
  heartbeatTimer = null;
63
68
  pollTimer = null;
69
+ viewTimer = null;
64
70
  stopping = false;
65
71
  started = false;
66
72
  controllerPollActive = false;
@@ -70,7 +76,7 @@ export class WorkflowHost {
70
76
  this.hostId = options.runnerId ?? `host-${randomUUID()}`;
71
77
  this.databasePath = path.resolve(options.databasePath ?? workflowStatePath());
72
78
  this.stateDirectory = path.join(path.dirname(this.databasePath), "host");
73
- this.socketPath = hostSocketPath(this.databasePath);
79
+ this.socketPath = clientSocketPath(this.databasePath);
74
80
  this.lockPath = path.join(this.stateDirectory, "host.lock.json");
75
81
  this.state = new StateDatabase({ filePath: this.databasePath });
76
82
  this.hostState = new HostStateStore(this.databasePath, { state: this.state });
@@ -90,6 +96,7 @@ export class WorkflowHost {
90
96
  },
91
97
  snapshotLifecycle: (context) => this.applyLifecycleProjection(context),
92
98
  });
99
+ this.views = new HostViewStore(this.state, this.queue, this.hostState, this.runStore, (runId) => this.activeRuns.has(runId));
93
100
  this.registry = options.registry ?? new HostProcessRegistry(this.stateDirectory);
94
101
  }
95
102
  get endpoint() {
@@ -156,8 +163,11 @@ export class WorkflowHost {
156
163
  clearInterval(this.heartbeatTimer);
157
164
  if (this.pollTimer !== null)
158
165
  clearInterval(this.pollTimer);
166
+ if (this.viewTimer !== null)
167
+ clearInterval(this.viewTimer);
159
168
  this.heartbeatTimer = null;
160
169
  this.pollTimer = null;
170
+ this.viewTimer = null;
161
171
  const server = this.server;
162
172
  this.server = null;
163
173
  await this.closeServer(server);
@@ -179,6 +189,7 @@ export class WorkflowHost {
179
189
  }
180
190
  }));
181
191
  await Promise.allSettled(this.activationTasks.values());
192
+ await Promise.allSettled(this.maintenanceCommands.values());
182
193
  this.registry.killAll();
183
194
  this.deliveryClaims.clear();
184
195
  if (this.claim !== null)
@@ -258,6 +269,11 @@ export class WorkflowHost {
258
269
  void this.claimControllerOne();
259
270
  }, this.options.claimPollMs ?? CLAIM_POLL_MS);
260
271
  this.pollTimer.unref?.();
272
+ this.viewTimer = setInterval(() => {
273
+ this.views.expireActivity();
274
+ this.publishViews();
275
+ }, 250);
276
+ this.viewTimer.unref?.();
261
277
  }
262
278
  async listen() {
263
279
  if (process.platform !== "win32")
@@ -293,8 +309,20 @@ export class WorkflowHost {
293
309
  }
294
310
  handleConnection(socket) {
295
311
  this.sockets.add(socket);
312
+ const connection = {
313
+ id: `connection-${randomUUID()}`,
314
+ socket,
315
+ subscriptions: new Map(),
316
+ publishing: false,
317
+ };
318
+ this.connections.set(socket, connection);
319
+ socket.write(encodeProtocolLine({
320
+ schema: CLIENT_PROTOCOL_SCHEMA,
321
+ type: "hello",
322
+ connectionId: connection.id,
323
+ packageVersion: PACKAGE_VERSION,
324
+ }));
296
325
  const decoder = new NdjsonFrameDecoder();
297
- let processing = Promise.resolve();
298
326
  socket.on("data", (chunk) => {
299
327
  let frames;
300
328
  try {
@@ -305,14 +333,13 @@ export class WorkflowHost {
305
333
  return;
306
334
  }
307
335
  for (const frame of frames) {
308
- processing = processing
309
- .then(async () => {
310
- const request = parseHostRequest(frame);
311
- const response = this.handleRequest(request);
336
+ void (async () => {
337
+ const request = parseClientRequest(frame);
338
+ const response = await this.handleClientRequest(connection, request);
312
339
  if (!socket.write(encodeProtocolLine(response)))
313
- await once(socket, "drain");
314
- })
315
- .catch(() => {
340
+ await waitForSocketDrain(socket);
341
+ this.publishConnection(connection);
342
+ })().catch(() => {
316
343
  socket.destroy();
317
344
  });
318
345
  }
@@ -323,12 +350,243 @@ export class WorkflowHost {
323
350
  });
324
351
  socket.on("close", () => {
325
352
  this.sockets.delete(socket);
353
+ this.connections.delete(socket);
354
+ this.views.clearConnection(connection.id);
326
355
  });
327
356
  }
357
+ async handleClientRequest(connection, request) {
358
+ try {
359
+ switch (request.operation) {
360
+ case "view.runs.watch":
361
+ this.addSubscription(connection, request, "runs");
362
+ return clientResponse(request.requestId, "accepted", { subscribed: true });
363
+ case "view.runs.page": {
364
+ const payload = requireRecord(request.payload, "view.runs.page payload");
365
+ const revision = requireString(payload.revision, "revision");
366
+ const page = this.views.list(requireNonNegativeInteger(payload.cursor, "cursor"), payload.limit === undefined
367
+ ? undefined
368
+ : requirePositiveInteger(payload.limit, "limit"));
369
+ return page.revision === revision
370
+ ? clientResponse(request.requestId, "accepted", toJsonValue(page))
371
+ : clientResponse(request.requestId, "conflict", toJsonValue(page), "Workflow run list changed while paging");
372
+ }
373
+ case "view.run.get": {
374
+ const view = this.views.run(requireRunId(request));
375
+ return view === null
376
+ ? clientResponse(request.requestId, "notFound", undefined, "Workflow run not found")
377
+ : clientResponse(request.requestId, "accepted", toJsonValue(view), undefined, view.revision);
378
+ }
379
+ case "view.run.watch": {
380
+ const runId = requireRunId(request);
381
+ if (this.views.run(runId) === null) {
382
+ return clientResponse(request.requestId, "notFound", undefined, "Workflow run not found");
383
+ }
384
+ this.addSubscription(connection, request, "run", runId);
385
+ return clientResponse(request.requestId, "accepted", { subscribed: true });
386
+ }
387
+ case "view.session.watch": {
388
+ const payload = requireRecord(request.payload, "view.session.watch payload");
389
+ this.addSubscription(connection, request, "session", requireString(payload.sessionId, "sessionId"));
390
+ return clientResponse(request.requestId, "accepted", { subscribed: true });
391
+ }
392
+ case "view.run.unwatch": {
393
+ const payload = requireRecord(request.payload, "view.run.unwatch payload");
394
+ connection.subscriptions.delete(requireString(payload.subscriptionId, "subscriptionId"));
395
+ return clientResponse(request.requestId, "accepted", { subscribed: false });
396
+ }
397
+ case "view.page": {
398
+ const payload = requireRecord(request.payload, "view.page payload");
399
+ const kind = requireString(payload.kind, "kind");
400
+ if (!WORKFLOW_PAGE_KINDS.includes(kind)) {
401
+ throw new Error("view.page kind is invalid");
402
+ }
403
+ const view = this.views.page(requireRunId(request), {
404
+ kind: kind,
405
+ cursor: requireNonNegativeInteger(payload.cursor, "cursor"),
406
+ });
407
+ return view === null
408
+ ? clientResponse(request.requestId, "notFound", undefined, "Workflow run not found")
409
+ : clientResponse(request.requestId, "accepted", runPageReceipt(view, kind, requireNonNegativeInteger(payload.cursor, "cursor")), undefined, view.revision);
410
+ }
411
+ case "view.content": {
412
+ const payload = requireRecord(request.payload, "view.content payload");
413
+ const content = this.views.content(requireRunId(request), requireString(payload.path, "path"), requireNonNegativeInteger(payload.offset, "offset"));
414
+ return content === null
415
+ ? clientResponse(request.requestId, "notFound", undefined, "Workflow content not found")
416
+ : clientResponse(request.requestId, "accepted", content);
417
+ }
418
+ case "activity.report": {
419
+ this.views.reportActivity(connection.id, parseActivityReport(request.payload));
420
+ return clientResponse(request.requestId, "accepted", { recorded: true });
421
+ }
422
+ case "interaction.submit":
423
+ return await this.submitInteractionAndWait(request);
424
+ case "state.status":
425
+ return clientResponse(request.requestId, "accepted", this.stateStatusReceipt());
426
+ case "state.verify":
427
+ this.state.integrityCheck();
428
+ return clientResponse(request.requestId, "accepted", { valid: true });
429
+ case "state.backup":
430
+ return await this.executeMaintenanceCommand(request, async () => {
431
+ const payload = requireRecord(request.payload, "state.backup payload");
432
+ const destination = requireAbsolutePath(payload.destination, "destination");
433
+ await this.state.backup(destination);
434
+ return { destination };
435
+ });
436
+ case "state.prune":
437
+ return await this.executeMaintenanceCommand(request, async () => {
438
+ const payload = requireRecord(request.payload, "state.prune payload");
439
+ const before = requireString(payload.before, "before");
440
+ const apply = requireBoolean(payload.apply, "apply");
441
+ const backupPath = payload.backupPath === undefined
442
+ ? undefined
443
+ : requireAbsolutePath(payload.backupPath, "backupPath");
444
+ const report = await pruneState(this.state, this.databasePath, {
445
+ before,
446
+ apply,
447
+ ...(backupPath === undefined ? {} : { backupPath }),
448
+ });
449
+ return toJsonValue(report);
450
+ });
451
+ default:
452
+ return this.handleRequest(request);
453
+ }
454
+ }
455
+ catch (error) {
456
+ return clientResponse(request.requestId, "rejected", undefined, errorMessage(error));
457
+ }
458
+ }
459
+ async executeMaintenanceCommand(request, operation) {
460
+ const adopted = this.hostState.adoptCommand(request);
461
+ if (adopted !== undefined)
462
+ return adopted;
463
+ const claim = this.claim;
464
+ if (claim === null || this.stopping) {
465
+ return clientResponse(request.requestId, "unavailable", undefined, "Workflow host is stopping");
466
+ }
467
+ const key = canonicalJson([request.clientId, request.idempotencyKey]);
468
+ const active = this.maintenanceCommands.get(key);
469
+ if (active !== undefined) {
470
+ await active;
471
+ return (this.hostState.adoptCommand(request) ??
472
+ clientResponse(request.requestId, "unavailable", undefined, "Workflow maintenance command did not complete durably"));
473
+ }
474
+ const execution = (async () => {
475
+ let result;
476
+ try {
477
+ result = { outcome: "accepted", receipt: await operation() };
478
+ }
479
+ catch (error) {
480
+ result = { outcome: "rejected", error: errorMessage(error) };
481
+ }
482
+ return this.hostState.executeCommand(request, claim.epoch, () => result);
483
+ })();
484
+ this.maintenanceCommands.set(key, execution);
485
+ try {
486
+ return await execution;
487
+ }
488
+ finally {
489
+ if (this.maintenanceCommands.get(key) === execution) {
490
+ this.maintenanceCommands.delete(key);
491
+ }
492
+ }
493
+ }
494
+ addSubscription(connection, request, kind, target) {
495
+ const payload = requireRecord(request.payload, `${request.operation} payload`);
496
+ const id = requireString(payload.subscriptionId, "subscriptionId");
497
+ const limit = kind === "runs" && payload.limit !== undefined
498
+ ? requirePositiveInteger(payload.limit, "limit")
499
+ : undefined;
500
+ connection.subscriptions.set(id, {
501
+ id,
502
+ kind,
503
+ ...(target === undefined ? {} : { target }),
504
+ ...(limit === undefined ? {} : { limit }),
505
+ revision: 0,
506
+ });
507
+ }
508
+ publishViews() {
509
+ for (const connection of this.connections.values())
510
+ void this.publishConnection(connection);
511
+ }
512
+ async publishConnection(connection) {
513
+ if (connection.publishing || connection.socket.destroyed)
514
+ return;
515
+ connection.publishing = true;
516
+ try {
517
+ for (const subscription of connection.subscriptions.values()) {
518
+ const payload = subscription.kind === "runs"
519
+ ? toJsonValue(this.views.list(0, subscription.limit))
520
+ : subscription.kind === "run"
521
+ ? toJsonValue(this.views.run(subscription.target ?? ""))
522
+ : toJsonValue(this.views.session(subscription.target ?? ""));
523
+ const digest = createHash("sha256").update(canonicalJson(payload)).digest("hex");
524
+ if (subscription.digest === digest)
525
+ continue;
526
+ subscription.digest = digest;
527
+ subscription.revision += 1;
528
+ const event = {
529
+ schema: CLIENT_PROTOCOL_SCHEMA,
530
+ type: "event",
531
+ subscriptionId: subscription.id,
532
+ event: subscription.kind === "runs"
533
+ ? "runs"
534
+ : subscription.kind === "run"
535
+ ? "run_snapshot"
536
+ : "session_snapshot",
537
+ revision: subscription.revision,
538
+ ...(subscription.kind === "run" && subscription.target !== undefined
539
+ ? { runId: subscription.target }
540
+ : {}),
541
+ payload,
542
+ };
543
+ if (!connection.socket.write(encodeProtocolLine(event))) {
544
+ await waitForSocketDrain(connection.socket);
545
+ if (connection.socket.destroyed)
546
+ return;
547
+ }
548
+ }
549
+ }
550
+ catch (error) {
551
+ this.log(`client view error: ${errorMessage(error)}`);
552
+ connection.socket.destroy();
553
+ }
554
+ finally {
555
+ connection.publishing = false;
556
+ }
557
+ }
558
+ async submitInteractionAndWait(request) {
559
+ const started = this.submitInteraction(request);
560
+ if (started.outcome !== "accepted" && started.outcome !== "adopted") {
561
+ return clientResponse(request.requestId, started.outcome, started.receipt, started.error);
562
+ }
563
+ const payload = requireRecord(request.payload, "interaction payload");
564
+ const requestId = requireString(payload.requestId, "requestId");
565
+ const startedReceipt = requireRecord(started.receipt, "interaction submission receipt");
566
+ const submissionId = requireString(startedReceipt.submissionId, "submissionId");
567
+ for (;;) {
568
+ if (this.stopping) {
569
+ return clientResponse(request.requestId, "unavailable", undefined, "Workflow host stopped while validating the submission");
570
+ }
571
+ const submission = this.hostState.interactionSubmission(requestId, submissionId);
572
+ if (submission?.outcome === "accepted" || submission?.outcome === "adopted") {
573
+ return clientResponse(request.requestId, started.outcome, submission.receipt ?? { requestId, submissionId });
574
+ }
575
+ if (submission?.outcome === "rejected") {
576
+ const receipt = submission.receipt ?? { requestId, submissionId };
577
+ const detail = isObjectRecord(receipt) && typeof receipt.error === "string"
578
+ ? receipt.error
579
+ : "Workflow step output failed validation";
580
+ return clientResponse(request.requestId, "rejected", receipt, detail);
581
+ }
582
+ await hostDelay(25);
583
+ }
584
+ }
328
585
  handleRequest(request) {
329
586
  if (this.claim === null || this.stopping) {
330
587
  return {
331
- schema: "pi-workflows.host-response.v1",
588
+ schema: CLIENT_PROTOCOL_SCHEMA,
589
+ type: "response",
332
590
  requestId: request.requestId,
333
591
  outcome: "unavailable",
334
592
  error: "Workflow host is stopping",
@@ -341,7 +599,8 @@ export class WorkflowHost {
341
599
  }
342
600
  catch (error) {
343
601
  response = {
344
- schema: "pi-workflows.host-response.v1",
602
+ schema: CLIENT_PROTOCOL_SCHEMA,
603
+ type: "response",
345
604
  requestId: request.requestId,
346
605
  outcome: "rejected",
347
606
  error: errorMessage(error),
@@ -469,6 +728,21 @@ export class WorkflowHost {
469
728
  return this.executeControllerOperation(request);
470
729
  case "interaction.update": {
471
730
  const payload = requireRecord(request.payload, "interaction update payload");
731
+ if (payload.validatePresentation === true) {
732
+ const interaction = this.hostState.getInteraction(requireString(payload.requestId, "requestId"));
733
+ const expectedRevision = requireNonNegativeInteger(request.expectedRevision, "expectedRevision");
734
+ const expiresAt = interaction?.presentationClaimExpiresAt;
735
+ const live = interaction !== undefined &&
736
+ interaction.runId === requireRunId(request) &&
737
+ interaction.status === "presenting" &&
738
+ interaction.presenterId === request.clientId &&
739
+ interaction.revision === expectedRevision &&
740
+ interaction.presentationSessionEntryId === null &&
741
+ typeof expiresAt === "string" &&
742
+ Date.parse(expiresAt) > Date.now() &&
743
+ !this.queue.isWorkflowRunPaused(interaction.runId);
744
+ return { outcome: "accepted", receipt: { live } };
745
+ }
472
746
  if (payload.claimPresentation === true) {
473
747
  try {
474
748
  const interaction = this.hostState.claimInteractionPresentation({
@@ -508,6 +782,20 @@ export class WorkflowHost {
508
782
  return this.submitInteraction(request);
509
783
  case "decision.answer":
510
784
  return this.answerDecision(request, afterCommit);
785
+ case "view.runs.watch":
786
+ case "view.runs.page":
787
+ case "view.run.get":
788
+ case "view.run.watch":
789
+ case "view.run.unwatch":
790
+ case "view.page":
791
+ case "view.content":
792
+ case "view.session.watch":
793
+ case "activity.report":
794
+ case "state.status":
795
+ case "state.verify":
796
+ case "state.backup":
797
+ case "state.prune":
798
+ throw new Error(`${request.operation} must use the live client connection`);
511
799
  }
512
800
  }
513
801
  executeControllerOperation(request) {
@@ -608,6 +896,27 @@ export class WorkflowHost {
608
896
  )`),
609
897
  };
610
898
  }
899
+ stateStatusReceipt() {
900
+ const count = (sql, ...params) => {
901
+ const row = this.state.connection.prepare(sql).get(...params);
902
+ return typeof row?.count === "number" ? row.count : 0;
903
+ };
904
+ const now = Date.now();
905
+ return {
906
+ sizeBytes: fs.statSync(this.databasePath).size,
907
+ counts: {
908
+ resources: count("SELECT COUNT(*) AS count FROM resources"),
909
+ runs: count("SELECT COUNT(*) AS count FROM runs"),
910
+ controllers: count("SELECT COUNT(*) AS count FROM controller_resources"),
911
+ decisions: count("SELECT COUNT(*) AS count FROM human_decisions"),
912
+ settingsScopes: count("SELECT COUNT(*) AS count FROM workflow_settings"),
913
+ pendingInteractions: count("SELECT COUNT(*) AS count FROM interactive_requests WHERE status IN ('pending', 'presenting')"),
914
+ pendingFollowUps: count("SELECT COUNT(*) AS count FROM workflow_follow_ups WHERE status IN ('queued', 'pending_presentation', 'ready')"),
915
+ activeLeases: count("SELECT COUNT(*) AS count FROM leases WHERE owner_id IS NOT NULL AND expires_at > ?", now),
916
+ unsettledEffects: count("SELECT COUNT(*) AS count FROM effects WHERE status IN ('pending', 'applying', 'ambiguous')"),
917
+ },
918
+ };
919
+ }
611
920
  rememberDeliveryClaim(options) {
612
921
  const now = Date.now();
613
922
  for (const [claimId, claim] of this.deliveryClaims) {
@@ -1020,6 +1329,9 @@ export class WorkflowHost {
1020
1329
  },
1021
1330
  });
1022
1331
  const interaction = submission.interaction;
1332
+ const receipt = isObjectRecord(submission.receipt)
1333
+ ? { ...submission.receipt, requestId, submissionId: submission.submissionId }
1334
+ : { requestId, submissionId: submission.submissionId, receipt: submission.receipt };
1023
1335
  if (this.activeRuns.has(interaction.runId) || this.activationTasks.has(interaction.runId)) {
1024
1336
  this.pendingResumes.add(interaction.runId);
1025
1337
  }
@@ -1039,7 +1351,7 @@ export class WorkflowHost {
1039
1351
  return {
1040
1352
  outcome: submission.outcome,
1041
1353
  revision: interaction.revision,
1042
- receipt: submission.receipt,
1354
+ receipt,
1043
1355
  };
1044
1356
  }
1045
1357
  async claimControllerOne() {
@@ -1322,7 +1634,7 @@ export class WorkflowHost {
1322
1634
  const projectPath = this.queue.workflowRunProjectPath(request.runId);
1323
1635
  if (projectPath === undefined)
1324
1636
  throw new Error("Workflow run project is missing");
1325
- const resolver = new WorkflowHostClient({
1637
+ const resolver = new WorkflowClient({
1326
1638
  databasePath: this.databasePath,
1327
1639
  ...(this.options.env === undefined ? {} : { env: this.options.env }),
1328
1640
  });
@@ -1369,7 +1681,7 @@ export class WorkflowHost {
1369
1681
  const existing = this.queue.getWorkflowRun(request.runId);
1370
1682
  if (existing !== undefined)
1371
1683
  return controllerWorkflowResult(existing);
1372
- const resolver = new WorkflowHostClient({
1684
+ const resolver = new WorkflowClient({
1373
1685
  databasePath: this.databasePath,
1374
1686
  ...(this.options.env === undefined ? {} : { env: this.options.env }),
1375
1687
  });
@@ -2430,6 +2742,129 @@ function controllerPathAllowed(projectPath, controllerName, controllerPath) {
2430
2742
  }
2431
2743
  return controllerSearchDirs({ cwd: projectPath }).some(({ dir }) => path.dirname(controllerPath) === path.resolve(dir));
2432
2744
  }
2745
+ function waitForSocketDrain(socket) {
2746
+ if (socket.destroyed)
2747
+ return Promise.resolve();
2748
+ return new Promise((resolve, reject) => {
2749
+ const cleanup = () => {
2750
+ socket.off("drain", onDrain);
2751
+ socket.off("close", onClose);
2752
+ socket.off("error", onError);
2753
+ };
2754
+ const onDrain = () => {
2755
+ cleanup();
2756
+ resolve();
2757
+ };
2758
+ const onClose = () => {
2759
+ cleanup();
2760
+ resolve();
2761
+ };
2762
+ const onError = (error) => {
2763
+ cleanup();
2764
+ reject(error);
2765
+ };
2766
+ socket.once("drain", onDrain);
2767
+ socket.once("close", onClose);
2768
+ socket.once("error", onError);
2769
+ if (socket.destroyed)
2770
+ onClose();
2771
+ });
2772
+ }
2773
+ function hostDelay(ms) {
2774
+ return new Promise((resolve) => setTimeout(resolve, ms));
2775
+ }
2776
+ function runPageReceipt(view, kind, cursor) {
2777
+ const base = {
2778
+ schema: "pi-workflows.run-page.v1",
2779
+ runId: view.runId,
2780
+ revision: view.revision,
2781
+ kind,
2782
+ cursor,
2783
+ };
2784
+ if (kind === "steps") {
2785
+ const state = requireRecord(view.state, "run state view");
2786
+ return {
2787
+ ...base,
2788
+ start: view.stepStart,
2789
+ total: view.stepTotal,
2790
+ items: Array.isArray(state.steps) ? state.steps : [],
2791
+ graphSteps: view.graphSteps,
2792
+ graphCursor: view.graphCursor,
2793
+ takenTransitions: view.takenTransitions,
2794
+ };
2795
+ }
2796
+ if (kind === "trace" || kind === "trace_at_step") {
2797
+ return { ...base, ...requireRecord(view.tracePage, "trace page") };
2798
+ }
2799
+ if (kind === "session_entries" || kind === "session_events") {
2800
+ const session = requireRecord(view.session, "session view");
2801
+ const page = requireRecord(kind === "session_entries" ? session.entryPage : session.eventPage, "session page");
2802
+ return {
2803
+ ...base,
2804
+ ...page,
2805
+ ...(kind === "session_events" ? { replayCheckpoint: session.replayCheckpoint } : {}),
2806
+ };
2807
+ }
2808
+ if (kind === "settings") {
2809
+ return {
2810
+ ...base,
2811
+ start: view.settingsStart,
2812
+ total: view.settingsTotal,
2813
+ items: view.settingsScopes,
2814
+ };
2815
+ }
2816
+ if (kind === "follow_ups") {
2817
+ const queue = isObjectRecord(view.followUpQueue) ? view.followUpQueue : {};
2818
+ return {
2819
+ ...base,
2820
+ start: view.followUpStart,
2821
+ total: view.followUpTotal,
2822
+ items: Array.isArray(queue.items) ? queue.items : [],
2823
+ };
2824
+ }
2825
+ return {
2826
+ ...base,
2827
+ start: view.updateStart,
2828
+ total: view.updateTotal,
2829
+ items: view.updates,
2830
+ };
2831
+ }
2832
+ function clientResponse(requestId, outcome, receipt, error, revision) {
2833
+ return {
2834
+ schema: CLIENT_PROTOCOL_SCHEMA,
2835
+ type: "response",
2836
+ requestId,
2837
+ outcome,
2838
+ ...(revision === undefined ? {} : { revision }),
2839
+ ...(receipt === undefined ? {} : { receipt }),
2840
+ ...(error === undefined ? {} : { error: boundedClientError(error) }),
2841
+ };
2842
+ }
2843
+ function boundedClientError(error) {
2844
+ const singleLine = error.replaceAll(/[\r\n\t]+/gu, " ").trim();
2845
+ if (singleLine.length === 0)
2846
+ return "Workflow request failed";
2847
+ return singleLine.length <= 1_000 ? singleLine : `${singleLine.slice(0, 997)}...`;
2848
+ }
2849
+ function parseActivityReport(payload) {
2850
+ const value = requireRecord(payload, "activity.report payload");
2851
+ const state = requireString(value.state, "state");
2852
+ if (state !== "started" && state !== "refresh" && state !== "settled") {
2853
+ throw new Error("activity state must be started, refresh, or settled");
2854
+ }
2855
+ return {
2856
+ sessionId: requireString(value.sessionId, "sessionId"),
2857
+ runId: requireString(value.runId, "runId"),
2858
+ requestId: requireString(value.requestId, "requestId"),
2859
+ deliveryId: requireString(value.deliveryId, "deliveryId"),
2860
+ sessionEntryId: requireString(value.sessionEntryId, "sessionEntryId"),
2861
+ sequence: requireNonNegativeInteger(value.sequence, "sequence"),
2862
+ state,
2863
+ };
2864
+ }
2865
+ function toJsonValue(value) {
2866
+ return JSON.parse(canonicalJson(value));
2867
+ }
2433
2868
  function payloadLimit(payload) {
2434
2869
  if (typeof payload !== "object" || payload === null || Array.isArray(payload))
2435
2870
  return 100;
@@ -2441,6 +2876,9 @@ function requireRunId(request) {
2441
2876
  throw new Error(`${request.operation} requires runId`);
2442
2877
  return request.runId;
2443
2878
  }
2879
+ function isObjectRecord(value) {
2880
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2881
+ }
2444
2882
  function requireRecord(value, name) {
2445
2883
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
2446
2884
  throw new Error(`${name} must be an object`);
@@ -2459,6 +2897,17 @@ function requireAbsolutePath(value, name) {
2459
2897
  throw new Error(`${name} must be an absolute path`);
2460
2898
  return parsed;
2461
2899
  }
2900
+ function requireBoolean(value, name) {
2901
+ if (typeof value !== "boolean")
2902
+ throw new Error(`${name} must be a boolean`);
2903
+ return value;
2904
+ }
2905
+ function requirePositiveInteger(value, name) {
2906
+ if (!Number.isSafeInteger(value) || value <= 0) {
2907
+ throw new Error(`${name} must be a positive integer`);
2908
+ }
2909
+ return value;
2910
+ }
2462
2911
  function requireNonNegativeInteger(value, name) {
2463
2912
  if (!Number.isSafeInteger(value) || value < 0) {
2464
2913
  throw new Error(`${name} must be a non-negative integer`);
@@ -2470,6 +2919,13 @@ function isRevisionRow(value) {
2470
2919
  value !== null &&
2471
2920
  typeof value.revision === "number");
2472
2921
  }
2922
+ function runtimePackageVersion() {
2923
+ const parsed = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
2924
+ if (typeof parsed.version !== "string" || parsed.version.length === 0) {
2925
+ throw new Error("Package version is missing");
2926
+ }
2927
+ return parsed.version;
2928
+ }
2473
2929
  function isLockRecord(value) {
2474
2930
  return (typeof value === "object" &&
2475
2931
  value !== null &&