@osolmaz/pi-workflows 0.16.1 → 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/dist/client/view.d.ts +6 -0
- package/dist/client/view.js +1 -0
- package/dist/client/view.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +8 -0
- package/dist/controllers/sqlite.js +54 -1
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/extension/index.js +34 -15
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/workflow-message-coordinator.d.ts +3 -2
- package/dist/extension/workflow-message-coordinator.js +78 -26
- package/dist/extension/workflow-message-coordinator.js.map +1 -1
- package/dist/host/runner.d.ts +8 -1
- package/dist/host/runner.js +231 -176
- package/dist/host/runner.js.map +1 -1
- package/dist/host/view.js +6 -2
- 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/workflow-messages.d.ts +2 -0
- package/dist/state/workflow-messages.js +31 -0
- package/dist/state/workflow-messages.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/schema.js +0 -4
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.d.ts +15 -0
- package/dist/workflows/store.js +42 -0
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +1 -3
- package/docs/2026-09-04-workflow-run-state-plan.md +363 -0
- package/docs/SQLITE_STATE.md +8 -4
- package/docs/WORKFLOW_HOST.md +14 -5
- package/docs/workflows.md +10 -1
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/client/view.ts +8 -0
- package/src/controllers/sqlite.ts +83 -1
- package/src/extension/index.ts +37 -19
- package/src/extension/workflow-message-coordinator.ts +91 -27
- package/src/host/runner.ts +277 -228
- package/src/host/view.ts +6 -2
- package/src/host/worker-entry.ts +38 -30
- package/src/host/worker-protocol.ts +11 -0
- package/src/state/workflow-messages.ts +51 -0
- package/src/workflows/composition.ts +0 -5
- package/src/workflows/definition.ts +0 -8
- package/src/workflows/engine.ts +4 -6
- package/src/workflows/schema.ts +0 -6
- package/src/workflows/store.ts +64 -0
- package/src/workflows/types.ts +1 -3
package/src/host/view.ts
CHANGED
|
@@ -804,7 +804,8 @@ export class HostViewStore {
|
|
|
804
804
|
.prepare(
|
|
805
805
|
`SELECT t.target_session_id AS targetSessionId FROM workflow_turns t
|
|
806
806
|
JOIN workflow_messages m ON m.workflow_message_id = t.workflow_message_id
|
|
807
|
-
WHERE t.run_id = ? AND t.state = 'started'
|
|
807
|
+
WHERE t.run_id = ? AND t.state = 'started'
|
|
808
|
+
AND m.kind IN ('step', 'terminal', 'followUp') LIMIT 1`,
|
|
808
809
|
)
|
|
809
810
|
.get(runId);
|
|
810
811
|
return (
|
|
@@ -920,7 +921,10 @@ export function reduceWorkflowDisplay(facts: WorkflowDisplayFacts): WorkflowDisp
|
|
|
920
921
|
reason = "The workflow is waiting for origin-session input.";
|
|
921
922
|
} else if (facts.queueStatus === "parked" || facts.queueStatus === "queued") {
|
|
922
923
|
status = "queued";
|
|
923
|
-
reason =
|
|
924
|
+
reason =
|
|
925
|
+
facts.queueStatus === "parked"
|
|
926
|
+
? (facts.errorMessage ?? "The workflow is ready to resume.")
|
|
927
|
+
: null;
|
|
924
928
|
} else if (facts.queueStatus === "done") {
|
|
925
929
|
status = "completed";
|
|
926
930
|
} else if (facts.queueStatus === "failed" || facts.queueStatus === "cancelled") {
|
package/src/host/worker-entry.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
parseWorkerResponse,
|
|
32
32
|
type WorkerMessage,
|
|
33
33
|
type WorkerResponse,
|
|
34
|
+
type WorkerRunCommand,
|
|
34
35
|
} from "./worker-protocol.js";
|
|
35
36
|
import { HostBackedWorkflowStore, type WorkerStoreTransport } from "./worker-store.js";
|
|
36
37
|
|
|
@@ -38,14 +39,10 @@ const STARTUP_ENV = "PI_WORKFLOWS_WORKER_LAUNCH";
|
|
|
38
39
|
const PRESENTATION_TIMEOUT_MS = 30_000;
|
|
39
40
|
|
|
40
41
|
type WorkerBootstrap = {
|
|
41
|
-
|
|
42
|
-
input: JsonValue;
|
|
43
|
-
launchOptions: JsonValue;
|
|
44
|
-
parentRunId: string | null;
|
|
42
|
+
command: WorkerRunCommand;
|
|
45
43
|
originSessionId: string | null;
|
|
46
44
|
stateDirectory: string;
|
|
47
45
|
piArgs?: string[];
|
|
48
|
-
resumeInteractionAttemptId?: string;
|
|
49
46
|
candidateInteraction?: {
|
|
50
47
|
requestId: string;
|
|
51
48
|
attemptId: string;
|
|
@@ -278,6 +275,35 @@ export function validateAcceptedAssistantSubmission(
|
|
|
278
275
|
};
|
|
279
276
|
}
|
|
280
277
|
|
|
278
|
+
export async function executeWorkerRunCommand(
|
|
279
|
+
engine: Pick<WorkflowEngine, "run" | "resumeRun" | "continueRun">,
|
|
280
|
+
workflow: WorkflowDefinition,
|
|
281
|
+
runId: string,
|
|
282
|
+
workflowSource: WorkflowSource,
|
|
283
|
+
command: WorkerRunCommand,
|
|
284
|
+
) {
|
|
285
|
+
switch (command.kind) {
|
|
286
|
+
case "start":
|
|
287
|
+
case "restart":
|
|
288
|
+
return await engine.run(workflow, command.input, { runId, workflowSource });
|
|
289
|
+
case "resume":
|
|
290
|
+
return await engine.resumeRun(workflow, runId, {
|
|
291
|
+
workflowSource,
|
|
292
|
+
...(command.resumeInteractionAttemptId === undefined
|
|
293
|
+
? {}
|
|
294
|
+
: { resumeInteractionAttemptId: command.resumeInteractionAttemptId }),
|
|
295
|
+
});
|
|
296
|
+
case "continue":
|
|
297
|
+
return await engine.continueRun(workflow, command.parentRunId, command.input, {
|
|
298
|
+
runId,
|
|
299
|
+
workflowSource,
|
|
300
|
+
...(command.humanDecision === undefined
|
|
301
|
+
? {}
|
|
302
|
+
: { humanDecision: command.humanDecision as ResolvedHumanDecision }),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
281
307
|
export async function runWorkflowWorker(): Promise<number> {
|
|
282
308
|
const launch = readLaunchEnvelope();
|
|
283
309
|
const transport = new StdioWorkerTransport(launch);
|
|
@@ -360,32 +386,14 @@ export async function runWorkflowWorker(): Promise<number> {
|
|
|
360
386
|
await store.requestPresentation(instructions);
|
|
361
387
|
},
|
|
362
388
|
});
|
|
363
|
-
const launchOptions =
|
|
364
|
-
typeof bootstrap.launchOptions === "object" &&
|
|
365
|
-
bootstrap.launchOptions !== null &&
|
|
366
|
-
!Array.isArray(bootstrap.launchOptions)
|
|
367
|
-
? (bootstrap.launchOptions as Record<string, unknown>)
|
|
368
|
-
: {};
|
|
369
389
|
try {
|
|
370
|
-
const result =
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
: bootstrap.parentRunId === null
|
|
378
|
-
? await engine.run(workflow, bootstrap.input, {
|
|
379
|
-
runId: launch.runId,
|
|
380
|
-
workflowSource: workflowSource.root,
|
|
381
|
-
})
|
|
382
|
-
: await engine.continueRun(workflow, bootstrap.parentRunId, bootstrap.input, {
|
|
383
|
-
runId: launch.runId,
|
|
384
|
-
workflowSource: workflowSource.root,
|
|
385
|
-
...(launchOptions.humanDecision === undefined
|
|
386
|
-
? {}
|
|
387
|
-
: { humanDecision: launchOptions.humanDecision as ResolvedHumanDecision }),
|
|
388
|
-
});
|
|
390
|
+
const result = await executeWorkerRunCommand(
|
|
391
|
+
engine,
|
|
392
|
+
workflow,
|
|
393
|
+
launch.runId,
|
|
394
|
+
workflowSource.root,
|
|
395
|
+
bootstrap.command,
|
|
396
|
+
);
|
|
389
397
|
await transport.control("worker.exiting", {
|
|
390
398
|
status: result.state.status,
|
|
391
399
|
traceSeq: result.state.traceSeq,
|
|
@@ -4,6 +4,17 @@ import { canonicalJson, parseJson, type JsonValue } from "../state/json.js";
|
|
|
4
4
|
export const WORKER_MESSAGE_SCHEMA = "pi-workflows.worker-message.v1" as const;
|
|
5
5
|
export const WORKER_RESPONSE_SCHEMA = "pi-workflows.worker-response.v1" as const;
|
|
6
6
|
|
|
7
|
+
export type WorkerRunCommand =
|
|
8
|
+
| { kind: "start"; input: JsonValue }
|
|
9
|
+
| { kind: "resume"; resumeInteractionAttemptId?: string }
|
|
10
|
+
| {
|
|
11
|
+
kind: "continue";
|
|
12
|
+
parentRunId: string;
|
|
13
|
+
input: JsonValue;
|
|
14
|
+
humanDecision?: JsonValue;
|
|
15
|
+
}
|
|
16
|
+
| { kind: "restart"; input: JsonValue };
|
|
17
|
+
|
|
7
18
|
export const WORKER_MESSAGE_KINDS = [
|
|
8
19
|
"worker.ready",
|
|
9
20
|
"node.started",
|
|
@@ -279,6 +279,12 @@ export class WorkflowMessageStore {
|
|
|
279
279
|
throw new Error("Workflow turn does not match its message");
|
|
280
280
|
}
|
|
281
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
|
+
}
|
|
282
288
|
this.state.connection
|
|
283
289
|
.prepare(
|
|
284
290
|
`INSERT INTO workflow_turns(
|
|
@@ -337,6 +343,51 @@ export class WorkflowMessageStore {
|
|
|
337
343
|
});
|
|
338
344
|
}
|
|
339
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
|
+
|
|
340
391
|
getTurn(workflowTurnId: string): WorkflowTurn | undefined {
|
|
341
392
|
const row = this.state.connection
|
|
342
393
|
.prepare(
|
|
@@ -589,15 +589,10 @@ function wrappedEffect(
|
|
|
589
589
|
project: (context: WorkflowNodeContext) => WorkflowNodeContext,
|
|
590
590
|
): WorkflowManagedEffect | undefined {
|
|
591
591
|
if (node.effect === undefined) return undefined;
|
|
592
|
-
const idempotencyKey = node.effect.idempotencyKey;
|
|
593
592
|
const request = node.effect.request;
|
|
594
593
|
return {
|
|
595
594
|
type: node.effect.type,
|
|
596
595
|
recovery: node.effect.recovery,
|
|
597
|
-
idempotencyKey:
|
|
598
|
-
typeof idempotencyKey === "function"
|
|
599
|
-
? (context: WorkflowNodeContext) => idempotencyKey(project(context))
|
|
600
|
-
: idempotencyKey,
|
|
601
596
|
request:
|
|
602
597
|
typeof request === "function"
|
|
603
598
|
? (context: WorkflowNodeContext) => request(project(context))
|
|
@@ -191,14 +191,6 @@ function workflowEffect(
|
|
|
191
191
|
return {
|
|
192
192
|
type: normalized,
|
|
193
193
|
recovery,
|
|
194
|
-
idempotencyKey: ({ state }: WorkflowNodeContext) => {
|
|
195
|
-
const nodeId = state.currentNode;
|
|
196
|
-
if (nodeId === undefined) {
|
|
197
|
-
throw new Error("Managed effect resolution requires an active workflow node");
|
|
198
|
-
}
|
|
199
|
-
const nodeInvocation = state.steps.filter((step) => step.nodeId === nodeId).length + 1;
|
|
200
|
-
return `${state.runId}:${normalized}:${nodeId}:${nodeInvocation}`;
|
|
201
|
-
},
|
|
202
194
|
request: ({ input, outputs }: WorkflowNodeContext) => ({ input, outputs }),
|
|
203
195
|
};
|
|
204
196
|
}
|
package/src/workflows/engine.ts
CHANGED
|
@@ -1563,17 +1563,15 @@ export class WorkflowEngine {
|
|
|
1563
1563
|
);
|
|
1564
1564
|
}
|
|
1565
1565
|
const effectType = node.effect.type.trim();
|
|
1566
|
-
const
|
|
1567
|
-
|
|
1568
|
-
? await node.effect.idempotencyKey(context)
|
|
1569
|
-
: node.effect.idempotencyKey;
|
|
1566
|
+
const invocation = context.state.steps.filter((step) => step.nodeId === nodeId).length + 1;
|
|
1567
|
+
const idempotencyKey = `${context.state.runId}:${effectType}:${nodeId}:${invocation}`;
|
|
1570
1568
|
const request =
|
|
1571
1569
|
typeof node.effect.request === "function"
|
|
1572
1570
|
? await node.effect.request(context)
|
|
1573
1571
|
: node.effect.request;
|
|
1574
1572
|
/* istanbul ignore if -- definition validation and effect helpers reject empty fields */
|
|
1575
|
-
if (effectType.length === 0
|
|
1576
|
-
throw new Error("Managed effect type
|
|
1573
|
+
if (effectType.length === 0) {
|
|
1574
|
+
throw new Error("Managed effect type must be nonempty text");
|
|
1577
1575
|
}
|
|
1578
1576
|
canonicalJson(request);
|
|
1579
1577
|
const reservation = await this.store.reserveEffect({
|
package/src/workflows/schema.ts
CHANGED
|
@@ -113,12 +113,6 @@ export function assertValidActionNode(node: ActionNodeDefinition, nodeId = "acti
|
|
|
113
113
|
if (node.effect.recovery !== "idempotent" && node.effect.recovery !== "manual") {
|
|
114
114
|
fail(`node ${nodeId} effect recovery must be idempotent or manual`);
|
|
115
115
|
}
|
|
116
|
-
if (
|
|
117
|
-
typeof node.effect.idempotencyKey !== "string" &&
|
|
118
|
-
typeof node.effect.idempotencyKey !== "function"
|
|
119
|
-
) {
|
|
120
|
-
fail(`node ${nodeId} effect idempotencyKey must be text or a function`);
|
|
121
|
-
}
|
|
122
116
|
if (!("request" in node.effect)) {
|
|
123
117
|
fail(`node ${nodeId} effect requires request data or a request function`);
|
|
124
118
|
}
|
package/src/workflows/store.ts
CHANGED
|
@@ -143,6 +143,17 @@ export type WorkflowRunDisplayState = {
|
|
|
143
143
|
error: string | null;
|
|
144
144
|
};
|
|
145
145
|
|
|
146
|
+
export type WorkflowRunTerminalData = {
|
|
147
|
+
runId: string;
|
|
148
|
+
status: "completed" | "failed" | "timed_out" | "cancelled";
|
|
149
|
+
statusDetail: string | null;
|
|
150
|
+
input: JsonValue;
|
|
151
|
+
finalOutput: JsonValue | null;
|
|
152
|
+
error: string | null;
|
|
153
|
+
presentationInstructions: string;
|
|
154
|
+
restartNumber: number;
|
|
155
|
+
};
|
|
156
|
+
|
|
146
157
|
export type WorkflowRunStoreOptions = {
|
|
147
158
|
authorityProvider?: (runId: string) => RunWriteAuthority | undefined;
|
|
148
159
|
/** Host-owned projection updates that must commit with each run snapshot. */
|
|
@@ -2182,6 +2193,59 @@ export class WorkflowRunStore {
|
|
|
2182
2193
|
};
|
|
2183
2194
|
}
|
|
2184
2195
|
|
|
2196
|
+
readRunInput(runId: string): JsonValue | null {
|
|
2197
|
+
const row = this.readRunRow(runId);
|
|
2198
|
+
return row === undefined ? null : this.state.readJson(row.inputHash);
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
readRunFinalOutput(runId: string): JsonValue | null {
|
|
2202
|
+
const row = this.readRunRow(runId);
|
|
2203
|
+
return row?.finalOutputHash == null ? null : this.state.readJson(row.finalOutputHash);
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
readRunError(runId: string): string | null {
|
|
2207
|
+
const row = this.readRunRow(runId);
|
|
2208
|
+
return row?.errorHash == null ? null : this.readText(row.errorHash);
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
readPresentationInstructions(runId: string): string {
|
|
2212
|
+
const row = this.state.connection
|
|
2213
|
+
.prepare("SELECT presentation_prompt_hash AS hash FROM runs WHERE run_id = ?")
|
|
2214
|
+
.get(runId) as { hash?: Buffer | null } | undefined;
|
|
2215
|
+
if (row?.hash == null) {
|
|
2216
|
+
return "Explain the final workflow result to the user in a normal response.";
|
|
2217
|
+
}
|
|
2218
|
+
return this.readText(row.hash);
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
readTerminalData(runId: string): WorkflowRunTerminalData | null {
|
|
2222
|
+
const row = this.state.connection
|
|
2223
|
+
.prepare(
|
|
2224
|
+
`SELECT status, status_detail AS statusDetail, restart_number AS restartNumber
|
|
2225
|
+
FROM runs WHERE run_id = ?`,
|
|
2226
|
+
)
|
|
2227
|
+
.get(runId) as
|
|
2228
|
+
| { status?: unknown; statusDetail?: unknown; restartNumber?: unknown }
|
|
2229
|
+
| undefined;
|
|
2230
|
+
if (
|
|
2231
|
+
row === undefined ||
|
|
2232
|
+
!["completed", "failed", "timed_out", "cancelled"].includes(String(row.status)) ||
|
|
2233
|
+
typeof row.restartNumber !== "number"
|
|
2234
|
+
) {
|
|
2235
|
+
return null;
|
|
2236
|
+
}
|
|
2237
|
+
return {
|
|
2238
|
+
runId,
|
|
2239
|
+
status: row.status as WorkflowRunTerminalData["status"],
|
|
2240
|
+
statusDetail: typeof row.statusDetail === "string" ? row.statusDetail : null,
|
|
2241
|
+
input: this.readRunInput(runId) as JsonValue,
|
|
2242
|
+
finalOutput: this.readRunFinalOutput(runId),
|
|
2243
|
+
error: this.readRunError(runId),
|
|
2244
|
+
presentationInstructions: this.readPresentationInstructions(runId),
|
|
2245
|
+
restartNumber: row.restartNumber,
|
|
2246
|
+
};
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2185
2249
|
readRun(runId: string, options: ReadWorkflowRunOptions = {}): LoadedWorkflowRun | null {
|
|
2186
2250
|
const row = this.readRunRow(runId);
|
|
2187
2251
|
if (row === undefined) return null;
|
package/src/workflows/types.ts
CHANGED
|
@@ -53,9 +53,7 @@ export type WorkflowEffectRecovery = "idempotent" | "manual";
|
|
|
53
53
|
export type WorkflowManagedEffect = {
|
|
54
54
|
/** Stable external effect category, for example `github.comment`. */
|
|
55
55
|
type: string;
|
|
56
|
-
/**
|
|
57
|
-
idempotencyKey: string | ((context: WorkflowNodeContext) => MaybePromise<string>);
|
|
58
|
-
/** Canonical request data used to reject key reuse with another operation. */
|
|
56
|
+
/** Canonical request data used to reject engine-owned key reuse with another operation. */
|
|
59
57
|
request: unknown | ((context: WorkflowNodeContext) => MaybePromise<unknown>);
|
|
60
58
|
/** `manual` never retries after an uncertain child exit. */
|
|
61
59
|
recovery: WorkflowEffectRecovery;
|