@osolmaz/pi-workflows 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +6 -6
  2. package/dist/client/activity.d.ts +2 -0
  3. package/dist/client/activity.js +6 -0
  4. package/dist/client/activity.js.map +1 -0
  5. package/dist/client/client.d.ts +101 -0
  6. package/dist/client/client.js +733 -0
  7. package/dist/client/client.js.map +1 -0
  8. package/dist/client/index.d.ts +3 -0
  9. package/dist/client/index.js +3 -0
  10. package/dist/client/index.js.map +1 -0
  11. package/dist/client/materialize.d.ts +7 -0
  12. package/dist/client/materialize.js +177 -0
  13. package/dist/client/materialize.js.map +1 -0
  14. package/dist/client/protocol.d.ts +60 -0
  15. package/dist/client/protocol.js +269 -0
  16. package/dist/client/protocol.js.map +1 -0
  17. package/dist/client/resolver.d.ts +23 -0
  18. package/dist/client/resolver.js +2 -0
  19. package/dist/client/resolver.js.map +1 -0
  20. package/dist/client/view.d.ts +118 -0
  21. package/dist/client/view.js +3 -0
  22. package/dist/client/view.js.map +1 -0
  23. package/dist/controllers/sqlite.d.ts +64 -0
  24. package/dist/controllers/sqlite.js +219 -3
  25. package/dist/controllers/sqlite.js.map +1 -1
  26. package/dist/extension/index.d.ts +1 -0
  27. package/dist/extension/index.js +384 -156
  28. package/dist/extension/index.js.map +1 -1
  29. package/dist/extension/session-delivery.d.ts +6 -0
  30. package/dist/extension/session-delivery.js +80 -25
  31. package/dist/extension/session-delivery.js.map +1 -1
  32. package/dist/extension/session-view.d.ts +21 -0
  33. package/dist/extension/session-view.js +127 -0
  34. package/dist/extension/session-view.js.map +1 -0
  35. package/dist/extension/widget.d.ts +2 -1
  36. package/dist/extension/widget.js +15 -7
  37. package/dist/extension/widget.js.map +1 -1
  38. package/dist/host/child-worker-supervisor.js +1 -1
  39. package/dist/host/child-worker-supervisor.js.map +1 -1
  40. package/dist/host/resolver-entry.d.ts +2 -23
  41. package/dist/host/resolver-entry.js +1 -1
  42. package/dist/host/resolver-entry.js.map +1 -1
  43. package/dist/host/runner.d.ts +12 -0
  44. package/dist/host/runner.js +565 -43
  45. package/dist/host/runner.js.map +1 -1
  46. package/dist/host/state.d.ts +8 -3
  47. package/dist/host/state.js +59 -27
  48. package/dist/host/state.js.map +1 -1
  49. package/dist/host/view.d.ts +73 -0
  50. package/dist/host/view.js +871 -0
  51. package/dist/host/view.js.map +1 -0
  52. package/dist/host/worker-protocol.js +1 -1
  53. package/dist/host/worker-protocol.js.map +1 -1
  54. package/dist/state/database.d.ts +1 -0
  55. package/dist/state/database.js +15 -0
  56. package/dist/state/database.js.map +1 -1
  57. package/dist/state/prune.d.ts +3 -1
  58. package/dist/state/prune.js +6 -8
  59. package/dist/state/prune.js.map +1 -1
  60. package/dist/state/schema.js +12 -1
  61. package/dist/state/schema.js.map +1 -1
  62. package/dist/viewer/backup.d.ts +2 -0
  63. package/dist/viewer/backup.js +28 -0
  64. package/dist/viewer/backup.js.map +1 -0
  65. package/dist/viewer/cli.d.ts +4 -0
  66. package/dist/viewer/cli.js +150 -170
  67. package/dist/viewer/cli.js.map +1 -1
  68. package/dist/viewer/tui.d.ts +5 -7
  69. package/dist/viewer/tui.js +188 -108
  70. package/dist/viewer/tui.js.map +1 -1
  71. package/dist/workflows/store.d.ts +62 -1
  72. package/dist/workflows/store.js +350 -44
  73. package/dist/workflows/store.js.map +1 -1
  74. package/docs/2026-09-01-restore-session-delivery-controls-plan.md +139 -0
  75. package/docs/2026-09-01-unified-workflow-client-plan.md +381 -0
  76. package/docs/2026-09-02-installed-live-e2e-plan.md +225 -0
  77. package/docs/SQLITE_STATE.md +13 -11
  78. package/docs/WORKFLOW_HOST.md +81 -64
  79. package/docs/WORKFLOW_STEP_MESSAGES.md +4 -4
  80. package/docs/development.md +2 -1
  81. package/docs/live-replay-protocol.md +70 -132
  82. package/docs/tui-viewer.md +10 -14
  83. package/docs/workflows.md +56 -3
  84. package/herdr-plugin.toml +1 -1
  85. package/package.json +9 -3
  86. package/protocol/client.v1.schema.json +137 -0
  87. package/protocol/fixtures/client-v1.json +23 -0
  88. package/src/client/activity.ts +6 -0
  89. package/src/client/client.ts +935 -0
  90. package/src/client/index.ts +24 -0
  91. package/src/client/materialize.ts +228 -0
  92. package/src/client/protocol.ts +327 -0
  93. package/src/client/resolver.ts +26 -0
  94. package/src/client/view.ts +138 -0
  95. package/src/controllers/sqlite.ts +342 -3
  96. package/src/extension/index.ts +482 -171
  97. package/src/extension/session-delivery.ts +88 -25
  98. package/src/extension/session-view.ts +154 -0
  99. package/src/extension/widget.ts +18 -9
  100. package/src/host/child-worker-supervisor.ts +1 -1
  101. package/src/host/resolver-entry.ts +11 -26
  102. package/src/host/runner.ts +749 -75
  103. package/src/host/state.ts +82 -44
  104. package/src/host/view.ts +1084 -0
  105. package/src/host/worker-protocol.ts +1 -1
  106. package/src/state/database.ts +13 -0
  107. package/src/state/prune.ts +11 -11
  108. package/src/state/schema.ts +12 -1
  109. package/src/viewer/backup.ts +29 -0
  110. package/src/viewer/cli.ts +171 -185
  111. package/src/viewer/tui.ts +196 -124
  112. package/src/workflows/store.ts +500 -45
  113. package/dist/host/client.d.ts +0 -48
  114. package/dist/host/client.js +0 -216
  115. package/dist/host/client.js.map +0 -1
  116. package/dist/host/protocol.d.ts +0 -38
  117. package/dist/host/protocol.js +0 -156
  118. package/dist/host/protocol.js.map +0 -1
  119. package/dist/viewer/watch.d.ts +0 -6
  120. package/dist/viewer/watch.js +0 -46
  121. package/dist/viewer/watch.js.map +0 -1
  122. package/src/host/client.ts +0 -293
  123. package/src/host/protocol.ts +0 -196
  124. package/src/viewer/watch.ts +0 -51
