@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,15 +1,15 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import path from "node:path";
3
3
  import { BUILTIN_WORKFLOW_METADATA } from "../builtins/metadata.js";
4
- import { SqliteControllerStore } from "../controllers/sqlite.js";
5
- import { WorkflowHostClient } from "../host/client.js";
6
- import { HostStateStore } from "../host/state.js";
7
- import { workflowStatePath } from "../state/database.js";
4
+ import { ORIGIN_ACTIVITY_REFRESH_MS } from "../client/activity.js";
5
+ import { WorkflowClient } from "../client/client.js";
6
+ import { materializeSessionView } from "../client/materialize.js";
8
7
  import { canonicalJson, parseJson } from "../state/json.js";
9
8
  import { errorMessage } from "../workflows/errors.js";
10
9
  import { discoverWorkflows } from "../workflows/loader.js";
11
- import { createRunId, WorkflowRunStore } from "../workflows/store.js";
10
+ import { createRunId } from "../workflows/store.js";
12
11
  import { parseControllerArgs } from "./controller-command.js";
12
+ import { HerdrWorkflowViewer, PIW_SHORTCUT, PIW_SHORTCUT_HINT, VIEWER_PLACEMENTS, } from "./herdr-viewer.js";
13
13
  import { SessionDeliveryCoordinator } from "./session-delivery.js";
14
14
  import { SessionWorkflowView } from "./session-view.js";
15
15
  import { recoverAssistantStep, registerWorkflowAgentStepMessageRenderer, WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA, WORKFLOW_AGENT_STEP_MESSAGE_TYPE, } from "./step-message.js";
@@ -17,10 +17,13 @@ import { parseWorkflowToolInput, WorkflowToolParameters } from "./workflow-tool.
17
17
  export { parseControllerArgs } from "./controller-command.js";
18
18
  export { PiDecisionChannel, TelegramDecisionChannel, audienceChannels, createTelegramChannels, decisionConfigDir, loadDecisionChannelConfig, verifyTelegramTokenFile, writeDecisionChannelProfile, } from "./decision-channels.js";
19
19
  const INTERACTION_POLL_MS = 1_000;
20
- const SUBMISSION_POLL_MS = 50;
21
20
  const WORKFLOW_INTERACTION_MESSAGE_TYPE = "pi-workflows-interaction";
22
21
  const WORKFLOW_NOTIFICATION_MESSAGE_TYPE = "pi-workflows-notification";
23
22
  const WORKFLOW_PRESENTATION_MESSAGE_TYPE = "pi-workflows-presentation";
23
+ const sessionSnapshots = new Map();
24
+ export function activityStateForConnection(state, previousConnectionId, connectionId) {
25
+ return state === "refresh" && previousConnectionId !== connectionId ? "started" : state;
26
+ }
24
27
  /** Parse `/workflow` arguments. Exported for tests. */
