@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.
- package/dist/controllers/sqlite.d.ts +21 -0
- package/dist/controllers/sqlite.js +82 -1
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/extension/index.js +264 -139
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/session-delivery.d.ts +28 -0
- package/dist/extension/session-delivery.js +138 -0
- package/dist/extension/session-delivery.js.map +1 -0
- package/dist/extension/session-view.d.ts +16 -0
- package/dist/extension/session-view.js +119 -0
- package/dist/extension/session-view.js.map +1 -0
- package/dist/extension/widget.js +2 -1
- package/dist/extension/widget.js.map +1 -1
- package/dist/host/runner.d.ts +1 -0
- package/dist/host/runner.js +92 -26
- package/dist/host/runner.js.map +1 -1
- package/dist/host/state.d.ts +2 -0
- package/dist/host/state.js +9 -3
- package/dist/host/state.js.map +1 -1
- package/docs/2026-09-01-restore-session-delivery-controls-plan.md +139 -0
- package/docs/WORKFLOW_HOST.md +12 -5
- package/docs/WORKFLOW_STEP_MESSAGES.md +12 -5
- package/docs/workflows.md +12 -2
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/controllers/sqlite.ts +129 -1
- package/src/extension/index.ts +331 -159
- package/src/extension/session-delivery.ts +174 -0
- package/src/extension/session-view.ts +131 -0
- package/src/extension/widget.ts +3 -2
- package/src/host/runner.ts +103 -29
- package/src/host/state.ts +12 -3
|
@@ -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
|
+
}
|
package/src/extension/widget.ts
CHANGED
|
@@ -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:
|
|
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
|
}
|
package/src/host/runner.ts
CHANGED
|
@@ -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
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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">):
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
|
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: {
|
|
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
|
|
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: {
|
|
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
|
|
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,
|
|
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" &&
|