@@ -1,25 +1,29 @@
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
+ import { SessionWorkflowView } from "./session-view.js";
14
15
  import { recoverAssistantStep, registerWorkflowAgentStepMessageRenderer, WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA, WORKFLOW_AGENT_STEP_MESSAGE_TYPE, } from "./step-message.js";
15
16
  import { parseWorkflowToolInput, WorkflowToolParameters } from "./workflow-tool.js";
16
17
  export { parseControllerArgs } from "./controller-command.js";
17
18
  export { PiDecisionChannel, TelegramDecisionChannel, audienceChannels, createTelegramChannels, decisionConfigDir, loadDecisionChannelConfig, verifyTelegramTokenFile, writeDecisionChannelProfile, } from "./decision-channels.js";
18
19
  const INTERACTION_POLL_MS = 1_000;
19
- const SUBMISSION_POLL_MS = 50;
20
20
  const WORKFLOW_INTERACTION_MESSAGE_TYPE = "pi-workflows-interaction";
21
21
  const WORKFLOW_NOTIFICATION_MESSAGE_TYPE = "pi-workflows-notification";
22
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
+ }
23
27
  /** Parse `/workflow` arguments. Exported for tests. */
24
28
  export function parseWorkflowArgs(args) {
25
29
  const trimmed = args.trim();
@@ -89,12 +93,58 @@ export function parseWorkflowArgs(args) {
89
93
  }
90
94
  export default function piWorkflows(pi) {
91
95
  registerWorkflowAgentStepMessageRenderer(pi);
92
- const client = new WorkflowHostClient({ clientId: `pi-extension-${randomUUID()}` });
96
+ let client = new WorkflowClient({ clientId: `pi-extension-${randomUUID()}` });
97
+ const herdrViewer = new HerdrWorkflowViewer(pi.exec);
93
98
  let sessionContext = null;
99
+ let sessionGeneration = 0;
100
+ let sessionUnsubscribe = null;
94
101
  let pollTimer = null;
95
102
  let presentationTail = Promise.resolve();
96
103
  let toolTail = Promise.resolve();
104
+ let originActivity = null;
105
+ let originActivityConnectionId = null;
106
+ let activityTimer = null;
97
107
  const sessionDelivery = new SessionDeliveryCoordinator();
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
+ };
98
148
  const presentInOrder = async (ctx) => {
99
149
  const prior = presentationTail;
100
150
  let release;
@@ -103,16 +153,39 @@ export default function piWorkflows(pi) {
103
153
  });
104
154
  await prior;
105
155
  try {
156
+ const deliveries = sessionSnapshots.get(ctx.sessionManager.getSessionId())?.deliveries;
106
157
  await sessionDelivery.synchronize(ctx, [
107
- () => claimPendingInteractionDelivery(pi, client, ctx),
108
- () => claimPendingNotificationDelivery(pi, client, ctx),
109
- () => 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)] : []),
110
163
  ]);