25
28
  export function parseWorkflowArgs(args) {
26
29
  const trimmed = args.trim();
@@ -90,13 +93,58 @@ export function parseWorkflowArgs(args) {
90
93
  }
91
94
  export default function piWorkflows(pi) {
92
95
  registerWorkflowAgentStepMessageRenderer(pi);
93
- const client = new WorkflowHostClient({ clientId: `pi-extension-${randomUUID()}` });
96
+ let client = new WorkflowClient({ clientId: `pi-extension-${randomUUID()}` });
97
+ const herdrViewer = new HerdrWorkflowViewer(pi.exec);
94
98
  let sessionContext = null;
99
+ let sessionGeneration = 0;
100
+ let sessionUnsubscribe = null;
95
101
  let pollTimer = null;
96
102
  let presentationTail = Promise.resolve();
97
103
  let toolTail = Promise.resolve();
104
+ let originActivity = null;
105
+ let originActivityConnectionId = null;
106
+ let activityTimer = null;
98
107
  const sessionDelivery = new SessionDeliveryCoordinator();
99
108
  const sessionView = new SessionWorkflowView();
109
+ const reportOriginActivity = async (activity, state) => {
110
+ const connection = await client.connect();
111
+ const reportState = activityStateForConnection(state, originActivityConnectionId, connection.connectionId);
112
+ const response = await client.request({
113
+ operation: "activity.report",
114
+ runId: activity.runId,
115
+ payload: { ...activity, state: reportState },
116
+ });
117
+ if (response.outcome !== "accepted" && response.outcome !== "adopted") {
118
+ throw new Error(response.error ?? "Workflow host rejected origin activity");
119
+ }
120
+ originActivityConnectionId =
121
+ reportState === "settled" ? null : (client.connectionId ?? connection.connectionId);
122
+ };
123
+ const startOriginActivity = (presented) => {
124
+ if (activityTimer !== null)
125
+ clearInterval(activityTimer);
126
+ originActivityConnectionId = null;
127
+ originActivity = { ...presented, sequence: 0 };
128
+ void reportOriginActivity(originActivity, "started").catch(() => undefined);
129
+ activityTimer = setInterval(() => {
130
+ if (originActivity === null)
131
+ return;
132
+ originActivity.sequence += 1;
133
+ void reportOriginActivity(originActivity, "refresh").catch(() => undefined);
134
+ }, ORIGIN_ACTIVITY_REFRESH_MS);
135
+ activityTimer.unref?.();
136
+ };
137
+ const settleOriginActivity = async () => {
138
+ if (activityTimer !== null)
139
+ clearInterval(activityTimer);
140
+ activityTimer = null;
141
+ const settled = originActivity;
142
+ originActivity = null;
143
+ if (settled === null)
144
+ return;
145
+ settled.sequence += 1;
146
+ await reportOriginActivity(settled, "settled").catch(() => undefined);
147
+ };
100
148
  const presentInOrder = async (ctx) => {
101
149
  const prior = presentationTail;
102
150
  let release;
@@ -105,10 +153,13 @@ export default function piWorkflows(pi) {
105
153
  });
106
154
  await prior;
107
155
  try {
156
+ const deliveries = sessionSnapshots.get(ctx.sessionManager.getSessionId())?.deliveries;
108
157
  await sessionDelivery.synchronize(ctx, [
109
- () => claimPendingInteractionDelivery(pi, client, ctx),
110
- () => claimPendingNotificationDelivery(pi, client, ctx),
111
- () => claimPendingTurnDelivery(pi, client, ctx),
158
+ () => claimPendingInteractionDelivery(pi, client, ctx, startOriginActivity),
159
+ ...(deliveries?.notification === true
160
+ ? [() => claimPendingNotificationDelivery(pi, client, ctx)]
161
+ : []),
162
+ ...(deliveries?.turn === true ? [() => claimPendingTurnDelivery(pi, client, ctx)] : []),
112
163
  ]);
113
164
  }
114
165
  finally {
@@ -116,6 +167,25 @@ export default function piWorkflows(pi) {
116
167
  release?.();
117
168
  }
118
169
  };
170
+ const openPiw = async (ctx, requestedPlacement) => {
171
+ const run = sessionSnapshots.get(ctx.sessionManager.getSessionId())?.run;
172
+ if (run === null || run === undefined || !isRecord(run.state)) {
173
+ ctx.ui.notify("No active workflow is available for piw.", "warning");
174
+ return;
175
+ }
176
+ const capability = await herdrViewer.probe();
177
+ if (!capability.available) {
178
+ ctx.ui.notify(capability.reason, "warning");
179
+ return;
180
+ }
181
+ const placement = requestedPlacement ??
182
+ (await ctx.ui.select("Open workflow viewer", [...VIEWER_PLACEMENTS]));
183
+ if (placement === undefined)
184
+ return;
185
+ const workflowName = typeof run.state.workflowName === "string" ? run.state.workflowName : run.runId;
186
+ const opened = await herdrViewer.open({ runId: run.runId, workflowName }, placement, ctx.cwd);
187
+ ctx.ui.notify(opened.reused ? "Focused the existing piw view." : "Opened piw in Herdr.", "info");
188
+ };
119
189
  const runToolInOrder = async (operation) => {
120
190
  const prior = toolTail;
121
191
  let release;
@@ -123,8 +193,12 @@ export default function piWorkflows(pi) {
123
193
  release = resolve;
124
194
  });
125
195
  await prior;
126
- await presentationTail;
127
196
  try {
197
+ await presentationTail;
198
+ const currentContext = sessionContext;
199
+ if (currentContext === null)
200
+ throw new Error("Workflow session is unavailable");
201
+ await presentInOrder(currentContext);
128
202
  return await operation();
129
203
  }
130
204
  finally {
@@ -200,7 +274,7 @@ export default function piWorkflows(pi) {
200
274
  }
201
275
  const response = await requestAccepted(client, {
202
276
  operation: params.action === "update" ? "interaction.update" : "interaction.submit",
203
- requestId: `${params.action}-${toolCallId}`,
277
+ requestId: `${params.action}-${toolCallId}-${randomUUID()}`,
204
278
  idempotencyKey: toolCallId,
205
279
  runId: interaction.runId,
206
280
  expectedRevision: interaction.revision,
@@ -211,10 +285,8 @@ export default function piWorkflows(pi) {
211
285
  attempt: params.attempt,
212
286
  value: params.action === "update" ? { update: params.update } : { output: params.output },
213
287
  }),
288
+ ...(signal === undefined ? {} : { signal }),
214
289
  });
215
- if (params.action === "submit") {
216
- await waitForInteractionSubmission(interaction.requestId, toolCallId, signal);
217
- }
218
290
  await presentInOrder(ctx);
219
291
  return toolResult(params.action === "update"
220
292
  ? "Workflow update accepted; the step remains active."
@@ -227,6 +299,32 @@ export default function piWorkflows(pi) {
227
299
  });
228
300
  },
229
301
  });
