@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.
Files changed (57) hide show
  1. package/dist/client/view.d.ts +6 -0
  2. package/dist/client/view.js +1 -0
  3. package/dist/client/view.js.map +1 -1
  4. package/dist/controllers/sqlite.d.ts +8 -0
  5. package/dist/controllers/sqlite.js +54 -1
  6. package/dist/controllers/sqlite.js.map +1 -1
  7. package/dist/extension/index.js +34 -15
  8. package/dist/extension/index.js.map +1 -1
  9. package/dist/extension/workflow-message-coordinator.d.ts +3 -2
  10. package/dist/extension/workflow-message-coordinator.js +78 -26
  11. package/dist/extension/workflow-message-coordinator.js.map +1 -1
  12. package/dist/host/runner.d.ts +8 -1
  13. package/dist/host/runner.js +231 -176
  14. package/dist/host/runner.js.map +1 -1
  15. package/dist/host/view.js +6 -2
  16. package/dist/host/view.js.map +1 -1
  17. package/dist/host/worker-entry.d.ts +4 -1
  18. package/dist/host/worker-entry.js +23 -24
  19. package/dist/host/worker-entry.js.map +1 -1
  20. package/dist/host/worker-protocol.d.ts +15 -0
  21. package/dist/host/worker-protocol.js.map +1 -1
  22. package/dist/state/workflow-messages.d.ts +2 -0
  23. package/dist/state/workflow-messages.js +31 -0
  24. package/dist/state/workflow-messages.js.map +1 -1
  25. package/dist/workflows/composition.js +0 -4
  26. package/dist/workflows/composition.js.map +1 -1
  27. package/dist/workflows/definition.js +0 -8
  28. package/dist/workflows/definition.js.map +1 -1
  29. package/dist/workflows/engine.js +4 -5
  30. package/dist/workflows/engine.js.map +1 -1
  31. package/dist/workflows/schema.js +0 -4
  32. package/dist/workflows/schema.js.map +1 -1
  33. package/dist/workflows/store.d.ts +15 -0
  34. package/dist/workflows/store.js +42 -0
  35. package/dist/workflows/store.js.map +1 -1
  36. package/dist/workflows/types.d.ts +1 -3
  37. package/docs/2026-09-04-workflow-run-state-plan.md +363 -0
  38. package/docs/SQLITE_STATE.md +8 -4
  39. package/docs/WORKFLOW_HOST.md +14 -5
  40. package/docs/workflows.md +10 -1
  41. package/herdr-plugin.toml +1 -1
  42. package/package.json +1 -1
  43. package/src/client/view.ts +8 -0
  44. package/src/controllers/sqlite.ts +83 -1
  45. package/src/extension/index.ts +37 -19
  46. package/src/extension/workflow-message-coordinator.ts +91 -27
  47. package/src/host/runner.ts +277 -228
  48. package/src/host/view.ts +6 -2
  49. package/src/host/worker-entry.ts +38 -30
  50. package/src/host/worker-protocol.ts +11 -0
  51. package/src/state/workflow-messages.ts +51 -0
  52. package/src/workflows/composition.ts +0 -5
  53. package/src/workflows/definition.ts +0 -8
  54. package/src/workflows/engine.ts +4 -6
  55. package/src/workflows/schema.ts +0 -6
  56. package/src/workflows/store.ts +64 -0
  57. 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' AND m.kind IN ('step', 'terminal') LIMIT 1`,
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 = facts.queueStatus === "parked" ? "The workflow is ready to resume." : null;
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") {
@@ -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
- initialized: boolean;
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 = bootstrap.initialized
371
- ? await engine.resumeRun(workflow, launch.runId, {
372
- workflowSource: workflowSource.root,
373
- ...(bootstrap.resumeInteractionAttemptId === undefined
374
- ? {}
375
- : { resumeInteractionAttemptId: bootstrap.resumeInteractionAttemptId }),
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
  }
@@ -1563,17 +1563,15 @@ export class WorkflowEngine {
1563
1563
  );
1564
1564
  }
1565
1565
  const effectType = node.effect.type.trim();
1566
- const idempotencyKey =
1567
- typeof node.effect.idempotencyKey === "function"
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 || idempotencyKey.trim().length === 0) {
1576
- throw new Error("Managed effect type and idempotency key must be nonempty text");
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({
@@ -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
  }
@@ -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;
@@ -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
- /** Stable key. Idempotent adapters must pass this key to the external system. */
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;