111
164
  }
112
165
  finally {
166
+ sessionView.refresh(ctx);
113
167
  release?.();
114
168
  }
115
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
+ };
116
189
  const runToolInOrder = async (operation) => {
117
190
  const prior = toolTail;
118
191
  let release;
@@ -120,8 +193,12 @@ export default function piWorkflows(pi) {
120
193
  release = resolve;
121
194
  });
122
195
  await prior;
123
- await presentationTail;
124
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);
125
202
  return await operation();
126
203
  }
127
204
  finally {
@@ -197,7 +274,7 @@ export default function piWorkflows(pi) {
197
274
  }
198
275
  const response = await requestAccepted(client, {
199
276
  operation: params.action === "update" ? "interaction.update" : "interaction.submit",
200
- requestId: `${params.action}-${toolCallId}`,
277
+ requestId: `${params.action}-${toolCallId}-${randomUUID()}`,
201
278
  idempotencyKey: toolCallId,
202
279
  runId: interaction.runId,
203
280
  expectedRevision: interaction.revision,
@@ -208,10 +285,8 @@ export default function piWorkflows(pi) {
208
285
  attempt: params.attempt,
209
286
  value: params.action === "update" ? { update: params.update } : { output: params.output },
210
287
  }),
288
+ ...(signal === undefined ? {} : { signal }),
211
289
  });
212
- if (params.action === "submit") {
213
- await waitForInteractionSubmission(interaction.requestId, toolCallId, signal);
214
- }
215
290
  await presentInOrder(ctx);
216
291
  return toolResult(params.action === "update"
217
292
  ? "Workflow update accepted; the step remains active."
@@ -224,20 +299,127 @@ export default function piWorkflows(pi) {
224
299
  });
225
300
  },
226
301
  });
