@osolmaz/pi-workflows 0.16.0 → 0.16.2
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/README.md +18 -2
- package/dist/channels/adapter-entry.d.ts +3 -0
- package/dist/channels/adapter-entry.js +203 -0
- package/dist/channels/adapter-entry.js.map +1 -0
- package/dist/channels/config.d.ts +43 -0
- package/dist/channels/config.js +234 -0
- package/dist/channels/config.js.map +1 -0
- package/dist/channels/protocol.d.ts +94 -0
- package/dist/channels/protocol.js +219 -0
- package/dist/channels/protocol.js.map +1 -0
- package/dist/channels/telegram.d.ts +46 -0
- package/dist/channels/telegram.js +285 -0
- package/dist/channels/telegram.js.map +1 -0
- package/dist/client/client.d.ts +1 -0
- package/dist/client/client.js +5 -1
- package/dist/client/client.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/protocol.d.ts +1 -1
- package/dist/client/protocol.js +11 -5
- package/dist/client/protocol.js.map +1 -1
- package/dist/client/view.d.ts +48 -16
- package/dist/client/view.js +1 -0
- package/dist/client/view.js.map +1 -1
- package/dist/controllers/index.d.ts +1 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +20 -165
- package/dist/controllers/sqlite.js +178 -436
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/extension/index.d.ts +23 -2
- package/dist/extension/index.js +637 -534
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +95 -0
- package/dist/extension/recorder.js +530 -0
- package/dist/extension/recorder.js.map +1 -0
- package/dist/extension/remote-recorder-store.d.ts +25 -0
- package/dist/extension/remote-recorder-store.js +81 -0
- package/dist/extension/remote-recorder-store.js.map +1 -0
- package/dist/extension/session-events.d.ts +134 -0
- package/dist/extension/session-events.js +60 -0
- package/dist/extension/session-events.js.map +1 -0
- package/dist/extension/session-view.d.ts +2 -0
- package/dist/extension/session-view.js +60 -2
- package/dist/extension/session-view.js.map +1 -1
- package/dist/extension/step-message.d.ts +6 -4
- package/dist/extension/step-message.js +12 -6
- package/dist/extension/step-message.js.map +1 -1
- package/dist/extension/widget.d.ts +2 -2
- package/dist/extension/widget.js +28 -18
- package/dist/extension/widget.js.map +1 -1
- package/dist/extension/workflow-message-coordinator.d.ts +28 -0
- package/dist/extension/workflow-message-coordinator.js +346 -0
- package/dist/extension/workflow-message-coordinator.js.map +1 -0
- package/dist/host/channel-effects.d.ts +72 -0
- package/dist/host/channel-effects.js +271 -0
- package/dist/host/channel-effects.js.map +1 -0
- package/dist/host/channel-supervisor.d.ts +21 -0
- package/dist/host/channel-supervisor.js +54 -0
- package/dist/host/channel-supervisor.js.map +1 -0
- package/dist/host/runner.d.ts +50 -8
- package/dist/host/runner.js +1632 -341
- package/dist/host/runner.js.map +1 -1
- package/dist/host/state.d.ts +19 -16
- package/dist/host/state.js +251 -63
- package/dist/host/state.js.map +1 -1
- package/dist/host/view.d.ts +19 -9
- package/dist/host/view.js +227 -119
- package/dist/host/view.js.map +1 -1
- package/dist/host/worker-entry.d.ts +4 -1
- package/dist/host/worker-entry.js +23 -24
- package/dist/host/worker-entry.js.map +1 -1
- package/dist/host/worker-protocol.d.ts +15 -0
- package/dist/host/worker-protocol.js.map +1 -1
- package/dist/state/prune.js +3 -25
- package/dist/state/prune.js.map +1 -1
- package/dist/state/schema.js +76 -97
- package/dist/state/schema.js.map +1 -1
- package/dist/state/workflow-messages.d.ts +98 -0
- package/dist/state/workflow-messages.js +366 -0
- package/dist/state/workflow-messages.js.map +1 -0
- package/dist/viewer/render.js +1 -1
- package/dist/viewer/render.js.map +1 -1
- package/dist/viewer/tui.d.ts +1 -1
- package/dist/viewer/tui.js +65 -8
- package/dist/viewer/tui.js.map +1 -1
- package/dist/workflows/composition.js +0 -4
- package/dist/workflows/composition.js.map +1 -1
- package/dist/workflows/definition.js +0 -8
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.js +4 -5
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/human-decision.d.ts +2 -0
- package/dist/workflows/human-decision.js +8 -2
- package/dist/workflows/human-decision.js.map +1 -1
- package/dist/workflows/index.d.ts +1 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/schema.js +0 -4
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/settings.d.ts +1 -8
- package/dist/workflows/settings.js.map +1 -1
- package/dist/workflows/store.d.ts +20 -19
- package/dist/workflows/store.js +163 -354
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/tool-input.d.ts +21 -0
- package/dist/workflows/tool-input.js +23 -1
- package/dist/workflows/tool-input.js.map +1 -1
- package/dist/workflows/types.d.ts +6 -8
- package/dist/workflows/workflow-message-content.d.ts +38 -0
- package/dist/workflows/workflow-message-content.js +157 -0
- package/dist/workflows/workflow-message-content.js.map +1 -0
- package/docs/2026-08-18-herdr-piw-plan.md +2 -1
- package/docs/2026-08-20-durable-workflow-launch-plan.md +1 -1
- package/docs/2026-08-25-workflow-follow-ups.md +26 -50
- package/docs/2026-08-30-out-of-process-workflow-host-plan.md +3 -3
- package/docs/2026-09-01-restore-session-delivery-controls-plan.md +4 -0
- package/docs/2026-09-01-unified-workflow-client-plan.md +5 -1
- package/docs/2026-09-02-installed-live-e2e-plan.md +15 -1
- package/docs/2026-09-02-unify-workflow-messages-plan.md +562 -0
- package/docs/2026-09-04-workflow-run-state-plan.md +363 -0
- package/docs/DEFERRED_TURNS.md +66 -294
- package/docs/HUMAN_DECISIONS.md +29 -30
- package/docs/HUMAN_DECISION_PRESENTATIONS.md +4 -11
- package/docs/SQLITE_STATE.md +25 -17
- package/docs/WORKFLOW_HOST.md +105 -31
- package/docs/WORKFLOW_STEP_MESSAGES.md +166 -75
- package/docs/live-replay-protocol.md +8 -4
- package/docs/tui-viewer.md +3 -1
- package/docs/workflows.md +44 -28
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/plugins/herdr/viewer.mjs +14 -5
- package/src/channels/adapter-entry.ts +220 -0
- package/src/channels/config.ts +296 -0
- package/src/channels/protocol.ts +333 -0
- package/src/channels/telegram.ts +335 -0
- package/src/client/client.ts +6 -2
- package/src/client/index.ts +2 -1
- package/src/client/protocol.ts +11 -5
- package/src/client/view.ts +51 -18
- package/src/controllers/index.ts +0 -1
- package/src/controllers/sqlite.ts +247 -845
- package/src/extension/index.ts +712 -647
- package/src/extension/recorder.ts +687 -0
- package/src/extension/remote-recorder-store.ts +126 -0
- package/src/extension/session-events.ts +119 -0
- package/src/extension/session-view.ts +86 -3
- package/src/extension/step-message.ts +19 -9
- package/src/extension/widget.ts +37 -17
- package/src/extension/workflow-message-coordinator.ts +398 -0
- package/src/host/channel-effects.ts +465 -0
- package/src/host/channel-supervisor.ts +73 -0
- package/src/host/runner.ts +2163 -525
- package/src/host/state.ts +333 -94
- package/src/host/view.ts +254 -122
- package/src/host/worker-entry.ts +38 -30
- package/src/host/worker-protocol.ts +11 -0
- package/src/state/prune.ts +3 -31
- package/src/state/schema.ts +76 -97
- package/src/state/workflow-messages.ts +599 -0
- package/src/viewer/render.ts +1 -5
- package/src/viewer/tui.ts +71 -6
- package/src/workflows/composition.ts +0 -5
- package/src/workflows/definition.ts +0 -8
- package/src/workflows/engine.ts +4 -6
- package/src/workflows/human-decision.ts +18 -2
- package/src/workflows/index.ts +0 -1
- package/src/workflows/schema.ts +0 -6
- package/src/workflows/settings.ts +1 -20
- package/src/workflows/store.ts +237 -489
- package/src/workflows/tool-input.ts +36 -1
- package/src/workflows/types.ts +6 -8
- package/src/workflows/workflow-message-content.ts +197 -0
- package/dist/extension/decision-channels.d.ts +0 -134
- package/dist/extension/decision-channels.js +0 -1307
- package/dist/extension/decision-channels.js.map +0 -1
- package/src/extension/decision-channels.ts +0 -1826
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import type { StateDatabase } from "./database.js";
|
|
3
|
+
import { canonicalJson, type JsonValue } from "./json.js";
|
|
4
|
+
|
|
5
|
+
export const WORKFLOW_MESSAGE_SCHEMA = "pi-workflows.workflow-message.v1" as const;
|
|
6
|
+
export const WORKFLOW_MESSAGE_CONTENT_SCHEMA = "pi-workflows.workflow-message-content.v1" as const;
|
|
7
|
+
export const WORKFLOW_TURN_SCHEMA = "pi-workflows.workflow-turn.v1" as const;
|
|
8
|
+
|
|
9
|
+
export type WorkflowMessageKind = "step" | "decision" | "notification" | "terminal" | "followUp";
|
|
10
|
+
export type WorkflowMessageStatus = "pending" | "sent" | "cancelled";
|
|
11
|
+
export type WorkflowStepReason = "initial" | "reminder" | "resumed";
|
|
12
|
+
export type WorkflowTurnState = "started" | "ended";
|
|
13
|
+
export type WorkflowTurnStopReason = "completed" | "aborted" | "error" | "lost";
|
|
14
|
+
|
|
15
|
+
export type WorkflowMessageContent = {
|
|
16
|
+
schema: typeof WORKFLOW_MESSAGE_CONTENT_SCHEMA;
|
|
17
|
+
customType: string;
|
|
18
|
+
content: string;
|
|
19
|
+
display: boolean;
|
|
20
|
+
details: JsonValue;
|
|
21
|
+
triggerTurn: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type WorkflowMessage = {
|
|
25
|
+
schema: typeof WORKFLOW_MESSAGE_SCHEMA;
|
|
26
|
+
workflowMessageId: string;
|
|
27
|
+
runId: string;
|
|
28
|
+
targetSessionId: string;
|
|
29
|
+
kind: WorkflowMessageKind;
|
|
30
|
+
sourceId: string;
|
|
31
|
+
contentDigest: string;
|
|
32
|
+
content: WorkflowMessageContent;
|
|
33
|
+
order: number;
|
|
34
|
+
status: WorkflowMessageStatus;
|
|
35
|
+
piSessionEntryId: string | null;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
updatedAt: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type WorkflowTurn = {
|
|
41
|
+
schema: typeof WORKFLOW_TURN_SCHEMA;
|
|
42
|
+
workflowTurnId: string;
|
|
43
|
+
workflowMessageId: string;
|
|
44
|
+
runId: string;
|
|
45
|
+
targetSessionId: string;
|
|
46
|
+
state: WorkflowTurnState;
|
|
47
|
+
stopReason: WorkflowTurnStopReason | null;
|
|
48
|
+
responseSessionEntryId: string | null;
|
|
49
|
+
startedAt: string;
|
|
50
|
+
endedAt: string | null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type WorkflowBranchEntry = {
|
|
54
|
+
workflowMessageId: string;
|
|
55
|
+
piSessionEntryId: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type CreateWorkflowMessageOptions = {
|
|
59
|
+
workflowMessageId?: string;
|
|
60
|
+
runId: string;
|
|
61
|
+
targetSessionId: string;
|
|
62
|
+
kind: WorkflowMessageKind;
|
|
63
|
+
sourceId: string;
|
|
64
|
+
idempotencyKey: string;
|
|
65
|
+
content: WorkflowMessageContent;
|
|
66
|
+
now?: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type WorkflowMessageRow = {
|
|
70
|
+
workflowMessageId: string;
|
|
71
|
+
runId: string;
|
|
72
|
+
targetSessionId: string;
|
|
73
|
+
kind: WorkflowMessageKind;
|
|
74
|
+
sourceId: string;
|
|
75
|
+
contentHash: Buffer;
|
|
76
|
+
orderNumber: number;
|
|
77
|
+
status: WorkflowMessageStatus;
|
|
78
|
+
piSessionEntryId: string | null;
|
|
79
|
+
createdAt: number;
|
|
80
|
+
updatedAt: number;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type WorkflowTurnRow = {
|
|
84
|
+
workflowTurnId: string;
|
|
85
|
+
workflowMessageId: string;
|
|
86
|
+
runId: string;
|
|
87
|
+
targetSessionId: string;
|
|
88
|
+
state: WorkflowTurnState;
|
|
89
|
+
stopReason: WorkflowTurnStopReason | null;
|
|
90
|
+
responseSessionEntryId: string | null;
|
|
91
|
+
startedAt: number;
|
|
92
|
+
endedAt: number | null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Durable workflow content that the host requires Pi to add to one origin session. */
|
|
96
|
+
export class WorkflowMessageStore {
|
|
97
|
+
constructor(readonly state: StateDatabase) {}
|
|
98
|
+
|
|
99
|
+
create(options: CreateWorkflowMessageOptions): WorkflowMessage {
|
|
100
|
+
validateContent(options.content, options.kind);
|
|
101
|
+
const now = options.now ?? Date.now();
|
|
102
|
+
const workflowMessageId =
|
|
103
|
+
options.workflowMessageId ??
|
|
104
|
+
workflowMessageIdFor(options.kind, options.sourceId, options.idempotencyKey);
|
|
105
|
+
return this.state.transaction(() => {
|
|
106
|
+
const existing = this.get(workflowMessageId);
|
|
107
|
+
if (existing !== undefined) {
|
|
108
|
+
assertSameMessage(existing, options);
|
|
109
|
+
return existing;
|
|
110
|
+
}
|
|
111
|
+
const contentHash = this.state.putJson(options.content, now);
|
|
112
|
+
const order =
|
|
113
|
+
((
|
|
114
|
+
this.state.connection
|
|
115
|
+
.prepare(
|
|
116
|
+
`SELECT COALESCE(MAX(order_number), 0) AS value
|
|
117
|
+
FROM workflow_messages WHERE target_session_id = ?`,
|
|
118
|
+
)
|
|
119
|
+
.get(options.targetSessionId) as { value: number }
|
|
120
|
+
).value ?? 0) + 1;
|
|
121
|
+
this.state.connection
|
|
122
|
+
.prepare(
|
|
123
|
+
`INSERT INTO workflow_messages(
|
|
124
|
+
workflow_message_id, run_id, target_session_id, kind, source_id, content_hash,
|
|
125
|
+
order_number, status, pi_session_entry_id, created_at, updated_at
|
|
126
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', NULL, ?, ?)`,
|
|
127
|
+
)
|
|
128
|
+
.run(
|
|
129
|
+
workflowMessageId,
|
|
130
|
+
options.runId,
|
|
131
|
+
options.targetSessionId,
|
|
132
|
+
options.kind,
|
|
133
|
+
options.sourceId,
|
|
134
|
+
contentHash,
|
|
135
|
+
order,
|
|
136
|
+
now,
|
|
137
|
+
now,
|
|
138
|
+
);
|
|
139
|
+
return this.require(workflowMessageId);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
get(workflowMessageId: string): WorkflowMessage | undefined {
|
|
144
|
+
const row = this.state.connection
|
|
145
|
+
.prepare(
|
|
146
|
+
`SELECT workflow_message_id AS workflowMessageId, run_id AS runId,
|
|
147
|
+
target_session_id AS targetSessionId, kind, source_id AS sourceId,
|
|
148
|
+
content_hash AS contentHash, order_number AS orderNumber, status,
|
|
149
|
+
pi_session_entry_id AS piSessionEntryId, created_at AS createdAt,
|
|
150
|
+
updated_at AS updatedAt
|
|
151
|
+
FROM workflow_messages WHERE workflow_message_id = ?`,
|
|
152
|
+
)
|
|
153
|
+
.get(workflowMessageId);
|
|
154
|
+
return isWorkflowMessageRow(row) ? this.mapMessage(row) : undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
require(workflowMessageId: string): WorkflowMessage {
|
|
158
|
+
const message = this.get(workflowMessageId);
|
|
159
|
+
if (message === undefined) throw new Error(`Workflow message not found: ${workflowMessageId}`);
|
|
160
|
+
return message;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
listSession(targetSessionId: string): WorkflowMessage[] {
|
|
164
|
+
const rows = this.state.connection
|
|
165
|
+
.prepare(
|
|
166
|
+
`SELECT workflow_message_id AS workflowMessageId, run_id AS runId,
|
|
167
|
+
target_session_id AS targetSessionId, kind, source_id AS sourceId,
|
|
168
|
+
content_hash AS contentHash, order_number AS orderNumber, status,
|
|
169
|
+
pi_session_entry_id AS piSessionEntryId, created_at AS createdAt,
|
|
170
|
+
updated_at AS updatedAt
|
|
171
|
+
FROM workflow_messages WHERE target_session_id = ? ORDER BY order_number`,
|
|
172
|
+
)
|
|
173
|
+
.all(targetSessionId);
|
|
174
|
+
return rows.filter(isWorkflowMessageRow).map((row) => this.mapMessage(row));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
listRun(runId: string): WorkflowMessage[] {
|
|
178
|
+
const rows = this.state.connection
|
|
179
|
+
.prepare(
|
|
180
|
+
`SELECT workflow_message_id AS workflowMessageId, run_id AS runId,
|
|
181
|
+
target_session_id AS targetSessionId, kind, source_id AS sourceId,
|
|
182
|
+
content_hash AS contentHash, order_number AS orderNumber, status,
|
|
183
|
+
pi_session_entry_id AS piSessionEntryId, created_at AS createdAt,
|
|
184
|
+
updated_at AS updatedAt
|
|
185
|
+
FROM workflow_messages WHERE run_id = ? ORDER BY target_session_id, order_number`,
|
|
186
|
+
)
|
|
187
|
+
.all(runId);
|
|
188
|
+
return rows.filter(isWorkflowMessageRow).map((row) => this.mapMessage(row));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
latestForSource(kind: WorkflowMessageKind, sourceId: string): WorkflowMessage | undefined {
|
|
192
|
+
const row = this.state.connection
|
|
193
|
+
.prepare(
|
|
194
|
+
`SELECT workflow_message_id AS workflowMessageId, run_id AS runId,
|
|
195
|
+
target_session_id AS targetSessionId, kind, source_id AS sourceId,
|
|
196
|
+
content_hash AS contentHash, order_number AS orderNumber, status,
|
|
197
|
+
pi_session_entry_id AS piSessionEntryId, created_at AS createdAt,
|
|
198
|
+
updated_at AS updatedAt
|
|
199
|
+
FROM workflow_messages WHERE kind = ? AND source_id = ?
|
|
200
|
+
ORDER BY created_at DESC, order_number DESC LIMIT 1`,
|
|
201
|
+
)
|
|
202
|
+
.get(kind, sourceId);
|
|
203
|
+
return isWorkflowMessageRow(row) ? this.mapMessage(row) : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
cancelPendingForSource(
|
|
207
|
+
sourceId: string,
|
|
208
|
+
kind?: WorkflowMessageKind,
|
|
209
|
+
now: number = Date.now(),
|
|
210
|
+
): number {
|
|
211
|
+
return this.state.transaction(
|
|
212
|
+
() =>
|
|
213
|
+
this.state.connection
|
|
214
|
+
.prepare(
|
|
215
|
+
`UPDATE workflow_messages SET status = 'cancelled', updated_at = ?
|
|
216
|
+
WHERE source_id = ? AND status = 'pending'${kind === undefined ? "" : " AND kind = ?"}`,
|
|
217
|
+
)
|
|
218
|
+
.run(...(kind === undefined ? [now, sourceId] : [now, sourceId, kind])).changes,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
adoptBranch(
|
|
223
|
+
targetSessionId: string,
|
|
224
|
+
entries: readonly WorkflowBranchEntry[],
|
|
225
|
+
allowedMessageIds: ReadonlySet<string>,
|
|
226
|
+
now: number = Date.now(),
|
|
227
|
+
): WorkflowMessage[] {
|
|
228
|
+
return this.state.transaction(() => {
|
|
229
|
+
const adopted: WorkflowMessage[] = [];
|
|
230
|
+
for (const entry of entries) {
|
|
231
|
+
if (!allowedMessageIds.has(entry.workflowMessageId)) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`Workflow branch report contains an unknown message: ${entry.workflowMessageId}`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
const current = this.require(entry.workflowMessageId);
|
|
237
|
+
if (current.targetSessionId !== targetSessionId) {
|
|
238
|
+
throw new Error("Workflow branch report targets the wrong origin session");
|
|
239
|
+
}
|
|
240
|
+
if (
|
|
241
|
+
current.status === "sent" &&
|
|
242
|
+
current.piSessionEntryId !== null &&
|
|
243
|
+
current.piSessionEntryId !== entry.piSessionEntryId
|
|
244
|
+
) {
|
|
245
|
+
throw new Error("Workflow message has conflicting Pi session entry evidence");
|
|
246
|
+
}
|
|
247
|
+
if (current.status !== "sent") {
|
|
248
|
+
this.state.connection
|
|
249
|
+
.prepare(
|
|
250
|
+
`UPDATE workflow_messages
|
|
251
|
+
SET status = 'sent', pi_session_entry_id = ?, updated_at = ?
|
|
252
|
+
WHERE workflow_message_id = ? AND status IN ('pending', 'cancelled')`,
|
|
253
|
+
)
|
|
254
|
+
.run(entry.piSessionEntryId, now, entry.workflowMessageId);
|
|
255
|
+
}
|
|
256
|
+
adopted.push(this.require(entry.workflowMessageId));
|
|
257
|
+
}
|
|
258
|
+
return adopted;
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
startTurn(options: {
|
|
263
|
+
workflowMessageId: string;
|
|
264
|
+
workflowTurnId?: string;
|
|
265
|
+
runId: string;
|
|
266
|
+
targetSessionId: string;
|
|
267
|
+
now?: number;
|
|
268
|
+
}): WorkflowTurn {
|
|
269
|
+
const now = options.now ?? Date.now();
|
|
270
|
+
const workflowTurnId = options.workflowTurnId ?? `workflow-turn-${randomUUID()}`;
|
|
271
|
+
return this.state.transaction(() => {
|
|
272
|
+
const existing = this.getTurn(workflowTurnId);
|
|
273
|
+
if (existing !== undefined) {
|
|
274
|
+
assertSameTurn(existing, options);
|
|
275
|
+
return existing;
|
|
276
|
+
}
|
|
277
|
+
const message = this.require(options.workflowMessageId);
|
|
278
|
+
if (message.runId !== options.runId || message.targetSessionId !== options.targetSessionId) {
|
|
279
|
+
throw new Error("Workflow turn does not match its message");
|
|
280
|
+
}
|
|
281
|
+
if (message.status !== "sent") throw new Error("Workflow turn requires a sent message");
|
|
282
|
+
const open = this.openTurnForMessage(options.workflowMessageId);
|
|
283
|
+
if (open !== undefined) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`Workflow message ${options.workflowMessageId} already has open turn ${open.workflowTurnId}`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
this.state.connection
|
|
289
|
+
.prepare(
|
|
290
|
+
`INSERT INTO workflow_turns(
|
|
291
|
+
workflow_turn_id, workflow_message_id, run_id, target_session_id, state,
|
|
292
|
+
stop_reason, response_session_entry_id, started_at, ended_at
|
|
293
|
+
) VALUES (?, ?, ?, ?, 'started', NULL, NULL, ?, NULL)`,
|
|
294
|
+
)
|
|
295
|
+
.run(
|
|
296
|
+
workflowTurnId,
|
|
297
|
+
options.workflowMessageId,
|
|
298
|
+
options.runId,
|
|
299
|
+
options.targetSessionId,
|
|
300
|
+
now,
|
|
301
|
+
);
|
|
302
|
+
return this.requireTurn(workflowTurnId);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
endTurn(options: {
|
|
307
|
+
workflowMessageId: string;
|
|
308
|
+
workflowTurnId: string;
|
|
309
|
+
runId: string;
|
|
310
|
+
targetSessionId: string;
|
|
311
|
+
stopReason: WorkflowTurnStopReason;
|
|
312
|
+
responseSessionEntryId?: string | null;
|
|
313
|
+
now?: number;
|
|
314
|
+
}): WorkflowTurn {
|
|
315
|
+
const now = options.now ?? Date.now();
|
|
316
|
+
return this.state.transaction(() => {
|
|
317
|
+
const current = this.requireTurn(options.workflowTurnId);
|
|
318
|
+
assertSameTurn(current, options);
|
|
319
|
+
if (current.state === "ended") {
|
|
320
|
+
if (
|
|
321
|
+
current.stopReason !== options.stopReason ||
|
|
322
|
+
current.responseSessionEntryId !== (options.responseSessionEntryId ?? null)
|
|
323
|
+
) {
|
|
324
|
+
throw new Error("Workflow turn end report conflicts with its saved result");
|
|
325
|
+
}
|
|
326
|
+
return current;
|
|
327
|
+
}
|
|
328
|
+
const changed = this.state.connection
|
|
329
|
+
.prepare(
|
|
330
|
+
`UPDATE workflow_turns SET state = 'ended', stop_reason = ?,
|
|
331
|
+
response_session_entry_id = ?, ended_at = ?
|
|
332
|
+
WHERE workflow_turn_id = ? AND workflow_message_id = ? AND state = 'started'`,
|
|
333
|
+
)
|
|
334
|
+
.run(
|
|
335
|
+
options.stopReason,
|
|
336
|
+
options.responseSessionEntryId ?? null,
|
|
337
|
+
now,
|
|
338
|
+
options.workflowTurnId,
|
|
339
|
+
options.workflowMessageId,
|
|
340
|
+
);
|
|
341
|
+
if (changed.changes !== 1) throw new Error("Workflow turn changed before its end report");
|
|
342
|
+
return this.requireTurn(options.workflowTurnId);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
settleOpenTurnsForRun(
|
|
347
|
+
runId: string,
|
|
348
|
+
stopReason: WorkflowTurnStopReason = "lost",
|
|
349
|
+
now: number = Date.now(),
|
|
350
|
+
): WorkflowTurn[] {
|
|
351
|
+
return this.state.transaction(() => {
|
|
352
|
+
const open = this.state.connection
|
|
353
|
+
.prepare(
|
|
354
|
+
`SELECT workflow_turn_id AS workflowTurnId, workflow_message_id AS workflowMessageId,
|
|
355
|
+
run_id AS runId, target_session_id AS targetSessionId, state,
|
|
356
|
+
stop_reason AS stopReason, response_session_entry_id AS responseSessionEntryId,
|
|
357
|
+
started_at AS startedAt, ended_at AS endedAt
|
|
358
|
+
FROM workflow_turns WHERE run_id = ? AND state = 'started' ORDER BY started_at`,
|
|
359
|
+
)
|
|
360
|
+
.all(runId)
|
|
361
|
+
.filter(isWorkflowTurnRow)
|
|
362
|
+
.map(mapTurn);
|
|
363
|
+
this.state.connection
|
|
364
|
+
.prepare(
|
|
365
|
+
`UPDATE workflow_turns SET state = 'ended', stop_reason = ?, ended_at = ?
|
|
366
|
+
WHERE run_id = ? AND state = 'started'`,
|
|
367
|
+
)
|
|
368
|
+
.run(stopReason, now, runId);
|
|
369
|
+
return open.map((turn) => this.requireTurn(turn.workflowTurnId));
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
cancelPendingForRun(
|
|
374
|
+
runId: string,
|
|
375
|
+
now: number = Date.now(),
|
|
376
|
+
kinds: readonly WorkflowMessageKind[] = ["step", "decision", "followUp"],
|
|
377
|
+
): number {
|
|
378
|
+
if (kinds.length === 0) return 0;
|
|
379
|
+
return this.state.transaction(
|
|
380
|
+
() =>
|
|
381
|
+
this.state.connection
|
|
382
|
+
.prepare(
|
|
383
|
+
`UPDATE workflow_messages SET status = 'cancelled', updated_at = ?
|
|
384
|
+
WHERE run_id = ? AND status = 'pending'
|
|
385
|
+
AND kind IN (${kinds.map(() => "?").join(", ")})`,
|
|
386
|
+
)
|
|
387
|
+
.run(now, runId, ...kinds).changes,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
getTurn(workflowTurnId: string): WorkflowTurn | undefined {
|
|
392
|
+
const row = this.state.connection
|
|
393
|
+
.prepare(
|
|
394
|
+
`SELECT workflow_turn_id AS workflowTurnId, workflow_message_id AS workflowMessageId,
|
|
395
|
+
run_id AS runId, target_session_id AS targetSessionId, state,
|
|
396
|
+
stop_reason AS stopReason, response_session_entry_id AS responseSessionEntryId,
|
|
397
|
+
started_at AS startedAt, ended_at AS endedAt
|
|
398
|
+
FROM workflow_turns WHERE workflow_turn_id = ?`,
|
|
399
|
+
)
|
|
400
|
+
.get(workflowTurnId);
|
|
401
|
+
return isWorkflowTurnRow(row) ? mapTurn(row) : undefined;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
requireTurn(workflowTurnId: string): WorkflowTurn {
|
|
405
|
+
const turn = this.getTurn(workflowTurnId);
|
|
406
|
+
if (turn === undefined) throw new Error(`Workflow turn not found: ${workflowTurnId}`);
|
|
407
|
+
return turn;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
openTurnForMessage(workflowMessageId: string): WorkflowTurn | undefined {
|
|
411
|
+
const row = this.state.connection
|
|
412
|
+
.prepare(
|
|
413
|
+
`SELECT workflow_turn_id AS workflowTurnId, workflow_message_id AS workflowMessageId,
|
|
414
|
+
run_id AS runId, target_session_id AS targetSessionId, state,
|
|
415
|
+
stop_reason AS stopReason, response_session_entry_id AS responseSessionEntryId,
|
|
416
|
+
started_at AS startedAt, ended_at AS endedAt
|
|
417
|
+
FROM workflow_turns WHERE workflow_message_id = ? AND state = 'started'`,
|
|
418
|
+
)
|
|
419
|
+
.get(workflowMessageId);
|
|
420
|
+
return isWorkflowTurnRow(row) ? mapTurn(row) : undefined;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
latestTurnForMessage(workflowMessageId: string): WorkflowTurn | undefined {
|
|
424
|
+
const row = this.state.connection
|
|
425
|
+
.prepare(
|
|
426
|
+
`SELECT workflow_turn_id AS workflowTurnId, workflow_message_id AS workflowMessageId,
|
|
427
|
+
run_id AS runId, target_session_id AS targetSessionId, state,
|
|
428
|
+
stop_reason AS stopReason, response_session_entry_id AS responseSessionEntryId,
|
|
429
|
+
started_at AS startedAt, ended_at AS endedAt
|
|
430
|
+
FROM workflow_turns WHERE workflow_message_id = ?
|
|
431
|
+
ORDER BY started_at DESC LIMIT 1`,
|
|
432
|
+
)
|
|
433
|
+
.get(workflowMessageId);
|
|
434
|
+
return isWorkflowTurnRow(row) ? mapTurn(row) : undefined;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
openTurnsForSession(targetSessionId: string): WorkflowTurn[] {
|
|
438
|
+
const rows = this.state.connection
|
|
439
|
+
.prepare(
|
|
440
|
+
`SELECT workflow_turn_id AS workflowTurnId, workflow_message_id AS workflowMessageId,
|
|
441
|
+
run_id AS runId, target_session_id AS targetSessionId, state,
|
|
442
|
+
stop_reason AS stopReason, response_session_entry_id AS responseSessionEntryId,
|
|
443
|
+
started_at AS startedAt, ended_at AS endedAt
|
|
444
|
+
FROM workflow_turns WHERE target_session_id = ? AND state = 'started'
|
|
445
|
+
ORDER BY started_at`,
|
|
446
|
+
)
|
|
447
|
+
.all(targetSessionId);
|
|
448
|
+
return rows.filter(isWorkflowTurnRow).map(mapTurn);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private mapMessage(row: WorkflowMessageRow): WorkflowMessage {
|
|
452
|
+
const content = this.state.readJson(row.contentHash);
|
|
453
|
+
if (!isWorkflowMessageContent(content))
|
|
454
|
+
throw new Error("Stored workflow message content is invalid");
|
|
455
|
+
return {
|
|
456
|
+
schema: WORKFLOW_MESSAGE_SCHEMA,
|
|
457
|
+
workflowMessageId: row.workflowMessageId,
|
|
458
|
+
runId: row.runId,
|
|
459
|
+
targetSessionId: row.targetSessionId,
|
|
460
|
+
kind: row.kind,
|
|
461
|
+
sourceId: row.sourceId,
|
|
462
|
+
contentDigest: row.contentHash.toString("hex"),
|
|
463
|
+
content,
|
|
464
|
+
order: row.orderNumber,
|
|
465
|
+
status: row.status,
|
|
466
|
+
piSessionEntryId: row.piSessionEntryId,
|
|
467
|
+
createdAt: new Date(row.createdAt).toISOString(),
|
|
468
|
+
updatedAt: new Date(row.updatedAt).toISOString(),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function workflowMessageIdFor(
|
|
474
|
+
kind: WorkflowMessageKind,
|
|
475
|
+
sourceId: string,
|
|
476
|
+
idempotencyKey: string,
|
|
477
|
+
): string {
|
|
478
|
+
const digest = createHash("sha256")
|
|
479
|
+
.update(canonicalJson({ kind, sourceId, idempotencyKey }))
|
|
480
|
+
.digest("hex");
|
|
481
|
+
return `workflow-message-${digest}`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export function isWorkflowMessageContent(value: unknown): value is WorkflowMessageContent {
|
|
485
|
+
return (
|
|
486
|
+
isRecord(value) &&
|
|
487
|
+
value.schema === WORKFLOW_MESSAGE_CONTENT_SCHEMA &&
|
|
488
|
+
typeof value.customType === "string" &&
|
|
489
|
+
value.customType.length > 0 &&
|
|
490
|
+
typeof value.content === "string" &&
|
|
491
|
+
typeof value.display === "boolean" &&
|
|
492
|
+
Object.hasOwn(value, "details") &&
|
|
493
|
+
typeof value.triggerTurn === "boolean"
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function validateContent(content: WorkflowMessageContent, kind: WorkflowMessageKind): void {
|
|
498
|
+
if (!isWorkflowMessageContent(content)) throw new Error("Workflow message content is invalid");
|
|
499
|
+
const shouldTrigger = kind === "step" || kind === "terminal" || kind === "followUp";
|
|
500
|
+
if (content.triggerTurn !== shouldTrigger) {
|
|
501
|
+
throw new Error(`Workflow message kind ${kind} has invalid turn behavior`);
|
|
502
|
+
}
|
|
503
|
+
canonicalJson(content);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function assertSameMessage(message: WorkflowMessage, options: CreateWorkflowMessageOptions): void {
|
|
507
|
+
const digest = createHash("sha256").update(canonicalJson(options.content)).digest("hex");
|
|
508
|
+
if (
|
|
509
|
+
message.runId !== options.runId ||
|
|
510
|
+
message.targetSessionId !== options.targetSessionId ||
|
|
511
|
+
message.kind !== options.kind ||
|
|
512
|
+
message.sourceId !== options.sourceId ||
|
|
513
|
+
message.contentDigest !== digest
|
|
514
|
+
) {
|
|
515
|
+
throw new Error(`Workflow message idempotency conflict: ${message.workflowMessageId}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function assertSameTurn(
|
|
520
|
+
turn: WorkflowTurn,
|
|
521
|
+
options: { workflowMessageId: string; runId: string; targetSessionId: string },
|
|
522
|
+
): void {
|
|
523
|
+
if (
|
|
524
|
+
turn.workflowMessageId !== options.workflowMessageId ||
|
|
525
|
+
turn.runId !== options.runId ||
|
|
526
|
+
turn.targetSessionId !== options.targetSessionId
|
|
527
|
+
) {
|
|
528
|
+
throw new Error(`Workflow turn identity conflict: ${turn.workflowTurnId}`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function mapTurn(row: WorkflowTurnRow): WorkflowTurn {
|
|
533
|
+
return {
|
|
534
|
+
schema: WORKFLOW_TURN_SCHEMA,
|
|
535
|
+
workflowTurnId: row.workflowTurnId,
|
|
536
|
+
workflowMessageId: row.workflowMessageId,
|
|
537
|
+
runId: row.runId,
|
|
538
|
+
targetSessionId: row.targetSessionId,
|
|
539
|
+
state: row.state,
|
|
540
|
+
stopReason: row.stopReason,
|
|
541
|
+
responseSessionEntryId: row.responseSessionEntryId,
|
|
542
|
+
startedAt: new Date(row.startedAt).toISOString(),
|
|
543
|
+
endedAt: row.endedAt === null ? null : new Date(row.endedAt).toISOString(),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function isWorkflowMessageRow(value: unknown): value is WorkflowMessageRow {
|
|
548
|
+
return (
|
|
549
|
+
isRecord(value) &&
|
|
550
|
+
typeof value.workflowMessageId === "string" &&
|
|
551
|
+
typeof value.runId === "string" &&
|
|
552
|
+
typeof value.targetSessionId === "string" &&
|
|
553
|
+
isWorkflowMessageKind(value.kind) &&
|
|
554
|
+
typeof value.sourceId === "string" &&
|
|
555
|
+
Buffer.isBuffer(value.contentHash) &&
|
|
556
|
+
typeof value.orderNumber === "number" &&
|
|
557
|
+
isWorkflowMessageStatus(value.status) &&
|
|
558
|
+
(value.piSessionEntryId === null || typeof value.piSessionEntryId === "string") &&
|
|
559
|
+
typeof value.createdAt === "number" &&
|
|
560
|
+
typeof value.updatedAt === "number"
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function isWorkflowTurnRow(value: unknown): value is WorkflowTurnRow {
|
|
565
|
+
return (
|
|
566
|
+
isRecord(value) &&
|
|
567
|
+
typeof value.workflowTurnId === "string" &&
|
|
568
|
+
typeof value.workflowMessageId === "string" &&
|
|
569
|
+
typeof value.runId === "string" &&
|
|
570
|
+
typeof value.targetSessionId === "string" &&
|
|
571
|
+
(value.state === "started" || value.state === "ended") &&
|
|
572
|
+
(value.stopReason === null || isWorkflowTurnStopReason(value.stopReason)) &&
|
|
573
|
+
(value.responseSessionEntryId === null || typeof value.responseSessionEntryId === "string") &&
|
|
574
|
+
typeof value.startedAt === "number" &&
|
|
575
|
+
(value.endedAt === null || typeof value.endedAt === "number")
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function isWorkflowMessageKind(value: unknown): value is WorkflowMessageKind {
|
|
580
|
+
return (
|
|
581
|
+
value === "step" ||
|
|
582
|
+
value === "decision" ||
|
|
583
|
+
value === "notification" ||
|
|
584
|
+
value === "terminal" ||
|
|
585
|
+
value === "followUp"
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function isWorkflowMessageStatus(value: unknown): value is WorkflowMessageStatus {
|
|
590
|
+
return value === "pending" || value === "sent" || value === "cancelled";
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function isWorkflowTurnStopReason(value: unknown): value is WorkflowTurnStopReason {
|
|
594
|
+
return value === "completed" || value === "aborted" || value === "error" || value === "lost";
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
598
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
599
|
+
}
|
package/src/viewer/render.ts
CHANGED
|
@@ -300,11 +300,7 @@ export function renderRunDetailLines(
|
|
|
300
300
|
|
|
301
301
|
if ((bundle.followUpQueue?.followUps.length ?? 0) > 0) {
|
|
302
302
|
lines.push("");
|
|
303
|
-
lines.push(
|
|
304
|
-
ansi.bold(
|
|
305
|
-
`follow-ups · presentation ${bundle.followUpQueue?.presentationState ?? "unknown"}`,
|
|
306
|
-
),
|
|
307
|
-
);
|
|
303
|
+
lines.push(ansi.bold("follow-ups"));
|
|
308
304
|
for (const followUp of bundle.followUpQueue?.followUps ?? []) {
|
|
309
305
|
lines.push(
|
|
310
306
|
fitWidth(
|