@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.
@@ -0,0 +1,174 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type ClaimedSessionDelivery = {
4
+ deliveryId: string;
5
+ claimExpiresAt: number;
6
+ isStillDeliverable: () => boolean | Promise<boolean>;
7
+ findSessionEntryId: (entries: readonly unknown[]) => string | undefined;
8
+ send: () => void;
9
+ settle: (sessionEntryId: string) => Promise<void>;
10
+ };
11
+
12
+ export type ClaimSessionDelivery = () => Promise<ClaimedSessionDelivery | undefined>;
13
+
14
+ type QueuedSessionDelivery = {
15
+ delivery: ClaimedSessionDelivery;
16
+ queuedAt: number;
17
+ ambiguityReported: boolean;
18
+ };
19
+
20
+ const SESSION_ENTRY_CONFIRMATION_MS = 10_000;
21
+
22
+ /**
23
+ * Delivers at most one host-owned message into a Pi session at a time.
24
+ *
25
+ * Pi's public sendMessage API does not return the saved session entry ID. Keep
26
+ * the claimed delivery in memory until that entry becomes observable. Polling
27
+ * may reconcile the delivery, but it must never send the same claim again.
28
+ */
29
+ export class SessionDeliveryCoordinator {
30
+ private readonly queued = new Map<string, QueuedSessionDelivery>();
31
+ private claimed: ClaimedSessionDelivery | undefined;
32
+ private synchronizing = false;
33
+
34
+ async synchronize(
35
+ ctx: Pick<ExtensionContext, "hasPendingMessages" | "isIdle" | "sessionManager" | "ui">,
36
+ claimers: readonly ClaimSessionDelivery[],
37
+ ): Promise<void> {
38
+ if (this.synchronizing) return;
39
+ this.synchronizing = true;
40
+ try {
41
+ if (await this.settleQueued(ctx)) return;
42
+ if (await this.sendClaimed(ctx)) return;
43
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
44
+
45
+ for (const claim of claimers) {
46
+ const delivery = await claim();
47
+ if (delivery === undefined) continue;
48
+
49
+ const existingEntryId = delivery.findSessionEntryId(ctx.sessionManager.getBranch());
50
+ if (existingEntryId !== undefined) {
51
+ if (delivery.claimExpiresAt <= Date.now()) return;
52
+ this.rememberQueued(delivery);
53
+ await this.settleDelivery(ctx, delivery.deliveryId, existingEntryId);
54
+ return;
55
+ }
56
+
57
+ // Remember the host claim before the final idle check. If Pi starts a
58
+ // turn while the claim request is in flight, a later poll can use this
59
+ // exact still-live claim instead of making a conflicting second claim.
60
+ this.claimed = delivery;
61
+ await this.sendClaimed(ctx);
62
+ return;
63
+ }
64
+ } finally {
65
+ this.synchronizing = false;
66
+ }
67
+ }
68
+
69
+ clear(): void {
70
+ this.claimed = undefined;
71
+ this.queued.clear();
72
+ }
73
+
74
+ private async sendClaimed(
75
+ ctx: Pick<ExtensionContext, "hasPendingMessages" | "isIdle" | "sessionManager" | "ui">,
76
+ ): Promise<boolean> {
77
+ const delivery = this.claimed;
78
+ if (delivery === undefined) return false;
79
+ if (delivery.claimExpiresAt <= Date.now()) {
80
+ this.claimed = undefined;
81
+ return false;
82
+ }
83
+
84
+ const existingEntryId = delivery.findSessionEntryId(ctx.sessionManager.getBranch());
85
+ if (existingEntryId !== undefined) {
86
+ this.rememberQueued(delivery);
87
+ this.claimed = undefined;
88
+ await this.settleDelivery(ctx, delivery.deliveryId, existingEntryId);
89
+ return true;
90
+ }
91
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return true;
92
+ if (!(await delivery.isStillDeliverable())) {
93
+ this.claimed = undefined;
94
+ return false;
95
+ }
96
+ if (delivery.claimExpiresAt <= Date.now()) {
97
+ this.claimed = undefined;
98
+ return false;
99
+ }
100
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return true;
101
+
102
+ this.rememberQueued(delivery);
103
+ this.claimed = undefined;
104
+ try {
105
+ delivery.send();
106
+ } catch (error) {
107
+ this.queued.delete(delivery.deliveryId);
108
+ throw error;
109
+ }
110
+
111
+ const insertedEntryId = delivery.findSessionEntryId(ctx.sessionManager.getBranch());
112
+ if (insertedEntryId !== undefined) {
113
+ await this.settleDelivery(ctx, delivery.deliveryId, insertedEntryId);
114
+ }
115
+ return true;
116
+ }
117
+
118
+ private rememberQueued(delivery: ClaimedSessionDelivery): void {
119
+ if (this.queued.has(delivery.deliveryId)) return;
120
+ this.queued.set(delivery.deliveryId, {
121
+ delivery,
122
+ queuedAt: Date.now(),
123
+ ambiguityReported: false,
124
+ });
125
+ }
126
+
127
+ private async settleQueued(
128
+ ctx: Pick<ExtensionContext, "hasPendingMessages" | "isIdle" | "sessionManager" | "ui">,
129
+ ): Promise<boolean> {
130
+ let hadQueuedDelivery = false;
131
+ for (const [deliveryId, queued] of this.queued) {
132
+ hadQueuedDelivery = true;
133
+ const sessionEntryId = queued.delivery.findSessionEntryId(ctx.sessionManager.getBranch());
134
+ if (sessionEntryId === undefined) {
135
+ if (
136
+ !queued.ambiguityReported &&
137
+ ctx.isIdle() &&
138
+ !ctx.hasPendingMessages() &&
139
+ Date.now() - queued.queuedAt >= SESSION_ENTRY_CONFIRMATION_MS
140
+ ) {
141
+ queued.ambiguityReported = true;
142
+ ctx.ui.notify(
143
+ `Workflow session delivery ${deliveryId} is ambiguous: Pi did not expose a matching session entry. Do not retry it until you check the session history.`,
144
+ "warning",
145
+ );
146
+ }
147
+ return true;
148
+ }
149
+ await this.settleDelivery(ctx, deliveryId, sessionEntryId);
150
+ }
151
+ return hadQueuedDelivery;
152
+ }
153
+
154
+ private async settleDelivery(
155
+ ctx: Pick<ExtensionContext, "ui">,
156
+ deliveryId: string,
157
+ sessionEntryId: string,
158
+ ): Promise<void> {
159
+ const queued = this.queued.get(deliveryId);
160
+ if (queued === undefined) return;
161
+ try {
162
+ await queued.delivery.settle(sessionEntryId);
163
+ this.queued.delete(deliveryId);
164
+ } catch (error) {
165
+ if (!queued.ambiguityReported) {
166
+ queued.ambiguityReported = true;
167
+ ctx.ui.notify(
168
+ `Workflow session delivery ${deliveryId} is visible but its durable receipt is ambiguous: ${String(error)}. Do not retry it until recovery checks the session history.`,
169
+ "warning",
170
+ );
171
+ }
172
+ }
173
+ }
174
+ }
@@ -0,0 +1,131 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { SqliteControllerStore } from "../controllers/sqlite.js";
3
+ import { workflowStatePath } from "../state/database.js";
4
+ import { WorkflowRunStore, type LoadedWorkflowRun } from "../workflows/store.js";
5
+ import { buildWidgetView } from "./widget.js";
6
+
7
+ const WIDGET_KEY = "pi-workflows";
8
+ const WIDGET_SCROLL_STEP = 3;
9
+
10
+ /** Read-only projection of the host-owned run into the origin Pi session. */
11
+ export class SessionWorkflowView {
12
+ private loaded: LoadedWorkflowRun | undefined;
13
+ private scroll: number | null = null;
14
+ private shownScroll = 0;
15
+ private maxScroll = 0;
16
+ private stepCount = 0;
17
+ private visible = false;
18
+
19
+ refresh(ctx: ExtensionContext): void {
20
+ const loaded = loadSessionRun(ctx.sessionManager.getSessionId());
21
+ if (loaded === undefined) {
22
+ this.clear(ctx);
23
+ return;
24
+ }
25
+ if (this.loaded?.runId !== loaded.runId || this.stepCount !== loaded.state.steps.length) {
26
+ this.scroll = null;
27
+ this.stepCount = loaded.state.steps.length;
28
+ }
29
+ this.loaded = loaded;
30
+ this.render(ctx);
31
+ }
32
+
33
+ scrollUp(ctx: ExtensionContext): void {
34
+ this.scrollBy(ctx, -WIDGET_SCROLL_STEP);
35
+ }
36
+
37
+ scrollDown(ctx: ExtensionContext): void {
38
+ this.scrollBy(ctx, WIDGET_SCROLL_STEP);
39
+ }
40
+
41
+ clear(ctx: ExtensionContext): void {
42
+ this.loaded = undefined;
43
+ this.scroll = null;
44
+ this.shownScroll = 0;
45
+ this.maxScroll = 0;
46
+ this.stepCount = 0;
47
+ if (!this.visible) return;
48
+ this.visible = false;
49
+ safelyUpdateUi(ctx, () => {
50
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
51
+ ctx.ui.setStatus(WIDGET_KEY, undefined);
52
+ });
53
+ }
54
+
55
+ private scrollBy(ctx: ExtensionContext, delta: number): void {
56
+ if (this.loaded === undefined) return;
57
+ const current = this.scroll ?? this.shownScroll;
58
+ this.scroll = Math.max(0, Math.min(this.maxScroll, current + delta));
59
+ this.render(ctx);
60
+ }
61
+
62
+ private render(ctx: ExtensionContext): void {
63
+ const loaded = this.loaded;
64
+ if (loaded === undefined) return;
65
+ const render = (
66
+ width = Number.POSITIVE_INFINITY,
67
+ theme?: Parameters<typeof buildWidgetView>[6],
68
+ ) => {
69
+ const view = buildWidgetView(
70
+ loaded.state,
71
+ loaded.snapshot,
72
+ new Date(),
73
+ this.scroll,
74
+ loaded.state.paused === true,
75
+ width,
76
+ theme,
77
+ undefined,
78
+ );
79
+ this.shownScroll = view.scroll;
80
+ this.maxScroll = view.maxScroll;
81
+ if (this.scroll !== null) this.scroll = view.scroll;
82
+ return view.lines;
83
+ };
84
+ safelyUpdateUi(ctx, () => {
85
+ if (ctx.mode === "tui") {
86
+ ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) => ({
87
+ render: (width) => render(width, theme),
88
+ invalidate() {},
89
+ }));
90
+ } else {
91
+ ctx.ui.setWidget(WIDGET_KEY, render());
92
+ }
93
+ const label = loaded.state.paused === true ? "paused" : loaded.state.status;
94
+ ctx.ui.setStatus(WIDGET_KEY, `${loaded.state.workflowName} [${label}]`);
95
+ this.visible = true;
96
+ });
97
+ }
98
+ }
99
+
100
+ function loadSessionRun(sessionId: string): LoadedWorkflowRun | undefined {
101
+ try {
102
+ const queue = new SqliteControllerStore(workflowStatePath(), {
103
+ readOnly: true,
104
+ global: true,
105
+ });
106
+ let runId: string | undefined;
107
+ try {
108
+ runId = queue.findSessionReservation(sessionId)?.runId;
109
+ } finally {
110
+ queue.close();
111
+ }
112
+ if (runId === undefined) return undefined;
113
+
114
+ const runs = new WorkflowRunStore(workflowStatePath(), { readOnly: true });
115
+ try {
116
+ return runs.readRun(runId) ?? undefined;
117
+ } finally {
118
+ runs.close();
119
+ }
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ function safelyUpdateUi(ctx: ExtensionContext, update: () => void): void {
126
+ try {
127
+ if (ctx.hasUI) update();
128
+ } catch {
129
+ // A session replacement can make a captured context stale between polls.
130
+ }
131
+ }
@@ -86,7 +86,7 @@ export function buildWidgetView(
86
86
  // `held` covers pauses the state cannot see yet: an escape-interrupted
87
87
  // step or a pause requested while the current node is still finishing.
88
88
  const paused = held || state.paused === true;
89
- const glyph = paused ? "⏸" : STATUS_GLYPHS[state.status];
89
+ const glyph = paused ? "⏸" : (STATUS_GLYPHS[state.status] ?? "·");
90
90
  const statusText = paused ? "paused" : state.status;
91
91
  // Titles, status details, and errors can carry model- or shell-controlled
92
92
  // text; never let escape sequences or newlines reach the terminal.
@@ -398,7 +398,7 @@ function elapsedSince(startedAt: string | undefined, now: Date): string | null {
398
398
  return formatDuration(Math.max(0, now.getTime() - started));
399
399
  }
400
400
 
401
- function statusTone(status: WorkflowRunStatus): ThemeColor {
401
+ function statusTone(status: string): ThemeColor {
402
402
  switch (status) {
403
403
  case "completed":
404
404
  return "success";
@@ -409,6 +409,7 @@ function statusTone(status: WorkflowRunStatus): ThemeColor {
409
409
  case "waiting":
410
410
  return "warning";
411
411
  case "running":
412
+ default:
412
413
  return "accent";
413
414
  }
414
415
  }
@@ -526,14 +526,37 @@ export class WorkflowHost {
526
526
  case "run.pause": {
527
527
  const runId = requireRunId(request);
528
528
  const active = this.activeRuns.get(runId);
529
- if (active === undefined) return { outcome: "rejected", error: "Run is not active" };
530
- this.commitActivePause(active);
531
- active.control = "pause";
532
- afterCommit.push(() => void active.supervisor.stop("cancelled"));
533
- return { outcome: "accepted", receipt: { runId, status: "parked", paused: true } };
529
+ if (active !== undefined) {
530
+ if (active.control === "pause") {
531
+ return { outcome: "adopted", receipt: { runId, status: "parked", paused: true } };
532
+ }
533
+ if (active.control === "handoff") {
534
+ const paused = this.queue.pauseParkedWorkflowRun({ runId });
535
+ return paused
536
+ ? { outcome: "accepted", receipt: { runId, status: "parked", paused: true } }
537
+ : { outcome: "rejected", error: "Run handoff is not pausable" };
538
+ }
539
+ if (active.control !== undefined) {
540
+ return { outcome: "rejected", error: `Run is already handling ${active.control}` };
541
+ }
542
+ this.commitActivePause(active);
543
+ active.control = "pause";
544
+ afterCommit.push(() => void active.supervisor.stop("cancelled"));
545
+ return { outcome: "accepted", receipt: { runId, status: "parked", paused: true } };
546
+ }
547
+ const paused = this.queue.pauseParkedWorkflowRun({ runId });
548
+ return paused
549
+ ? { outcome: "accepted", receipt: { runId, status: "parked", paused: true } }
550
+ : { outcome: "rejected", error: "Run is not pausable" };
534
551
  }
535
552
  case "run.resume": {
536
553
  const runId = requireRunId(request);
554
+ if (this.queue.resumePausedInteraction({ runId })) {
555
+ return {
556
+ outcome: "accepted",
557
+ receipt: { runId, status: "parked", paused: false, waitingForInteraction: true },
558
+ };
559
+ }
537
560
  if (this.activeRuns.has(runId) || this.pendingStarts.has(runId)) {
538
561
  return { outcome: "adopted", receipt: { runId, active: true } };
539
562
  }
@@ -570,20 +593,27 @@ export class WorkflowHost {
570
593
  case "interaction.update": {
571
594
  const payload = requireRecord(request.payload, "interaction update payload");
572
595
  if (payload.claimPresentation === true) {
573
- const interaction = this.hostState.claimInteractionPresentation({
574
- requestId: requireString(payload.requestId, "requestId"),
575
- expectedRevision: requireNonNegativeInteger(
576
- request.expectedRevision,
577
- "expectedRevision",
578
- ),
579
- presenterId: request.clientId,
580
- leaseMs: PRESENTATION_CLAIM_LEASE_MS,
581
- });
582
- return {
583
- outcome: "accepted",
584
- revision: interaction.revision,
585
- receipt: interaction as unknown as JsonValue,
586
- };
596
+ try {
597
+ const interaction = this.hostState.claimInteractionPresentation({
598
+ requestId: requireString(payload.requestId, "requestId"),
599
+ expectedRevision: requireNonNegativeInteger(
600
+ request.expectedRevision,
601
+ "expectedRevision",
602
+ ),
603
+ presenterId: request.clientId,
604
+ leaseMs: PRESENTATION_CLAIM_LEASE_MS,
605
+ });
606
+ return {
607
+ outcome: "accepted",
608
+ revision: interaction.revision,
609
+ receipt: interaction as unknown as JsonValue,
610
+ };
611
+ } catch (error) {
612
+ if (errorMessage(error) === "Interactive request presentation claim conflict") {
613
+ return { outcome: "conflict", error: errorMessage(error) };
614
+ }
615
+ throw error;
616
+ }
587
617
  }
588
618
  if (typeof payload.sessionEntryId === "string") {
589
619
  const interaction = this.hostState.markInteractionPresented({
@@ -722,17 +752,18 @@ export class WorkflowHost {
722
752
  };
723
753
  }
724
754
 
725
- private rememberDeliveryClaim(options: Omit<DeliveryClaim, "expiresAt">): string {
755
+ private rememberDeliveryClaim(options: Omit<DeliveryClaim, "expiresAt">): {
756
+ claimId: string;
757
+ claimExpiresAt: string;
758
+ } {
726
759
  const now = Date.now();
727
760
  for (const [claimId, claim] of this.deliveryClaims) {
728
761
  if (claim.expiresAt <= now) this.deliveryClaims.delete(claimId);
729
762
  }
730
763
  const claimId = randomUUID();
731
- this.deliveryClaims.set(claimId, {
732
- ...options,
733
- expiresAt: now + DELIVERY_CLAIM_LEASE_MS,
734
- });
735
- return claimId;
764
+ const expiresAt = now + DELIVERY_CLAIM_LEASE_MS;
765
+ this.deliveryClaims.set(claimId, { ...options, expiresAt });
766
+ return { claimId, claimExpiresAt: new Date(expiresAt).toISOString() };
736
767
  }
737
768
 
738
769
  private deliveryClaim(
@@ -757,8 +788,39 @@ export class WorkflowHost {
757
788
  return claim;
758
789
  }
759
790
 
791
+ private validateDelivery(
792
+ command: HostRequest,
793
+ payload: Record<string, unknown>,
794
+ kind: DeliveryClaim["kind"],
795
+ ): Omit<HostResponse, "schema" | "requestId"> {
796
+ const resourceId = requireString(payload.resourceId, "resourceId");
797
+ const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
798
+ const claimId = requireString(payload.claimId, "claimId");
799
+ const claim = this.deliveryClaim(claimId, command.clientId, kind, resourceId, targetSessionId);
800
+ if (claim === undefined) {
801
+ return { outcome: "accepted", receipt: { claimId, live: false } };
802
+ }
803
+ const live =
804
+ kind === "notification"
805
+ ? this.queue.isWorkflowNotificationClaimLive({
806
+ notificationId: resourceId,
807
+ targetSessionId,
808
+ claimToken: claim.token,
809
+ })
810
+ : this.queue.isWorkflowTurnIntentClaimLive({
811
+ intentId: resourceId,
812
+ targetSessionId,
813
+ claimToken: claim.token,
814
+ });
815
+ if (!live) this.deliveryClaims.delete(claimId);
816
+ return { outcome: "accepted", receipt: { claimId, live } };
817
+ }
818
+
760
819
  private claimNotification(command: HostRequest): Omit<HostResponse, "schema" | "requestId"> {
761
820
  const payload = requireRecord(command.payload, "notification claim payload");
821
+ if (payload.validateClaim === true) {
822
+ return this.validateDelivery(command, payload, "notification");
823
+ }
762
824
  const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
763
825
  const token = randomUUID();
764
826
  const notification = this.queue.claimPendingWorkflowNotifications({
@@ -770,7 +832,7 @@ export class WorkflowHost {
770
832
  if (notification === undefined) {
771
833
  return { outcome: "accepted", receipt: { notification: null } };
772
834
  }
773
- const claimId = this.rememberDeliveryClaim({
835
+ const claim = this.rememberDeliveryClaim({
774
836
  clientId: command.clientId,
775
837
  token,
776
838
  targetSessionId,
@@ -779,7 +841,7 @@ export class WorkflowHost {
779
841
  });
780
842
  return {
781
843
  outcome: "accepted",
782
- receipt: { claimId, notification } as unknown as JsonValue,
844
+ receipt: { ...claim, notification } as unknown as JsonValue,
783
845
  };
784
846
  }
785
847
 
@@ -811,6 +873,9 @@ export class WorkflowHost {
811
873
 
812
874
  private claimTurn(command: HostRequest): Omit<HostResponse, "schema" | "requestId"> {
813
875
  const payload = requireRecord(command.payload, "turn claim payload");
876
+ if (payload.validateClaim === true) {
877
+ return this.validateDelivery(command, payload, "turn");
878
+ }
814
879
  const targetSessionId = requireString(payload.targetSessionId, "targetSessionId");
815
880
  const token = randomUUID();
816
881
  const intent = this.queue.claimEligibleWorkflowTurnIntents({
@@ -822,7 +887,7 @@ export class WorkflowHost {
822
887
  if (intent === undefined) {
823
888
  return { outcome: "accepted", receipt: { turn: null } };
824
889
  }
825
- const claimId = this.rememberDeliveryClaim({
890
+ const claim = this.rememberDeliveryClaim({
826
891
  clientId: command.clientId,
827
892
  token,
828
893
  targetSessionId,
@@ -831,7 +896,7 @@ export class WorkflowHost {
831
896
  });
832
897
  return {
833
898
  outcome: "accepted",
834
- receipt: { claimId, turn: intent } as unknown as JsonValue,
899
+ receipt: { ...claim, turn: intent } as unknown as JsonValue,
835
900
  };
836
901
  }
837
902
 
@@ -913,6 +978,9 @@ export class WorkflowHost {
913
978
  if (interaction === undefined || interaction.kind !== "decision") {
914
979
  return { outcome: "notFound", error: `Decision request not found: ${requestId}` };
915
980
  }
981
+ if (this.queue.isWorkflowRunPaused(interaction.runId)) {
982
+ return { outcome: "conflict", error: "Workflow run is paused" };
983
+ }
916
984
  const request = interaction.contract as unknown as HumanDecisionRequest;
917
985
  const response = payload.response as HumanDecisionResponse;
918
986
  const accepted = this.decisions.acceptSync(request, {
@@ -1085,6 +1153,9 @@ export class WorkflowHost {
1085
1153
  if (interaction === undefined || interaction.runId !== runId) {
1086
1154
  return { outcome: "notFound", error: `Interactive request not found: ${requestId}` };
1087
1155
  }
1156
+ if (this.queue.isWorkflowRunPaused(runId)) {
1157
+ return { outcome: "conflict", error: "Workflow run is paused" };
1158
+ }
1088
1159
  const attemptId = requireString(payload.attempt, "attempt");
1089
1160
  const nodeId = requireString(payload.step, "step");
1090
1161
  const storedContract = requireRecord(interaction.contract, "interactive contract");
@@ -1148,6 +1219,9 @@ export class WorkflowHost {
1148
1219
  if (current === undefined || current.runId !== requireRunId(request)) {
1149
1220
  return { outcome: "notFound", error: `Interactive request not found: ${requestId}` };
1150
1221
  }
1222
+ if (this.queue.isWorkflowRunPaused(current.runId)) {
1223
+ return { outcome: "conflict", error: "Workflow run is paused" };
1224
+ }
1151
1225
  const attemptId = requireString(payload.attempt, "attempt");
1152
1226
  const nodeId = requireString(payload.step, "step");
1153
1227
  const storedContract = requireRecord(current.contract, "interactive contract");
package/src/host/state.ts CHANGED
@@ -65,6 +65,8 @@ export type InteractiveRequestRecord = {
65
65
  contract: JsonValue;
66
66
  revision: number;
67
67
  status: "pending" | "presenting" | "settled" | "cancelled";
68
+ presenterId: string | null;
69
+ presentationClaimExpiresAt: string | null;
68
70
  presentationSessionEntryId: string | null;
69
71
  acceptedSubmissionId: string | null;
70
72
  createdAt: string;
@@ -676,7 +678,7 @@ export class HostStateStore {
676
678
  WHERE request_id = ? AND revision = ? AND presentation_session_entry_id IS NULL
677
679
  AND (
678
680
  status = 'pending'
679
- OR (status = 'presenting' AND (presenter_id = ? OR presentation_claim_expires_at <= ?))
681
+ OR (status = 'presenting' AND presentation_claim_expires_at <= ?)
680
682
  )`,
681
683
  )
682
684
  .run(
@@ -685,7 +687,6 @@ export class HostStateStore {
685
687
  now,
686
688
  options.requestId,
687
689
  options.expectedRevision,
688
- options.presenterId,
689
690
  now,
690
691
  );
691
692
  if (changed.changes !== 1) throw new Error("Interactive request presentation claim conflict");
@@ -887,7 +888,9 @@ export class HostStateStore {
887
888
  .prepare(
888
889
  `SELECT request_id AS requestId, run_id AS runId, attempt_id AS attemptId,
889
890
  target_session_id AS targetSessionId, kind, contract_hash AS contractHash,
890
- revision, status, presentation_session_entry_id AS presentationSessionEntryId,
891
+ revision, status, presenter_id AS presenterId,
892
+ presentation_claim_expires_at AS presentationClaimExpiresAt,
893
+ presentation_session_entry_id AS presentationSessionEntryId,
891
894
  accepted_submission_id AS acceptedSubmissionId, created_at AS createdAt,
892
895
  updated_at AS updatedAt, settled_at AS settledAt, consumed_at AS consumedAt
893
896
  FROM interactive_requests WHERE request_id = ?`,
@@ -903,6 +906,8 @@ export class HostStateStore {
903
906
  contract: this.state.readJson(row.contractHash),
904
907
  revision: row.revision,
905
908
  status: row.status,
909
+ presenterId: row.presenterId,
910
+ presentationClaimExpiresAt: iso(row.presentationClaimExpiresAt),
906
911
  presentationSessionEntryId: row.presentationSessionEntryId,
907
912
  acceptedSubmissionId: row.acceptedSubmissionId,
908
913
  createdAt: new Date(row.createdAt).toISOString(),
@@ -998,6 +1003,8 @@ type InteractiveRequestRow = {
998
1003
  contractHash: Buffer;
999
1004
  revision: number;
1000
1005
  status: InteractiveRequestRecord["status"];
1006
+ presenterId: string | null;
1007
+ presentationClaimExpiresAt: number | null;
1001
1008
  presentationSessionEntryId: string | null;
1002
1009
  acceptedSubmissionId: string | null;
1003
1010
  createdAt: number;
@@ -1116,6 +1123,8 @@ function isInteractiveRequestRow(value: unknown): value is InteractiveRequestRow
1116
1123
  Buffer.isBuffer(value.contractHash) &&
1117
1124
  typeof value.revision === "number" &&
1118
1125
  ["pending", "presenting", "settled", "cancelled"].includes(value.status as string) &&
1126
+ nullableString(value.presenterId) &&
1127
+ nullableNumber(value.presentationClaimExpiresAt) &&
1119
1128
  nullableString(value.presentationSessionEntryId) &&
1120
1129
  nullableString(value.acceptedSubmissionId) &&
1121
1130
  typeof value.createdAt === "number" &&