227
- pi.on("session_start", async (_event, ctx) => {
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
+ });
328
+ pi.registerShortcut("shift+up", {
329
+ description: "Scroll the workflow widget up",
330
+ handler: (ctx) => sessionView.scrollUp(ctx),
331
+ });
332
+ pi.registerShortcut("shift+down", {
333
+ description: "Scroll the workflow widget down",
334
+ handler: (ctx) => sessionView.scrollDown(ctx),
335
+ });
336
+ pi.on("session_start", (_event, ctx) => {
228
337
  sessionContext = ctx;
229
- try {
230
- await client.ensureRunning();
231
- await presentInOrder(ctx);
232
- }
233
- catch (error) {
234
- ctx.ui.notify(`Workflow host is unavailable: ${errorMessage(error)}`, "warning");
235
- }
338
+ const sessionId = ctx.sessionManager.getSessionId();
339
+ const generation = ++sessionGeneration;
340
+ let snapshotGeneration = 0;
341
+ const sessionClient = client;
236
342
  pollTimer = setInterval(() => {
237
343
  if (sessionContext !== null)
238
344
  void presentInOrder(sessionContext).catch(() => undefined);
239
345
  }, INTERACTION_POLL_MS);
240
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
+ })();
393
+ });
394
+ pi.on("agent_end", async (event, ctx) => {
395
+ const interrupted = ctx.signal?.aborted === true ||
396
+ event.messages.some((message) => isRecord(message) && "stopReason" in message && message.stopReason === "aborted");
397
+ if (!interrupted)
398
+ return;
399
+ const interaction = pendingInteractionForSession(ctx.sessionManager.getSessionId());
400
+ if (interaction === undefined ||
401
+ interaction.kind === "decision" ||
402
+ workflowRunPaused(interaction.runId)) {
403
+ return;
404
+ }
405
+ const turnHasPrompt = event.messages.some((message) => interactionRequestId(message) === interaction.requestId);
406
+ if (!turnHasPrompt)
407
+ return;
408
+ await settleOriginActivity();
409
+ try {
410
+ const pauseId = `escape-pause-${interaction.runId}-${randomUUID()}`;
411
+ await requestAccepted(client, {
412
+ operation: "run.pause",
413
+ requestId: pauseId,
414
+ idempotencyKey: pauseId,
415
+ runId: interaction.runId,
416
+ });
417
+ sessionView.refresh(ctx);
418
+ ctx.ui.notify(`Workflow ${interaction.runId} paused because its model turn was interrupted. Use /workflow resume to continue.`, "info");
419
+ }
420
+ catch (error) {
421
+ ctx.ui.notify(`Could not pause interrupted workflow: ${errorMessage(error)}`, "warning");
422
+ }
241
423
  });