302
+ pi.registerCommand("piw", {
303
+ description: "Open the active workflow in Herdr",
304
+ handler: async (args, ctx) => {
305
+ try {
306
+ const requested = args.trim();
307
+ if (requested.length > 0 && !VIEWER_PLACEMENTS.includes(requested)) {
308
+ throw new Error(`Unknown piw placement: ${requested}`);
309
+ }
310
+ await openPiw(ctx, requested.length === 0 ? undefined : requested);
311
+ }
312
+ catch (error) {
313
+ ctx.ui.notify(`Could not open piw: ${errorMessage(error)}`, "warning");
314
+ }
315
+ },
316
+ });
317
+ pi.registerShortcut(PIW_SHORTCUT, {
318
+ description: "Open the active workflow in Herdr",
319
+ handler: async (ctx) => {
320
+ try {
321
+ await openPiw(ctx);
322
+ }
323
+ catch (error) {
324
+ ctx.ui.notify(`Could not open piw: ${errorMessage(error)}`, "warning");
325
+ }
326
+ },
327
+ });
230
328
  pi.registerShortcut("shift+up", {
231
329
  description: "Scroll the workflow widget up",
232
330
  handler: (ctx) => sessionView.scrollUp(ctx),
@@ -235,20 +333,63 @@ export default function piWorkflows(pi) {
235
333
  description: "Scroll the workflow widget down",
236
334
  handler: (ctx) => sessionView.scrollDown(ctx),
237
335
  });
238
- pi.on("session_start", async (_event, ctx) => {
336
+ pi.on("session_start", (_event, ctx) => {
239
337
  sessionContext = ctx;
240
- try {
241
- await client.ensureRunning();
242
- await presentInOrder(ctx);
243
- }
244
- catch (error) {
245
- ctx.ui.notify(`Workflow host is unavailable: ${errorMessage(error)}`, "warning");
246
- }
338
+ const sessionId = ctx.sessionManager.getSessionId();
339
+ const generation = ++sessionGeneration;
340
+ let snapshotGeneration = 0;
341
+ const sessionClient = client;
247
342
  pollTimer = setInterval(() => {
248
343
  if (sessionContext !== null)
249
344
  void presentInOrder(sessionContext).catch(() => undefined);
250
345
  }, INTERACTION_POLL_MS);
251
346
  pollTimer.unref?.();
347
+ void (async () => {
348
+ try {
349
+ await sessionClient.ensureAvailable();
350
+ const unsubscribe = await sessionClient.watchSession(sessionId, (event) => {
351
+ if (generation !== sessionGeneration || sessionContext !== ctx)
352
+ return;
353
+ const currentSnapshotGeneration = ++snapshotGeneration;
354
+ if (event.event === "unavailable") {
355
+ sessionSnapshots.delete(sessionId);
356
+ sessionView.clear(ctx);
357
+ return;
358
+ }
359
+ if (!isWorkflowSessionView(event.payload))
360
+ return;
361
+ void materializeSessionView(sessionClient, event.payload)
362
+ .then((session) => {
363
+ if (generation !== sessionGeneration ||
364
+ currentSnapshotGeneration !== snapshotGeneration ||
365
+ sessionContext !== ctx) {
366
+ return;
367
+ }
368
+ sessionSnapshots.set(sessionId, session);
369
+ sessionView.update(session, ctx);
370
+ void presentInOrder(ctx).catch(() => undefined);
371
+ })
372
+ .catch(() => {
373
+ // A newer session revision retries from its own stable snapshot.
374
+ });
375
+ });
376
+ if (generation !== sessionGeneration || sessionContext !== ctx) {
377
+ await unsubscribe();
378
+ return;
379
+ }
380
+ sessionUnsubscribe = unsubscribe;
381
+ const capability = await herdrViewer.probe();
382
+ if (generation !== sessionGeneration || sessionContext !== ctx)
383
+ return;
384
+ sessionView.setActionHint(capability.available ? PIW_SHORTCUT_HINT : undefined, ctx);
385
+ await presentInOrder(ctx);
386
+ }
387
+ catch (error) {
388
+ if (generation === sessionGeneration && sessionContext === ctx) {
389
+ ctx.ui.notify(`Workflow host is unavailable: ${errorMessage(error)}`, "warning");
390
+ }
391
+ }
392
+ })();
252
393
  });
253
394
  pi.on("agent_end", async (event, ctx) => {
254
395
  const interrupted = ctx.signal?.aborted === true ||
@@ -264,6 +405,7 @@ export default function piWorkflows(pi) {
264
405
  const turnHasPrompt = event.messages.some((message) => interactionRequestId(message) === interaction.requestId);
265
406
  if (!turnHasPrompt)
266
407
  return;
408
+ await settleOriginActivity();
267
409
  try {
268
410
  const pauseId = `escape-pause-${interaction.runId}-${randomUUID()}`;
269
411
  await requestAccepted(client, {
@@ -286,15 +428,27 @@ export default function piWorkflows(pi) {
286
428
  catch (error) {
287
429
  ctx.ui.notify(`Workflow response was rejected: ${errorMessage(error)}`, "error");
288
430
  }
431
+ await settleOriginActivity();
289
432
  await presentInOrder(ctx).catch(() => undefined);
290
433
  });
291
434
  pi.on("session_shutdown", async (_event, ctx) => {
435
+ sessionGeneration += 1;
292
436
  sessionContext = null;
293
437
  sessionDelivery.clear();
294
438
  sessionView.clear(ctx);
439
+ sessionSnapshots.delete(ctx.sessionManager.getSessionId());
440
+ if (activityTimer !== null)
441
+ clearInterval(activityTimer);
442
+ activityTimer = null;
443
+ originActivity = null;
295
444
  if (pollTimer !== null)
296
445
  clearInterval(pollTimer);
297
446
  pollTimer = null;
447
+ if (sessionUnsubscribe !== null)
448
+ await sessionUnsubscribe().catch(() => undefined);
449
+ sessionUnsubscribe = null;
450
+ await client.close();
451
+ client = new WorkflowClient({ clientId: `pi-extension-${randomUUID()}` });
298
452
  });
299
453
  }
300
454
  async function executeCommand(client, ctx, command, idempotencyKey = randomUUID(), authority = "human") {
@@ -310,7 +464,7 @@ async function executeCommand(client, ctx, command, idempotencyKey = randomUUID(
310
464
  };
311
465
  }
312
466
  case "run": {
313
- await client.ensureRunning();
467
+ await client.ensureAvailable();
314
468
  const resolved = await client.resolveWorkflow({ cwd: ctx.cwd, workflowRef: command.ref });
315
469
  const runId = createRunId(resolved.workflowName);
316
470
  const response = await requestAccepted(client, {
@@ -497,10 +651,15 @@ async function executeControllerCommand(client, ctx, command) {
497
651
  details: { action: command.kind, resource: response.receipt ?? null },
498
652
  };
499
653
  }
500
- async function claimPendingInteractionDelivery(pi, client, ctx) {
501
- const interaction = pendingInteractionForSession(ctx.sessionManager.getSessionId());
502
- if (interaction === undefined || interaction.presentationSessionEntryId !== null)
654
+ async function claimPendingInteractionDelivery(pi, client, ctx, onPresented) {
655
+ const storedInteraction = pendingInteractionForSession(ctx.sessionManager.getSessionId());
656
+ if (storedInteraction === undefined || storedInteraction.presentationSessionEntryId !== null)
503
657
  return undefined;
658
+ const interaction = {
659
+ ...storedInteraction,
660
+ contract: await client.hydrateContent(storedInteraction.runId, storedInteraction.contract),
661
+ };
662
+ rememberInteractiveRequest(ctx.sessionManager.getSessionId(), interaction);
504
663
  const findSessionEntryId = (entries) => entryIdentifier(entries.find((entry) => interactionRequestId(entry) === interaction.requestId));
505
664
  const existingEntryId = findSessionEntryId(ctx.sessionManager.getBranch());
506
665
  let presentationRevision = interaction.revision;
@@ -512,7 +671,7 @@ async function claimPendingInteractionDelivery(pi, client, ctx) {
512
671
  Date.parse(interaction.presentationClaimExpiresAt) > Date.now()) {
513
672
  return undefined;
514
673
  }
515
- await client.ensureRunning();
674
+ await client.ensureAvailable();
516
675
  const claim = await client.request({
517
676
  operation: "interaction.update",
518
677
  requestId: `claim-presentation-${interaction.requestId}-${interaction.revision}-${client.clientId}`,
@@ -531,13 +690,14 @@ async function claimPendingInteractionDelivery(pi, client, ctx) {
531
690
  const receipt = isRecord(claim.receipt) ? claim.receipt : undefined;
532
691
  presentationRevision = claim.revision;
533
692
  claimExpiresAt = claimExpiry(receipt?.presentationClaimExpiresAt, "presentation claim");
693
+ rememberInteractiveRequest(ctx.sessionManager.getSessionId(), claim.receipt);
534
694
  }
535
695
  const contract = interactionContract(interaction);
536
696
  return {
537
697
  deliveryId: `interaction:${interaction.requestId}`,
538
698
  claimExpiresAt,
539
699
  isStillDeliverable: () => existingEntryId === undefined &&
540
- interactionPresentationClaimIsLive({
700
+ interactionPresentationClaimIsLive(client, {
541
701
  requestId: interaction.requestId,
542
702
  runId: interaction.runId,
543
703
  presenterId: client.clientId,
@@ -579,7 +739,7 @@ async function claimPendingInteractionDelivery(pi, client, ctx) {
579
739
  }, { triggerTurn: true });
580
740
  },
581
741
  settle: async (sessionEntryId) => {
582
- await requestAccepted(client, {
742
+ const response = await requestAccepted(client, {
583
743
  operation: "interaction.update",
584
744
  requestId: `present-${interaction.requestId}-${sessionEntryId}`,
585
745
  idempotencyKey: `present-${interaction.requestId}-${sessionEntryId}`,
@@ -587,13 +747,21 @@ async function claimPendingInteractionDelivery(pi, client, ctx) {
587
747
  expectedRevision: presentationRevision,
588
748
  payload: { requestId: interaction.requestId, sessionEntryId },
589
749
  });
750
+ rememberInteractiveRequest(ctx.sessionManager.getSessionId(), response.receipt);
751
+ if (interaction.kind !== "decision") {
752
+ onPresented({
753
+ sessionId: ctx.sessionManager.getSessionId(),
754
+ runId: interaction.runId,
755
+ requestId: interaction.requestId,
756
+ deliveryId: `interaction:${interaction.requestId}`,
757
+ sessionEntryId,
758
+ });
759
+ }
590
760
  },
591
761
  };
592
762
  }
593
763
  async function claimPendingNotificationDelivery(pi, client, ctx) {
594
764
  const sessionId = ctx.sessionManager.getSessionId();
595
- if (!hasClaimableNotification(sessionId))
596
- return undefined;
597
765
  const claimRequestId = randomUUID();
598
766
  const claimed = await requestAccepted(client, {
599
767
  operation: "notification.claim",
@@ -647,9 +815,8 @@ async function claimPendingNotificationDelivery(pi, client, ctx) {
647
815
  }
648
816
  async function claimPendingTurnDelivery(pi, client, ctx) {
649
817
  const sessionId = ctx.sessionManager.getSessionId();
650
- if (pendingInteractionForSession(sessionId) !== undefined || !hasClaimableTurn(sessionId)) {
818
+ if (pendingInteractionForSession(sessionId) !== undefined)
651
819
  return undefined;
652
- }
653
820
  const claimRequestId = randomUUID();
654
821
  const claimed = await requestAccepted(client, {
655
822
  operation: "turn.claim",
@@ -665,9 +832,12 @@ async function claimPendingTurnDelivery(pi, client, ctx) {
665
832
  const claimExpiresAt = claimExpiry(receipt?.claimExpiresAt, "turn claim");
666
833
  const intentId = requireText(turn.intentId, "turn intentId");
667
834
  const runId = requireText(turn.runId, "turn runId");
668
- const state = terminalRunState(runId);
669
- if (state === undefined)
670
- return undefined;
835
+ const claimedRun = await client.getRun(runId);
836
+ if (claimedRun === null)
837
+ throw new Error(`Claimed workflow turn run is missing: ${runId}`);
838
+ const state = await client.hydrateContent(runId, claimedRun.state);
839
+ if (!isRecord(state))
840
+ throw new Error("Workflow terminal state content is invalid");
671
841
  return {
672
842
  deliveryId: `turn:${intentId}`,
673
843
  claimExpiresAt,
@@ -731,148 +901,57 @@ async function submitVisibleAssistantResponse(client, ctx) {
731
901
  value: submission,
732
902
  },
733
903
  });
734
- await waitForInteractionSubmission(interaction.requestId, `assistant-${responseId}`);
735
904
  }
736
- async function waitForInteractionSubmission(requestId, submissionId, signal) {
737
- const store = new HostStateStore(workflowStatePath(), { readOnly: true });
738
- try {
739
- for (;;) {
740
- if (signal?.aborted === true) {
741
- throw signal.reason ?? new Error("Workflow submission validation was cancelled");
742
- }
743
- const submission = store.interactionSubmission(requestId, submissionId);
744
- if (submission?.outcome === "accepted" || submission?.outcome === "adopted")
745
- return;
746
- if (submission?.outcome === "rejected") {
747
- const receipt = isRecord(submission.receipt) ? submission.receipt : undefined;
748
- throw new Error(typeof receipt?.error === "string"
749
- ? receipt.error
750
- : "Workflow step output failed validation");
751
- }
752
- await waitForSubmissionPoll(signal);
753
- }
754
- }
755
- finally {
756
- store.close();
757
- }
758
- }
759
- async function waitForSubmissionPoll(signal) {
760
- if (signal?.aborted === true) {
761
- throw signal.reason ?? new Error("Workflow submission was cancelled");
762
- }
763
- await new Promise((resolve, reject) => {
764
- const timer = setTimeout(finish, SUBMISSION_POLL_MS);
765
- const onAbort = () => finish(signal?.reason ?? new Error("Workflow submission was cancelled"));
766
- function finish(error) {
767
- clearTimeout(timer);
768
- signal?.removeEventListener("abort", onAbort);
769
- if (error === undefined)
770
- resolve();
771
- else
772
- reject(error);
773
- }
774
- signal?.addEventListener("abort", onAbort, { once: true });
905
+ function rememberInteractiveRequest(sessionId, value) {
906
+ const current = parseInteractiveRequest(value);
907
+ const session = sessionSnapshots.get(sessionId);
908
+ if (current === undefined || session === undefined)
909
+ return;
910
+ let found = false;
911
+ const pendingInteractions = session.pendingInteractions.map((item) => {
912
+ const stored = parseInteractiveRequest(item);
913
+ if (stored?.requestId !== current.requestId)
914
+ return item;
915
+ found = true;
916
+ return current;
775
917
  });
918
+ if (found)
919
+ sessionSnapshots.set(sessionId, { ...session, pendingInteractions });
776
920
  }
777
921
  function pendingInteractionForSession(sessionId) {
778
- try {
779
- const store = new HostStateStore(workflowStatePath(), { readOnly: true });
780
- try {
781
- return store.listPendingInteractions(sessionId)[0];
782
- }
783
- finally {
784
- store.close();
785
- }
786
- }
787
- catch {
788
- return undefined;
789
- }
922
+ return sessionSnapshots
923
+ .get(sessionId)
924
+ ?.pendingInteractions.map(parseInteractiveRequest)
925
+ .find((request) => request !== undefined);
790
926
  }
791
927
  function pendingDecision(sessionId, runId) {
792
- try {
793
- const store = new HostStateStore(workflowStatePath(), { readOnly: true });
794
- try {
795
- return store
796
- .listPendingInteractions(sessionId)
797
- .find((request) => request.kind === "decision" && (runId === undefined || request.runId === runId));
798
- }
799
- finally {
800
- store.close();
801
- }
802
- }
803
- catch {
804
- return undefined;
805
- }
928
+ return sessionSnapshots
929
+ .get(sessionId)
930
+ ?.pendingInteractions.map(parseInteractiveRequest)
931
+ .find((request) => request !== undefined &&
932
+ request.kind === "decision" &&
933
+ (runId === undefined || request.runId === runId));
806
934
  }
807
- function hasClaimableNotification(sessionId) {
808
- try {
809
- const store = new SqliteControllerStore(workflowStatePath(), { readOnly: true, global: true });
810
- try {
811
- return (store.listPendingWorkflowNotifications({ targetSessionId: sessionId, limit: 1 }).length > 0);
812
- }
813
- finally {
814
- store.close();
815
- }
816
- }
817
- catch {
818
- return false;
935
+ function workflowRunPaused(runId) {
936
+ for (const session of sessionSnapshots.values()) {
937
+ if (session.run?.runId === runId)
938
+ return session.run.display.status === "paused";
819
939
  }
940
+ return false;
820
941
  }
821
- function hasClaimableTurn(sessionId) {
822
- try {
823
- const store = new SqliteControllerStore(workflowStatePath(), { readOnly: true, global: true });
824
- try {
825
- const now = Date.now();
826
- return store
827
- .listWorkflowTurnIntents({ targetSessionId: sessionId, unresolvedOnly: true, limit: 10 })
828
- .some((intent) => intent.eligibleAt !== null &&
829
- Date.parse(intent.eligibleAt) <= now &&
830
- (intent.deliveryClaimExpiresAt === null ||
831
- Date.parse(intent.deliveryClaimExpiresAt) <= now));
832
- }
833
- finally {
834
- store.close();
835
- }
836
- }
837
- catch {
942
+ async function interactionPresentationClaimIsLive(client, options) {
943
+ if (options.claimExpiresAt <= Date.now() || options.presenterId !== client.clientId)
838
944
  return false;
839
- }
840
- }
841
- function terminalRunState(runId) {
842
945
  try {
843
- const store = new WorkflowRunStore(workflowStatePath(), { readOnly: true });
844
- try {
845
- const loaded = store.readRun(runId);
846
- return loaded === null ? undefined : loaded.state;
847
- }
848
- finally {
849
- store.close();
850
- }
851
- }
852
- catch {
853
- return undefined;
854
- }
855
- }
856
- function workflowRunPaused(runId) {
857
- return terminalRunState(runId)?.paused === true;
858
- }
859
- function interactionPresentationClaimIsLive(options) {
860
- try {
861
- const store = new HostStateStore(workflowStatePath(), { readOnly: true });
862
- try {
863
- const interaction = store.getInteraction(options.requestId);
864
- return (interaction?.runId === options.runId &&
865
- interaction.status === "presenting" &&
866
- interaction.presenterId === options.presenterId &&
867
- interaction.revision === options.revision &&
868
- interaction.presentationSessionEntryId === null &&
869
- interaction.presentationClaimExpiresAt !== null &&
870
- Date.parse(interaction.presentationClaimExpiresAt) === options.claimExpiresAt &&
871
- !workflowRunPaused(options.runId));
872
- }
873
- finally {
874
- store.close();
875
- }
946
+ const response = await client.request({
947
+ operation: "interaction.update",
948
+ runId: options.runId,
949
+ expectedRevision: options.revision,
950
+ payload: { requestId: options.requestId, validatePresentation: true },
951
+ });
952
+ return ((response.outcome === "accepted" || response.outcome === "adopted") &&
953
+ isRecord(response.receipt) &&
954
+ response.receipt.live === true);
876
955
  }
877
956
  catch {
878
957
  return false;
@@ -942,21 +1021,14 @@ function activeSessionRun(ctx) {
942
1021
  return sessionRun(ctx);
943
1022
  }
944
1023
  function sessionRun(ctx, runId) {
945
- try {
946
- const store = new SqliteControllerStore(workflowStatePath(), { readOnly: true, global: true });
947
- try {
948
- const run = runId === undefined
949
- ? store.findSessionReservation(ctx.sessionManager.getSessionId())
950
- : store.getWorkflowRun(runId);
951
- return run?.originSessionId === ctx.sessionManager.getSessionId() ? run : undefined;
952
- }
953
- finally {
954
- store.close();
955
- }
956
- }
957
- catch {
1024
+ const session = sessionSnapshots.get(ctx.sessionManager.getSessionId());
1025
+ if (session?.run === null || session?.run === undefined || !isRecord(session.run.queue)) {
958
1026
  return undefined;
959
1027
  }
1028
+ if (runId !== undefined && session.run.runId !== runId)
1029
+ return undefined;
1030
+ const queue = session.run.queue;
1031
+ return queue.originSessionId === ctx.sessionManager.getSessionId() ? queue : undefined;
960
1032
  }
961
1033
  async function listWorkflowMetadata(cwd) {
962
1034
  const files = await discoverWorkflows({ cwd });
@@ -997,8 +1069,10 @@ function toolInputToCommand(params) {
997
1069
  }
998
1070
  }
999
1071
  async function requestAccepted(client, options) {
1000
- await client.ensureRunning();
1001
- const response = await client.request(options);
1072
+ await client.ensureAvailable();
1073
+ const response = options.idempotencyKey === undefined
1074
+ ? await client.request(options)
1075
+ : await client.requestDurable({ ...options, idempotencyKey: options.idempotencyKey });
1002
1076
  if (response.outcome !== "accepted" && response.outcome !== "adopted") {
1003
1077
  throw new Error(response.error ?? `Workflow host rejected ${options.operation}`);
1004
1078
  }
@@ -1122,6 +1196,27 @@ function toolResult(message, details) {
1122
1196
  function validRunId(value) {
1123
1197
  return /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/u.test(value);
1124
1198
  }
1199
+ function isWorkflowSessionView(value) {
1200
+ return (isRecord(value) &&
1201
+ value.schema === "pi-workflows.session-view.v1" &&
1202
+ typeof value.sessionId === "string" &&
1203
+ Array.isArray(value.pendingInteractions) &&
1204
+ isRecord(value.deliveries) &&
1205
+ typeof value.deliveries.notification === "boolean" &&
1206
+ typeof value.deliveries.turn === "boolean" &&
1207
+ (value.run === null || isRecord(value.run)));
1208
+ }
1209
+ function parseInteractiveRequest(value) {
1210
+ if (!isRecord(value) ||
1211
+ typeof value.requestId !== "string" ||
1212
+ typeof value.runId !== "string" ||
1213
+ typeof value.targetSessionId !== "string" ||
1214
+ typeof value.revision !== "number" ||
1215
+ (value.kind !== "agent" && value.kind !== "assistant" && value.kind !== "decision")) {
1216
+ return undefined;
1217
+ }
1218
+ return value;
1219
+ }
1125
1220
  function isRecord(value) {
1126
1221
  return typeof value === "object" && value !== null && !Array.isArray(value);
1127
1222
  }