@osolmaz/pi-workflows 0.15.1 → 0.15.3

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.
@@ -10,6 +10,8 @@ import { errorMessage } from "../workflows/errors.js";
10
10
  import { discoverWorkflows } from "../workflows/loader.js";
11
11
  import { createRunId, WorkflowRunStore } from "../workflows/store.js";
12
12
  import { parseControllerArgs } from "./controller-command.js";
13
+ import { SessionDeliveryCoordinator } from "./session-delivery.js";
14
+ import { SessionWorkflowView } from "./session-view.js";
13
15
  import { recoverAssistantStep, registerWorkflowAgentStepMessageRenderer, WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA, WORKFLOW_AGENT_STEP_MESSAGE_TYPE, } from "./step-message.js";
14
16
  import { parseWorkflowToolInput, WorkflowToolParameters } from "./workflow-tool.js";
15
17
  export { parseControllerArgs } from "./controller-command.js";
@@ -93,6 +95,8 @@ export default function piWorkflows(pi) {
93
95
  let pollTimer = null;
94
96
  let presentationTail = Promise.resolve();
95
97
  let toolTail = Promise.resolve();
98
+ const sessionDelivery = new SessionDeliveryCoordinator();
99
+ const sessionView = new SessionWorkflowView();
96
100
  const presentInOrder = async (ctx) => {
97
101
  const prior = presentationTail;
98
102
  let release;
@@ -101,13 +105,14 @@ export default function piWorkflows(pi) {
101
105
  });
102
106
  await prior;
103
107
  try {
104
- await presentPendingInteraction(pi, client, ctx);
105
- await deliverPendingNotification(pi, client, ctx);
106
- if (pendingInteractionForSession(ctx.sessionManager.getSessionId()) === undefined) {
107
- await presentPendingTurn(pi, client, ctx);
108
- }
108
+ await sessionDelivery.synchronize(ctx, [
109
+ () => claimPendingInteractionDelivery(pi, client, ctx),
110
+ () => claimPendingNotificationDelivery(pi, client, ctx),
111
+ () => claimPendingTurnDelivery(pi, client, ctx),
112
+ ]);
109
113
  }
110
114
  finally {
115
+ sessionView.refresh(ctx);
111
116
  release?.();
112
117
  }
113
118
  };
@@ -222,6 +227,14 @@ export default function piWorkflows(pi) {
222
227
  });
223
228
  },
224
229
  });
230
+ pi.registerShortcut("shift+up", {
231
+ description: "Scroll the workflow widget up",
232
+ handler: (ctx) => sessionView.scrollUp(ctx),
233
+ });
234
+ pi.registerShortcut("shift+down", {
235
+ description: "Scroll the workflow widget down",
236
+ handler: (ctx) => sessionView.scrollDown(ctx),
237
+ });
225
238
  pi.on("session_start", async (_event, ctx) => {
226
239
  sessionContext = ctx;
227
240
  try {
@@ -237,6 +250,35 @@ export default function piWorkflows(pi) {
237
250
  }, INTERACTION_POLL_MS);
238
251
  pollTimer.unref?.();
239
252
  });