242
424
  pi.on("agent_settled", async (_event, ctx) => {
243
425
  try {
@@ -246,14 +428,27 @@ export default function piWorkflows(pi) {
246
428
  catch (error) {
247
429
  ctx.ui.notify(`Workflow response was rejected: ${errorMessage(error)}`, "error");
248
430
  }
431
+ await settleOriginActivity();
249
432
  await presentInOrder(ctx).catch(() => undefined);
250
433
  });
251
- pi.on("session_shutdown", async () => {
434
+ pi.on("session_shutdown", async (_event, ctx) => {
435
+ sessionGeneration += 1;
252
436
  sessionContext = null;
253
437
  sessionDelivery.clear();
438
+ sessionView.clear(ctx);
439
+ sessionSnapshots.delete(ctx.sessionManager.getSessionId());
440
+ if (activityTimer !== null)
441
+ clearInterval(activityTimer);
442
+ activityTimer = null;
443
+ originActivity = null;
254
444
  if (pollTimer !== null)
255
445
  clearInterval(pollTimer);
256
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()}` });
257
452
  });
258
453
  }
259
454
  async function executeCommand(client, ctx, command, idempotencyKey = randomUUID(), authority = "human") {
@@ -269,7 +464,7 @@ async function executeCommand(client, ctx, command, idempotencyKey = randomUUID(
269
464
  };
270
465
  }
271
466
  case "run": {
272
- await client.ensureRunning();
467
+ await client.ensureAvailable();
273
468
  const resolved = await client.resolveWorkflow({ cwd: ctx.cwd, workflowRef: command.ref });
274
469
  const runId = createRunId(resolved.workflowName);
275
470
  const response = await requestAccepted(client, {
@@ -456,15 +651,28 @@ async function executeControllerCommand(client, ctx, command) {
456
651
  details: { action: command.kind, resource: response.receipt ?? null },
457
652
  };
458
653
  }
459
- async function claimPendingInteractionDelivery(pi, client, ctx) {
460
- const interaction = pendingInteractionForSession(ctx.sessionManager.getSessionId());
461
- 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)
462
657
  return undefined;
658
+ const interaction = {
659
+ ...storedInteraction,
660
+ contract: await client.hydrateContent(storedInteraction.runId, storedInteraction.contract),
661
+ };
662
+ rememberInteractiveRequest(ctx.sessionManager.getSessionId(), interaction);
463
663
  const findSessionEntryId = (entries) => entryIdentifier(entries.find((entry) => interactionRequestId(entry) === interaction.requestId));
464
664
  const existingEntryId = findSessionEntryId(ctx.sessionManager.getBranch());
465
665
  let presentationRevision = interaction.revision;
666
+ let claimExpiresAt = Number.POSITIVE_INFINITY;
466
667
  if (existingEntryId === undefined) {
467
- const claim = await requestAccepted(client, {
668
+ if (workflowRunPaused(interaction.runId))
669
+ return undefined;
670
+ if (interaction.presentationClaimExpiresAt !== null &&
671
+ Date.parse(interaction.presentationClaimExpiresAt) > Date.now()) {
672
+ return undefined;
673
+ }
674
+ await client.ensureAvailable();
675
+ const claim = await client.request({
468
676
  operation: "interaction.update",
469
677
  requestId: `claim-presentation-${interaction.requestId}-${interaction.revision}-${client.clientId}`,
470
678
  idempotencyKey: `claim-presentation-${interaction.requestId}-${interaction.revision}-${client.clientId}`,
@@ -472,13 +680,30 @@ async function claimPendingInteractionDelivery(pi, client, ctx) {
472
680
  expectedRevision: interaction.revision,
473
681
  payload: { requestId: interaction.requestId, claimPresentation: true },
474
682
  });
683
+ if (claim.outcome === "conflict")
684
+ return undefined;
685
+ if (claim.outcome !== "accepted" && claim.outcome !== "adopted") {
686
+ throw new Error(claim.error ?? "Workflow host rejected interaction.update");
687
+ }
475
688
  if (claim.revision === undefined)
476
689
  throw new Error("Presentation claim has no revision");
690
+ const receipt = isRecord(claim.receipt) ? claim.receipt : undefined;
477
691
  presentationRevision = claim.revision;
692
+ claimExpiresAt = claimExpiry(receipt?.presentationClaimExpiresAt, "presentation claim");
693
+ rememberInteractiveRequest(ctx.sessionManager.getSessionId(), claim.receipt);
478
694
  }
479
695
  const contract = interactionContract(interaction);
480
696
  return {
481
697
  deliveryId: `interaction:${interaction.requestId}`,
698
+ claimExpiresAt,
699
+ isStillDeliverable: () => existingEntryId === undefined &&
700
+ interactionPresentationClaimIsLive(client, {
701
+ requestId: interaction.requestId,
702
+ runId: interaction.runId,
703
+ presenterId: client.clientId,
704
+ revision: presentationRevision,
705
+ claimExpiresAt,
706
+ }),
482
707
  findSessionEntryId,
483
708
  send: () => {
484
709
  if (interaction.kind === "decision") {
@@ -514,7 +739,7 @@ async function claimPendingInteractionDelivery(pi, client, ctx) {
514
739
  }, { triggerTurn: true });
515
740
  },
516
741
  settle: async (sessionEntryId) => {
517
- await requestAccepted(client, {
742
+ const response = await requestAccepted(client, {
518
743
  operation: "interaction.update",
519
744
  requestId: `present-${interaction.requestId}-${sessionEntryId}`,
520
745
  idempotencyKey: `present-${interaction.requestId}-${sessionEntryId}`,
@@ -522,13 +747,21 @@ async function claimPendingInteractionDelivery(pi, client, ctx) {
522
747
  expectedRevision: presentationRevision,
523
748
  payload: { requestId: interaction.requestId, sessionEntryId },
524
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
+ }
525
760
  },
526
761
  };
527
762
  }
528
763
  async function claimPendingNotificationDelivery(pi, client, ctx) {
529
764
  const sessionId = ctx.sessionManager.getSessionId();
530
- if (!hasClaimableNotification(sessionId))
531
- return undefined;
532
765
  const claimRequestId = randomUUID();
533
766
  const claimed = await requestAccepted(client, {
534
767
  operation: "notification.claim",
@@ -541,9 +774,17 @@ async function claimPendingNotificationDelivery(pi, client, ctx) {
541
774
  if (notification === undefined)
542
775
  return undefined;
543
776
  const claimId = requireText(receipt?.claimId, "notification claimId");
777
+ const claimExpiresAt = claimExpiry(receipt?.claimExpiresAt, "notification claim");
544
778
  const notificationId = requireText(notification.notificationId, "notificationId");
545
779
  return {
546
780
  deliveryId: `notification:${notificationId}`,
781
+ claimExpiresAt,
782
+ isStillDeliverable: () => hostDeliveryClaimIsLive(client, {
783
+ kind: "notification",
784
+ resourceId: notificationId,
785
+ targetSessionId: sessionId,
786
+ claimId,
787
+ }),
547
788
  findSessionEntryId: (entries) => entryIdentifier(entries.find((entry) => customMessageDetail(entry, WORKFLOW_NOTIFICATION_MESSAGE_TYPE, "notificationId") ===
548
789
  notificationId)),
549
790
  send: () => {
@@ -574,9 +815,8 @@ async function claimPendingNotificationDelivery(pi, client, ctx) {
574
815
  }
575
816
  async function claimPendingTurnDelivery(pi, client, ctx) {
576
817
  const sessionId = ctx.sessionManager.getSessionId();
577
- if (pendingInteractionForSession(sessionId) !== undefined || !hasClaimableTurn(sessionId)) {
818
+ if (pendingInteractionForSession(sessionId) !== undefined)
578
819
  return undefined;
579
- }
580
820
  const claimRequestId = randomUUID();
581
821
  const claimed = await requestAccepted(client, {
582
822
  operation: "turn.claim",
@@ -589,13 +829,24 @@ async function claimPendingTurnDelivery(pi, client, ctx) {
589
829
  if (turn === undefined)
590
830
  return undefined;
591
831
  const claimId = requireText(receipt?.claimId, "turn claimId");
832
+ const claimExpiresAt = claimExpiry(receipt?.claimExpiresAt, "turn claim");
592
833
  const intentId = requireText(turn.intentId, "turn intentId");
593
834
  const runId = requireText(turn.runId, "turn runId");
594
- const state = terminalRunState(runId);
595
- if (state === undefined)
596
- 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");
597
841
  return {
598
842
  deliveryId: `turn:${intentId}`,
843
+ claimExpiresAt,
844
+ isStillDeliverable: () => hostDeliveryClaimIsLive(client, {
845
+ kind: "turn",
846
+ resourceId: intentId,
847
+ targetSessionId: sessionId,
848
+ claimId,
849
+ }),
599
850
  findSessionEntryId: (entries) => entryIdentifier(entries.find((entry) => customMessageDetail(entry, WORKFLOW_PRESENTATION_MESSAGE_TYPE, "intentId") === intentId)),
600
851
  send: () => {
601
852
  pi.sendMessage({
@@ -622,8 +873,11 @@ async function claimPendingTurnDelivery(pi, client, ctx) {
622
873
  }
623
874
  async function submitVisibleAssistantResponse(client, ctx) {
624
875
  const interaction = pendingInteractionForSession(ctx.sessionManager.getSessionId());
625
- if (interaction === undefined || interaction.kind !== "assistant")
876
+ if (interaction === undefined ||
877
+ interaction.kind !== "assistant" ||
878
+ workflowRunPaused(interaction.runId)) {
626
879
  return;
880
+ }
627
881
  const contract = agentContract(interaction);
628
882
  if (contract === undefined)
629
883
  return;
@@ -647,126 +901,76 @@ async function submitVisibleAssistantResponse(client, ctx) {
647
901
  value: submission,
648
902
  },
649
903
  });
650
- await waitForInteractionSubmission(interaction.requestId, `assistant-${responseId}`);
651
- }
652
- async function waitForInteractionSubmission(requestId, submissionId, signal) {
653
- const store = new HostStateStore(workflowStatePath(), { readOnly: true });
654
- try {
655
- for (;;) {
656
- if (signal?.aborted === true) {
657
- throw signal.reason ?? new Error("Workflow submission validation was cancelled");
658
- }
659
- const submission = store.interactionSubmission(requestId, submissionId);
660
- if (submission?.outcome === "accepted" || submission?.outcome === "adopted")
661
- return;
662
- if (submission?.outcome === "rejected") {
663
- const receipt = isRecord(submission.receipt) ? submission.receipt : undefined;
664
- throw new Error(typeof receipt?.error === "string"
665
- ? receipt.error
666
- : "Workflow step output failed validation");
667
- }
668
- await waitForSubmissionPoll(signal);
669
- }
670
- }
671
- finally {
672
- store.close();
673
- }
674
904
  }
675
- async function waitForSubmissionPoll(signal) {
676
- if (signal?.aborted === true) {
677
- throw signal.reason ?? new Error("Workflow submission was cancelled");
678
- }
679
- await new Promise((resolve, reject) => {
680
- const timer = setTimeout(finish, SUBMISSION_POLL_MS);
681
- const onAbort = () => finish(signal?.reason ?? new Error("Workflow submission was cancelled"));
682
- function finish(error) {
683
- clearTimeout(timer);
684
- signal?.removeEventListener("abort", onAbort);
685
- if (error === undefined)
686
- resolve();
687
- else
688
- reject(error);
689
- }
690
- 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;
691
917
  });
918
+ if (found)
919
+ sessionSnapshots.set(sessionId, { ...session, pendingInteractions });
692
920
  }
693
921
  function pendingInteractionForSession(sessionId) {
694
- try {
695
- const store = new HostStateStore(workflowStatePath(), { readOnly: true });
696
- try {
697
- return store.listPendingInteractions(sessionId)[0];
698
- }
699
- finally {
700
- store.close();
701
- }
702
- }
703
- catch {
704
- return undefined;
705
- }
922
+ return sessionSnapshots
923
+ .get(sessionId)
924
+ ?.pendingInteractions.map(parseInteractiveRequest)
925
+ .find((request) => request !== undefined);
706
926
  }
707
927
  function pendingDecision(sessionId, runId) {
708
- try {
709
- const store = new HostStateStore(workflowStatePath(), { readOnly: true });
710
- try {
711
- return store
712
- .listPendingInteractions(sessionId)
713
- .find((request) => request.kind === "decision" && (runId === undefined || request.runId === runId));
714
- }
715
- finally {
716
- store.close();
717
- }
718
- }
719
- catch {
720
- return undefined;
721
- }
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));
722
934
  }
723
- function hasClaimableNotification(sessionId) {
724
- try {
725
- const store = new SqliteControllerStore(workflowStatePath(), { readOnly: true, global: true });
726
- try {
727
- return (store.listPendingWorkflowNotifications({ targetSessionId: sessionId, limit: 1 }).length > 0);
728
- }
729
- finally {
730
- store.close();
731
- }
732
- }
733
- catch {
734
- 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";
735
939
  }
940
+ return false;
736
941
  }
737
- function hasClaimableTurn(sessionId) {
942
+ async function interactionPresentationClaimIsLive(client, options) {
943
+ if (options.claimExpiresAt <= Date.now() || options.presenterId !== client.clientId)
944
+ return false;
738
945
  try {
739
- const store = new SqliteControllerStore(workflowStatePath(), { readOnly: true, global: true });
740
- try {
741
- const now = Date.now();
742
- return store
743
- .listWorkflowTurnIntents({ targetSessionId: sessionId, unresolvedOnly: true, limit: 10 })
744
- .some((intent) => intent.eligibleAt !== null &&
745
- Date.parse(intent.eligibleAt) <= now &&
746
- (intent.deliveryClaimExpiresAt === null ||
747
- Date.parse(intent.deliveryClaimExpiresAt) <= now));
748
- }
749
- finally {
750
- store.close();
751
- }
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);
752
955
  }
753
956
  catch {
754
957
  return false;
755
958
  }
756
959
  }
757
- function terminalRunState(runId) {
960
+ async function hostDeliveryClaimIsLive(client, options) {
758
961
  try {
759
- const store = new WorkflowRunStore(workflowStatePath(), { readOnly: true });
760
- try {
761
- const loaded = store.readRun(runId);
762
- return loaded === null ? undefined : loaded.state;
763
- }
764
- finally {
765
- store.close();
766
- }
962
+ const validationId = randomUUID();
963
+ const response = await client.request({
964
+ operation: options.kind === "notification" ? "notification.claim" : "turn.claim",
965
+ requestId: `delivery-validate-${options.claimId}-${validationId}`,
966
+ idempotencyKey: validationId,
967
+ payload: { ...options, validateClaim: true },
968
+ });
969
+ const receipt = isRecord(response.receipt) ? response.receipt : undefined;
970
+ return ((response.outcome === "accepted" || response.outcome === "adopted") && receipt?.live === true);
767
971
  }
768
972
  catch {
769
- return undefined;
973
+ return false;
770
974
  }
771
975
  }
772
976
  function presentationMessage(turn, state) {
@@ -807,25 +1011,24 @@ function requireText(value, name) {
807
1011
  throw new Error(`${name} must be text`);
808
1012
  return value;
809
1013
  }
1014
+ function claimExpiry(value, name) {
1015
+ const expiry = Date.parse(requireText(value, `${name} expiry`));
1016
+ if (!Number.isFinite(expiry))
1017
+ throw new Error(`${name} expiry must be a timestamp`);
1018
+ return expiry;
1019
+ }
810
1020
  function activeSessionRun(ctx) {
811
1021
  return sessionRun(ctx);
812
1022
  }
813
1023
  function sessionRun(ctx, runId) {
814
- try {
815
- const store = new SqliteControllerStore(workflowStatePath(), { readOnly: true, global: true });
816
- try {
817
- const run = runId === undefined
818
- ? store.findSessionReservation(ctx.sessionManager.getSessionId())
819
- : store.getWorkflowRun(runId);
820
- return run?.originSessionId === ctx.sessionManager.getSessionId() ? run : undefined;
821
- }
822
- finally {
823
- store.close();
824
- }
825
- }
826
- catch {
1024
+ const session = sessionSnapshots.get(ctx.sessionManager.getSessionId());
1025
+ if (session?.run === null || session?.run === undefined || !isRecord(session.run.queue)) {
827
1026
  return undefined;
828
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;
829
1032
  }
830
1033
  async function listWorkflowMetadata(cwd) {
831
1034
  const files = await discoverWorkflows({ cwd });
@@ -866,8 +1069,10 @@ function toolInputToCommand(params) {
866
1069
  }
867
1070
  }
868
1071
  async function requestAccepted(client, options) {
869
- await client.ensureRunning();
870
- 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 });
871
1076
  if (response.outcome !== "accepted" && response.outcome !== "adopted") {
872
1077
  throw new Error(response.error ?? `Workflow host rejected ${options.operation}`);
873
1078
  }
@@ -891,7 +1096,9 @@ function agentContract(interaction) {
891
1096
  return value;
892
1097
  }
893
1098
  function interactionRequestId(value) {
894
- if (!isRecord(value) || value.type !== "custom_message" || !isRecord(value.details)) {
1099
+ if (!isRecord(value) ||
1100
+ (value.type !== "custom_message" && value.role !== "custom") ||
1101
+ !isRecord(value.details)) {
895
1102
  return undefined;
896
1103
  }
897
1104
  return typeof value.details.requestId === "string" ? value.details.requestId : undefined;
@@ -989,6 +1196,27 @@ function toolResult(message, details) {
989
1196
  function validRunId(value) {
990
1197
  return /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/u.test(value);
991
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
+ }
992
1220
  function isRecord(value) {
993
1221
  return typeof value === "object" && value !== null && !Array.isArray(value);
994
1222
  }