253
+ pi.on("agent_end", async (event, ctx) => {
254
+ const interrupted = ctx.signal?.aborted === true ||
255
+ event.messages.some((message) => isRecord(message) && "stopReason" in message && message.stopReason === "aborted");
256
+ if (!interrupted)
257
+ return;
258
+ const interaction = pendingInteractionForSession(ctx.sessionManager.getSessionId());
259
+ if (interaction === undefined ||
260
+ interaction.kind === "decision" ||
261
+ workflowRunPaused(interaction.runId)) {
262
+ return;
263
+ }
264
+ const turnHasPrompt = event.messages.some((message) => interactionRequestId(message) === interaction.requestId);
265
+ if (!turnHasPrompt)
266
+ return;
267
+ try {
268
+ const pauseId = `escape-pause-${interaction.runId}-${randomUUID()}`;
269
+ await requestAccepted(client, {
270
+ operation: "run.pause",
271
+ requestId: pauseId,
272
+ idempotencyKey: pauseId,
273
+ runId: interaction.runId,
274
+ });
275
+ sessionView.refresh(ctx);
276
+ ctx.ui.notify(`Workflow ${interaction.runId} paused because its model turn was interrupted. Use /workflow resume to continue.`, "info");
277
+ }
278
+ catch (error) {
279
+ ctx.ui.notify(`Could not pause interrupted workflow: ${errorMessage(error)}`, "warning");
280
+ }
281
+ });
240
282
  pi.on("agent_settled", async (_event, ctx) => {
241
283
  try {
242
284
  await submitVisibleAssistantResponse(client, ctx);
@@ -246,8 +288,10 @@ export default function piWorkflows(pi) {
246
288
  }
247
289
  await presentInOrder(ctx).catch(() => undefined);
248
290
  });
249
- pi.on("session_shutdown", async () => {
291
+ pi.on("session_shutdown", async (_event, ctx) => {
250
292
  sessionContext = null;
293
+ sessionDelivery.clear();
294
+ sessionView.clear(ctx);
251
295
  if (pollTimer !== null)
252
296
  clearInterval(pollTimer);
253
297
  pollTimer = null;
@@ -453,85 +497,103 @@ async function executeControllerCommand(client, ctx, command) {
453
497
  details: { action: command.kind, resource: response.receipt ?? null },
454
498
  };
455
499
  }
456
- async function presentPendingInteraction(pi, client, ctx) {
500
+ async function claimPendingInteractionDelivery(pi, client, ctx) {
457
501
  const interaction = pendingInteractionForSession(ctx.sessionManager.getSessionId());
458
- if (interaction === undefined)
459
- return;
460
- const entries = ctx.sessionManager.getBranch();
461
- const existing = entries.find((entry) => interactionRequestId(entry) === interaction.requestId);
462
- if (existing !== undefined) {
463
- const entryId = entryIdentifier(existing);
464
- if (entryId !== undefined && interaction.presentationSessionEntryId !== entryId) {
502
+ if (interaction === undefined || interaction.presentationSessionEntryId !== null)
503
+ return undefined;
504
+ const findSessionEntryId = (entries) => entryIdentifier(entries.find((entry) => interactionRequestId(entry) === interaction.requestId));
505
+ const existingEntryId = findSessionEntryId(ctx.sessionManager.getBranch());
506
+ let presentationRevision = interaction.revision;
507
+ let claimExpiresAt = Number.POSITIVE_INFINITY;
508
+ if (existingEntryId === undefined) {
509
+ if (workflowRunPaused(interaction.runId))
510
+ return undefined;
511
+ if (interaction.presentationClaimExpiresAt !== null &&
512
+ Date.parse(interaction.presentationClaimExpiresAt) > Date.now()) {
513
+ return undefined;
514
+ }
515
+ await client.ensureRunning();
516
+ const claim = await client.request({
517
+ operation: "interaction.update",
518
+ requestId: `claim-presentation-${interaction.requestId}-${interaction.revision}-${client.clientId}`,
519
+ idempotencyKey: `claim-presentation-${interaction.requestId}-${interaction.revision}-${client.clientId}`,
520
+ runId: interaction.runId,
521
+ expectedRevision: interaction.revision,
522
+ payload: { requestId: interaction.requestId, claimPresentation: true },
523
+ });
524
+ if (claim.outcome === "conflict")
525
+ return undefined;
526
+ if (claim.outcome !== "accepted" && claim.outcome !== "adopted") {
527
+ throw new Error(claim.error ?? "Workflow host rejected interaction.update");
528
+ }
529
+ if (claim.revision === undefined)
530
+ throw new Error("Presentation claim has no revision");
531
+ const receipt = isRecord(claim.receipt) ? claim.receipt : undefined;
532
+ presentationRevision = claim.revision;
533
+ claimExpiresAt = claimExpiry(receipt?.presentationClaimExpiresAt, "presentation claim");
534
+ }
535
+ const contract = interactionContract(interaction);
536
+ return {
537
+ deliveryId: `interaction:${interaction.requestId}`,
538
+ claimExpiresAt,
539
+ isStillDeliverable: () => existingEntryId === undefined &&
540
+ interactionPresentationClaimIsLive({
541
+ requestId: interaction.requestId,
542
+ runId: interaction.runId,
543
+ presenterId: client.clientId,
544
+ revision: presentationRevision,
545
+ claimExpiresAt,
546
+ }),
547
+ findSessionEntryId,
548
+ send: () => {
549
+ if (interaction.kind === "decision") {
550
+ pi.sendMessage({
551
+ customType: WORKFLOW_INTERACTION_MESSAGE_TYPE,
552
+ content: decisionPrompt(contract),
553
+ display: true,
554
+ details: {
555
+ requestId: interaction.requestId,
556
+ runId: interaction.runId,
557
+ kind: "decision",
558
+ },
559
+ }, { triggerTurn: false });
560
+ return;
561
+ }
562
+ const agent = agentContract(interaction);
563
+ if (agent === undefined)
564
+ throw new Error("Stored workflow agent contract is invalid");
565
+ const details = {
566
+ schema: WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA,
567
+ kind: "step",
568
+ contract: agent,
569
+ requestId: interaction.requestId,
570
+ ...(isRecord(contract.presentation)
571
+ ? { presentation: contract.presentation }
572
+ : {}),
573
+ };
574
+ pi.sendMessage({
575
+ customType: WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
576
+ content: typeof contract.prompt === "string" ? contract.prompt : "Continue the workflow step.",
577
+ display: true,
578
+ details,
579
+ }, { triggerTurn: true });
580
+ },
581
+ settle: async (sessionEntryId) => {
465
582
  await requestAccepted(client, {
466
583
  operation: "interaction.update",
467
- requestId: `present-${interaction.requestId}-${entryId}`,
468
- idempotencyKey: `present-${interaction.requestId}-${entryId}`,
584
+ requestId: `present-${interaction.requestId}-${sessionEntryId}`,
585
+ idempotencyKey: `present-${interaction.requestId}-${sessionEntryId}`,
469
586
  runId: interaction.runId,
470
- expectedRevision: interaction.revision,
471
- payload: { requestId: interaction.requestId, sessionEntryId: entryId },
587
+ expectedRevision: presentationRevision,
588
+ payload: { requestId: interaction.requestId, sessionEntryId },
472
589
  });
473
- }
474
- return;
475
- }
476
- if (interaction.presentationSessionEntryId !== null)
477
- return;
478
- const claim = await requestAccepted(client, {
479
- operation: "interaction.update",
480
- requestId: `claim-presentation-${interaction.requestId}-${interaction.revision}-${client.clientId}`,
481
- idempotencyKey: `claim-presentation-${interaction.requestId}-${interaction.revision}-${client.clientId}`,
482
- runId: interaction.runId,
483
- expectedRevision: interaction.revision,
484
- payload: { requestId: interaction.requestId, claimPresentation: true },
485
- });
486
- if (claim.revision === undefined)
487
- throw new Error("Presentation claim has no revision");
488
- const presentationRevision = claim.revision;
489
- const contract = interactionContract(interaction);
490
- if (interaction.kind === "decision") {
491
- pi.sendMessage({
492
- customType: WORKFLOW_INTERACTION_MESSAGE_TYPE,
493
- content: decisionPrompt(contract),
494
- display: true,
495
- details: { requestId: interaction.requestId, runId: interaction.runId, kind: "decision" },
496
- }, { triggerTurn: false });
497
- }
498
- else {
499
- const agent = agentContract(interaction);
500
- if (agent === undefined)
501
- throw new Error("Stored workflow agent contract is invalid");
502
- const details = {
503
- schema: WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA,
504
- kind: "step",
505
- contract: agent,
506
- requestId: interaction.requestId,
507
- ...(isRecord(contract.presentation) ? { presentation: contract.presentation } : {}),
508
- };
509
- pi.sendMessage({
510
- customType: WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
511
- content: typeof contract.prompt === "string" ? contract.prompt : "Continue the workflow step.",
512
- display: true,
513
- details,
514
- }, { triggerTurn: true, deliverAs: "followUp" });
515
- }
516
- const inserted = ctx.sessionManager
517
- .getBranch()
518
- .find((entry) => interactionRequestId(entry) === interaction.requestId);
519
- const entryId = entryIdentifier(inserted);
520
- if (entryId === undefined)
521
- return;
522
- await requestAccepted(client, {
523
- operation: "interaction.update",
524
- requestId: `present-${interaction.requestId}-${entryId}`,
525
- idempotencyKey: `present-${interaction.requestId}-${entryId}`,
526
- runId: interaction.runId,
527
- expectedRevision: presentationRevision,
528
- payload: { requestId: interaction.requestId, sessionEntryId: entryId },
529
- });
590
+ },
591
+ };
530
592
  }
531
- async function deliverPendingNotification(pi, client, ctx) {
593
+ async function claimPendingNotificationDelivery(pi, client, ctx) {
532
594
  const sessionId = ctx.sessionManager.getSessionId();
533
595
  if (!hasClaimableNotification(sessionId))
534
- return;
596
+ return undefined;
535
597
  const claimRequestId = randomUUID();
536
598
  const claimed = await requestAccepted(client, {
537
599
  operation: "notification.claim",
@@ -542,45 +604,52 @@ async function deliverPendingNotification(pi, client, ctx) {
542
604
  const receipt = isRecord(claimed.receipt) ? claimed.receipt : undefined;
543
605
  const notification = isRecord(receipt?.notification) ? receipt.notification : undefined;
544
606
  if (notification === undefined)
545
- return;
607
+ return undefined;
546
608
  const claimId = requireText(receipt?.claimId, "notification claimId");
609
+ const claimExpiresAt = claimExpiry(receipt?.claimExpiresAt, "notification claim");
547
610
  const notificationId = requireText(notification.notificationId, "notificationId");
548
- const branch = ctx.sessionManager.getBranch();
549
- let entry = branch.find((candidate) => customMessageDetail(candidate, WORKFLOW_NOTIFICATION_MESSAGE_TYPE, "notificationId") ===
550
- notificationId);
551
- if (entry === undefined) {
552
- pi.sendMessage({
553
- customType: WORKFLOW_NOTIFICATION_MESSAGE_TYPE,
554
- content: requireText(notification.content, "notification content"),
555
- display: true,
556
- details: {
557
- notificationId,
558
- runId: requireText(notification.runId, "notification runId"),
559
- kind: notification.kind,
560
- },
561
- }, { triggerTurn: false });
562
- entry = ctx.sessionManager
563
- .getBranch()
564
- .find((candidate) => customMessageDetail(candidate, WORKFLOW_NOTIFICATION_MESSAGE_TYPE, "notificationId") ===
565
- notificationId);
566
- }
567
- if (entry === undefined)
568
- return;
569
- await requestAccepted(client, {
570
- operation: "notification.deliver",
571
- requestId: `notification-deliver-${notificationId}-${claimId}`,
572
- idempotencyKey: `notification-deliver-${notificationId}-${claimId}`,
573
- payload: {
574
- notificationId,
611
+ return {
612
+ deliveryId: `notification:${notificationId}`,
613
+ claimExpiresAt,
614
+ isStillDeliverable: () => hostDeliveryClaimIsLive(client, {
615
+ kind: "notification",
616
+ resourceId: notificationId,
575
617
  targetSessionId: sessionId,
576
618
  claimId,
619
+ }),
620
+ findSessionEntryId: (entries) => entryIdentifier(entries.find((entry) => customMessageDetail(entry, WORKFLOW_NOTIFICATION_MESSAGE_TYPE, "notificationId") ===
621
+ notificationId)),
622
+ send: () => {
623
+ pi.sendMessage({
624
+ customType: WORKFLOW_NOTIFICATION_MESSAGE_TYPE,
625
+ content: requireText(notification.content, "notification content"),
626
+ display: true,
627
+ details: {
628
+ notificationId,
629
+ runId: requireText(notification.runId, "notification runId"),
630
+ kind: notification.kind,
631
+ },
632
+ }, { triggerTurn: false });
577
633
  },
578
- });
634
+ settle: async () => {
635
+ await requestAccepted(client, {
636
+ operation: "notification.deliver",
637
+ requestId: `notification-deliver-${notificationId}-${claimId}`,
638
+ idempotencyKey: `notification-deliver-${notificationId}-${claimId}`,
639
+ payload: {
640
+ notificationId,
641
+ targetSessionId: sessionId,
642
+ claimId,
643
+ },
644
+ });
645
+ },
646
+ };
579
647
  }
580
- async function presentPendingTurn(pi, client, ctx) {
648
+ async function claimPendingTurnDelivery(pi, client, ctx) {
581
649
  const sessionId = ctx.sessionManager.getSessionId();
582
- if (!hasClaimableTurn(sessionId))
583
- return;
650
+ if (pendingInteractionForSession(sessionId) !== undefined || !hasClaimableTurn(sessionId)) {
651
+ return undefined;
652
+ }
584
653
  const claimRequestId = randomUUID();
585
654
  const claimed = await requestAccepted(client, {
586
655
  operation: "turn.claim",
@@ -591,47 +660,54 @@ async function presentPendingTurn(pi, client, ctx) {
591
660
  const receipt = isRecord(claimed.receipt) ? claimed.receipt : undefined;
592
661
  const turn = isRecord(receipt?.turn) ? receipt.turn : undefined;
593
662
  if (turn === undefined)
594
- return;
663
+ return undefined;
595
664
  const claimId = requireText(receipt?.claimId, "turn claimId");
665
+ const claimExpiresAt = claimExpiry(receipt?.claimExpiresAt, "turn claim");
596
666
  const intentId = requireText(turn.intentId, "turn intentId");
597
667
  const runId = requireText(turn.runId, "turn runId");
598
668
  const state = terminalRunState(runId);
599
669
  if (state === undefined)
600
- return;
601
- let entry = ctx.sessionManager
602
- .getBranch()
603
- .find((candidate) => customMessageDetail(candidate, WORKFLOW_PRESENTATION_MESSAGE_TYPE, "intentId") === intentId);
604
- if (entry === undefined) {
605
- pi.sendMessage({
606
- customType: WORKFLOW_PRESENTATION_MESSAGE_TYPE,
607
- content: presentationMessage(turn, state),
608
- display: false,
609
- details: { intentId, runId },
610
- }, { triggerTurn: true, deliverAs: "followUp" });
611
- entry = ctx.sessionManager
612
- .getBranch()
613
- .find((candidate) => customMessageDetail(candidate, WORKFLOW_PRESENTATION_MESSAGE_TYPE, "intentId") ===
614
- intentId);
615
- }
616
- const messageId = entryIdentifier(entry);
617
- if (messageId === undefined)
618
- return;
619
- await requestAccepted(client, {
620
- operation: "turn.resolve",
621
- requestId: `turn-resolve-${intentId}-${messageId}`,
622
- idempotencyKey: `turn-resolve-${intentId}-${messageId}`,
623
- payload: {
624
- intentId,
670
+ return undefined;
671
+ return {
672
+ deliveryId: `turn:${intentId}`,
673
+ claimExpiresAt,
674
+ isStillDeliverable: () => hostDeliveryClaimIsLive(client, {
675
+ kind: "turn",
676
+ resourceId: intentId,
625
677
  targetSessionId: sessionId,
626
678
  claimId,
627
- messageId,
679
+ }),
680
+ findSessionEntryId: (entries) => entryIdentifier(entries.find((entry) => customMessageDetail(entry, WORKFLOW_PRESENTATION_MESSAGE_TYPE, "intentId") === intentId)),
681
+ send: () => {
682
+ pi.sendMessage({
683
+ customType: WORKFLOW_PRESENTATION_MESSAGE_TYPE,
684
+ content: presentationMessage(turn, state),
685
+ display: false,
686
+ details: { intentId, runId },
687
+ }, { triggerTurn: true });
628
688
  },
629
- });
689
+ settle: async (sessionEntryId) => {
690
+ await requestAccepted(client, {
691
+ operation: "turn.resolve",
692
+ requestId: `turn-resolve-${intentId}-${sessionEntryId}`,
693
+ idempotencyKey: `turn-resolve-${intentId}-${sessionEntryId}`,
694
+ payload: {
695
+ intentId,
696
+ targetSessionId: sessionId,
697
+ claimId,
698
+ messageId: sessionEntryId,
699
+ },
700
+ });
701
+ },
702
+ };
630
703
  }
631
704
  async function submitVisibleAssistantResponse(client, ctx) {
632
705
  const interaction = pendingInteractionForSession(ctx.sessionManager.getSessionId());
633
- if (interaction === undefined || interaction.kind !== "assistant")
706
+ if (interaction === undefined ||
707
+ interaction.kind !== "assistant" ||
708
+ workflowRunPaused(interaction.runId)) {
634
709
  return;
710
+ }
635
711
  const contract = agentContract(interaction);
636
712
  if (contract === undefined)
637
713
  return;
@@ -777,6 +853,47 @@ function terminalRunState(runId) {
777
853
  return undefined;
778
854
  }
779
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
+ }
876
+ }
877
+ catch {
878
+ return false;
879
+ }
880
+ }
881
+ async function hostDeliveryClaimIsLive(client, options) {
882
+ try {
883
+ const validationId = randomUUID();
884
+ const response = await client.request({
885
+ operation: options.kind === "notification" ? "notification.claim" : "turn.claim",
886
+ requestId: `delivery-validate-${options.claimId}-${validationId}`,
887
+ idempotencyKey: validationId,
888
+ payload: { ...options, validateClaim: true },
889
+ });
890
+ const receipt = isRecord(response.receipt) ? response.receipt : undefined;
891
+ return ((response.outcome === "accepted" || response.outcome === "adopted") && receipt?.live === true);
892
+ }
893
+ catch {
894
+ return false;
895
+ }
896
+ }
780
897
  function presentationMessage(turn, state) {
781
898
  const facts = isRecord(turn.fallbackFacts) ? turn.fallbackFacts : {};
782
899
  const instructions = typeof facts.presentationPrompt === "string"
@@ -815,6 +932,12 @@ function requireText(value, name) {
815
932
  throw new Error(`${name} must be text`);
816
933
  return value;
817
934
  }
935
+ function claimExpiry(value, name) {
936
+ const expiry = Date.parse(requireText(value, `${name} expiry`));
937
+ if (!Number.isFinite(expiry))
938
+ throw new Error(`${name} expiry must be a timestamp`);
939
+ return expiry;
940
+ }
818
941
  function activeSessionRun(ctx) {
819
942
  return sessionRun(ctx);
820
943
  }
@@ -899,7 +1022,9 @@ function agentContract(interaction) {
899
1022
  return value;
900
1023
  }
901
1024
  function interactionRequestId(value) {
902
- if (!isRecord(value) || value.type !== "custom_message" || !isRecord(value.details)) {
1025
+ if (!isRecord(value) ||
1026
+ (value.type !== "custom_message" && value.role !== "custom") ||
1027
+ !isRecord(value.details)) {
903
1028
  return undefined;
904
1029
  }
905
1030
  return typeof value.details.requestId === "string" ? value.details.requestId : undefined;