@osolmaz/pi-workflows 0.13.4 → 0.14.0

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 (39) hide show
  1. package/README.md +136 -118
  2. package/dist/controllers/index.d.ts +1 -1
  3. package/dist/controllers/index.js.map +1 -1
  4. package/dist/controllers/sqlite.d.ts +34 -31
  5. package/dist/controllers/sqlite.js +116 -77
  6. package/dist/controllers/sqlite.js.map +1 -1
  7. package/dist/extension/index.js +721 -202
  8. package/dist/extension/index.js.map +1 -1
  9. package/dist/extension/restart-policy.d.ts +38 -0
  10. package/dist/extension/restart-policy.js +116 -0
  11. package/dist/extension/restart-policy.js.map +1 -0
  12. package/dist/extension/terminal-decision.d.ts +51 -0
  13. package/dist/extension/terminal-decision.js +110 -0
  14. package/dist/extension/terminal-decision.js.map +1 -0
  15. package/dist/state/prune.js +36 -10
  16. package/dist/state/prune.js.map +1 -1
  17. package/dist/workflows/tool-input.d.ts +4 -0
  18. package/dist/workflows/tool-input.js +6 -1
  19. package/dist/workflows/tool-input.js.map +1 -1
  20. package/docs/2026-08-25-workflow-follow-ups.md +8 -6
  21. package/docs/DEFERRED_TURNS.md +39 -26
  22. package/docs/HUMAN_DECISIONS.md +12 -4
  23. package/docs/SQLITE_STATE.md +24 -0
  24. package/docs/plans/2026-08-19-human-decision-gates-plan.md +34 -8
  25. package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +357 -0
  26. package/docs/workflows.md +85 -29
  27. package/herdr-plugin.toml +1 -1
  28. package/package.json +1 -1
  29. package/skills/autodoc/SKILL.md +1 -1
  30. package/skills/autoimplement/SKILL.md +1 -1
  31. package/skills/autoplan/SKILL.md +1 -1
  32. package/skills/pi-workflows/SKILL.md +2 -0
  33. package/src/controllers/index.ts +3 -0
  34. package/src/controllers/sqlite.ts +226 -155
  35. package/src/extension/index.ts +881 -220
  36. package/src/extension/restart-policy.ts +163 -0
  37. package/src/extension/terminal-decision.ts +172 -0
  38. package/src/state/prune.ts +35 -9
  39. package/src/workflows/tool-input.ts +9 -1
@@ -2,6 +2,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { StateDatabase, workflowStatePath } from "../state/database.js";
5
+ import { canonicalJson } from "../state/json.js";
5
6
  import { resourceIdFor, tokenHash } from "../state/mutation.js";
6
7
  import {
7
8
  EffectRequestConflictError,
@@ -39,6 +40,26 @@ export type WorkflowRunLaunchStatus =
39
40
  | "failed"
40
41
  | "cancelled";
41
42
 
43
+ export type WorkflowRunReservationOptions = {
44
+ runId: string;
45
+ workflowName: string;
46
+ workflowSourceRef: string;
47
+ workflowSource: unknown;
48
+ definitionDigest: string;
49
+ definitionSnapshot: unknown;
50
+ input: unknown;
51
+ launchOptions?: unknown;
52
+ runnerId: string;
53
+ originSessionId: string;
54
+ parentRunId?: string;
55
+ now?: string;
56
+ };
57
+
58
+ export type WorkflowRunClaimOptions = WorkflowRunReservationOptions & {
59
+ claimToken: string;
60
+ leaseMs: number;
61
+ };
62
+
42
63
  export type WorkflowRunQueueRecord = {
43
64
  runId: string;
44
65
  workflowName: string;
@@ -64,6 +85,16 @@ export type WorkflowRunQueueRecord = {
64
85
  finishedAt: string | null;
65
86
  };
66
87
 
88
+ export type WorkflowRunPreparationResult =
89
+ | {
90
+ state: "claimed";
91
+ run: WorkflowRunQueueRecord & { claimToken: string };
92
+ }
93
+ | {
94
+ state: "adopted";
95
+ run: WorkflowRunQueueRecord;
96
+ };
97
+
67
98
  export type WorkflowNotificationRecord = {
68
99
  notificationId: string;
69
100
  runId: string;
@@ -84,7 +115,9 @@ export type WorkflowTurnIntentCause =
84
115
  | "failed"
85
116
  | "launchFailed"
86
117
  | "controllerInterrupted"
87
- | "claimLost";
118
+ | "claimLost"
119
+ | "terminal"
120
+ | "cancelled";
88
121
 
89
122
  export type WorkflowTurnIntentResolution = "workflowPrompt" | "presentation" | "fallback";
90
123
 
@@ -996,139 +1029,147 @@ export class SqliteControllerStore implements ControllerStore {
996
1029
  }));
997
1030
  }
998
1031
 
999
- reserveWorkflowRun(options: {
1000
- runId: string;
1001
- workflowName: string;
1002
- workflowSourceRef: string;
1003
- workflowSource: unknown;
1004
- definitionDigest: string;
1005
- definitionSnapshot: unknown;
1006
- input: unknown;
1007
- launchOptions?: unknown;
1008
- runnerId: string;
1009
- originSessionId: string;
1010
- parentRunId?: string;
1011
- now?: string;
1012
- }): WorkflowRunQueueRecord {
1032
+ reserveWorkflowRun(options: WorkflowRunReservationOptions): WorkflowRunQueueRecord {
1013
1033
  validateRunId(options.runId);
1014
1034
  const now = epoch(validTimestamp(options.now));
1015
1035
  const definitionDigest = digestBuffer(options.definitionDigest);
1016
- return this.state.transaction(() => {
1017
- if (this.getWorkflowRun(options.runId) !== undefined) {
1018
- throw new Error(`Workflow run already reserved: ${options.runId}`);
1036
+ return this.state.transaction(() =>
1037
+ this.reserveWorkflowRunInTransaction(options, now, definitionDigest),
1038
+ );
1039
+ }
1040
+
1041
+ private reserveWorkflowRunInTransaction(
1042
+ options: WorkflowRunReservationOptions,
1043
+ now: number,
1044
+ definitionDigest: Buffer,
1045
+ ): WorkflowRunQueueRecord {
1046
+ if (this.getWorkflowRun(options.runId) !== undefined) {
1047
+ throw new Error(`Workflow run already reserved: ${options.runId}`);
1048
+ }
1049
+ if (options.parentRunId !== undefined) {
1050
+ const parent = this.requireWorkflowRunRow(options.parentRunId);
1051
+ if (parent.originSessionId !== options.originSessionId) {
1052
+ throw new Error("Continuation parent belongs to another Pi session");
1019
1053
  }
1020
- if (options.parentRunId !== undefined) {
1021
- const parent = this.requireWorkflowRunRow(options.parentRunId);
1022
- if (parent.originSessionId !== options.originSessionId) {
1023
- throw new Error("Continuation parent belongs to another Pi session");
1054
+ if (parent.status === "parked") {
1055
+ const parentLease = this.requireLease(parent.resourceId);
1056
+ if (parentLease.ownerId !== null) {
1057
+ throw new Error("Continuation parent still has an active owner");
1024
1058
  }
1025
- if (parent.status === "parked") {
1026
- const parentLease = this.requireLease(parent.resourceId);
1027
- if (parentLease.ownerId !== null) {
1028
- throw new Error("Continuation parent still has an active owner");
1029
- }
1030
- this.state.connection
1031
- .prepare(
1032
- `UPDATE run_queue
1059
+ this.state.connection
1060
+ .prepare(
1061
+ `UPDATE run_queue
1033
1062
  SET status = 'done', updated_at = ?, finished_at = ?
1034
1063
  WHERE run_id = ? AND status = 'parked'`,
1035
- )
1036
- .run(now, now, parent.runId);
1037
- const parentRevision = this.resourceRevision(parent.resourceId);
1038
- this.bumpResource(parent.resourceId, parentRevision, now);
1039
- this.insertEvent(
1040
- parent.resourceId,
1041
- parentRevision + 1,
1042
- "run.queue_done_for_continuation",
1043
- "session",
1044
- options.originSessionId,
1045
- { continuationRunId: options.runId },
1046
- now,
1047
- );
1048
- } else if (parent.status === "done") {
1049
- throw new Error("Continuation parent already has a reserved continuation");
1050
- } else {
1051
- throw new Error(`Continuation parent queue is ${parent.status}`);
1052
- }
1064
+ )
1065
+ .run(now, now, parent.runId);
1066
+ const parentRevision = this.resourceRevision(parent.resourceId);
1067
+ this.bumpResource(parent.resourceId, parentRevision, now);
1068
+ this.insertEvent(
1069
+ parent.resourceId,
1070
+ parentRevision + 1,
1071
+ "run.queue_done_for_continuation",
1072
+ "session",
1073
+ options.originSessionId,
1074
+ { continuationRunId: options.runId },
1075
+ now,
1076
+ );
1077
+ } else if (parent.status === "done") {
1078
+ throw new Error("Continuation parent already has a reserved continuation");
1079
+ } else {
1080
+ throw new Error(`Continuation parent queue is ${parent.status}`);
1053
1081
  }
1054
- const resourceId = resourceIdFor("run", options.runId);
1055
- const definitionHash = this.state.putJson(options.definitionSnapshot, now);
1056
- const queuedSource = queuedWorkflowSource(options.workflowSource);
1057
- const inputHash = this.state.putJson(options.input ?? null, now);
1058
- const launchHash = this.state.putJson(options.launchOptions ?? {}, now);
1059
- this.state.connection
1060
- .prepare(
1061
- `INSERT INTO workflow_definitions(
1082
+ }
1083
+ const resourceId = resourceIdFor("run", options.runId);
1084
+ const definitionHash = this.state.putJson(options.definitionSnapshot, now);
1085
+ const queuedSource = queuedWorkflowSource(options.workflowSource);
1086
+ const inputHash = this.state.putJson(options.input ?? null, now);
1087
+ const launchHash = this.state.putJson(options.launchOptions ?? {}, now);
1088
+ this.state.connection
1089
+ .prepare(
1090
+ `INSERT INTO workflow_definitions(
1062
1091
  definition_digest, workflow_name, definition_hash, created_at
1063
1092
  ) VALUES (?, ?, ?, ?)
1064
1093
  ON CONFLICT(definition_digest) DO NOTHING`,
1065
- )
1066
- .run(definitionDigest, options.workflowName, definitionHash, now);
1067
- this.state.connection
1068
- .prepare(
1069
- `INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at)
1094
+ )
1095
+ .run(definitionDigest, options.workflowName, definitionHash, now);
1096
+ this.state.connection
1097
+ .prepare(
1098
+ `INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at)
1070
1099
  VALUES (?, 'run', ?, 1, ?, ?)`,
1071
- )
1072
- .run(resourceId, options.runId, now, now);
1073
- this.state.connection
1074
- .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
1075
- .run(resourceId);
1076
- this.state.connection
1077
- .prepare(
1078
- `INSERT INTO runs(
1100
+ )
1101
+ .run(resourceId, options.runId, now, now);
1102
+ this.state.connection
1103
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
1104
+ .run(resourceId);
1105
+ this.state.connection
1106
+ .prepare(
1107
+ `INSERT INTO runs(
1079
1108
  run_id, resource_id, project_id, parent_run_id, definition_digest,
1080
1109
  workflow_ref, launch_options_hash, status, paused,
1081
1110
  input_hash, created_at, updated_at
1082
1111
  ) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`,
1083
- )
1084
- .run(
1085
- options.runId,
1086
- resourceId,
1087
- this.requireProjectId(),
1088
- options.parentRunId ?? null,
1089
- definitionDigest,
1090
- options.workflowSourceRef,
1091
- launchHash,
1092
- inputHash,
1093
- now,
1094
- now,
1095
- );
1096
- insertQueuedRunSources(this.state, options.runId, queuedSource);
1097
- this.state.connection
1098
- .prepare(
1099
- `INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
1112
+ )
1113
+ .run(
1114
+ options.runId,
1115
+ resourceId,
1116
+ this.requireProjectId(),
1117
+ options.parentRunId ?? null,
1118
+ definitionDigest,
1119
+ options.workflowSourceRef,
1120
+ launchHash,
1121
+ inputHash,
1122
+ now,
1123
+ now,
1124
+ );
1125
+ insertQueuedRunSources(this.state, options.runId, queuedSource);
1126
+ this.state.connection
1127
+ .prepare(
1128
+ `INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
1100
1129
  VALUES (?, ?, 'interactive', ?)`,
1101
- )
1102
- .run(options.runId, options.originSessionId, now);
1103
- this.state.connection
1104
- .prepare(
1105
- `INSERT INTO run_queue(
1130
+ )
1131
+ .run(options.runId, options.originSessionId, now);
1132
+ this.state.connection
1133
+ .prepare(
1134
+ `INSERT INTO run_queue(
1106
1135
  run_id, status, available_at, affinity_runner_id, origin_session_id,
1107
1136
  consecutive_errors, created_at, updated_at
1108
1137
  ) VALUES (?, 'queued', ?, ?, ?, 0, ?, ?)`,
1109
- )
1110
- .run(options.runId, now, options.runnerId, options.originSessionId, now, now);
1111
- this.insertEvent(resourceId, 1, "run.queued", "session", options.originSessionId, {}, now);
1112
- return this.requireWorkflowRun(options.runId);
1138
+ )
1139
+ .run(options.runId, now, options.runnerId, options.originSessionId, now, now);
1140
+ this.insertEvent(resourceId, 1, "run.queued", "session", options.originSessionId, {}, now);
1141
+ return this.requireWorkflowRun(options.runId);
1142
+ }
1143
+
1144
+ prepareOrAdoptWorkflowRun(options: WorkflowRunClaimOptions): WorkflowRunPreparationResult {
1145
+ validateRunId(options.runId);
1146
+ const now = epoch(validTimestamp(options.now));
1147
+ const definitionDigest = digestBuffer(options.definitionDigest);
1148
+ return this.state.transaction(() => {
1149
+ const existing = this.workflowRunRow(options.runId);
1150
+ if (existing !== undefined) {
1151
+ this.assertWorkflowRunPreparationCompatible(existing, options, definitionDigest);
1152
+ return { state: "adopted", run: this.mapWorkflowRun(existing) };
1153
+ }
1154
+ this.reserveWorkflowRunInTransaction(options, now, definitionDigest);
1155
+ const claimed = this.claimRunInTransaction(
1156
+ options.runId,
1157
+ options.runnerId,
1158
+ options.claimToken,
1159
+ options.leaseMs,
1160
+ now,
1161
+ );
1162
+ if (claimed === undefined) {
1163
+ throw new Error(`Workflow run could not be claimed: ${options.runId}`);
1164
+ }
1165
+ return {
1166
+ state: "claimed",
1167
+ run: claimed as WorkflowRunQueueRecord & { claimToken: string },
1168
+ };
1113
1169
  });
1114
1170
  }
1115
1171
 
1116
- enqueueWorkflowRun(options: {
1117
- runId: string;
1118
- workflowName: string;
1119
- workflowSourceRef: string;
1120
- workflowSource: unknown;
1121
- definitionDigest: string;
1122
- definitionSnapshot: unknown;
1123
- input: unknown;
1124
- launchOptions?: unknown;
1125
- runnerId: string;
1126
- claimToken: string;
1127
- leaseMs: number;
1128
- originSessionId: string;
1129
- parentRunId?: string;
1130
- now?: string;
1131
- }): WorkflowRunQueueRecord {
1172
+ enqueueWorkflowRun(options: WorkflowRunClaimOptions): WorkflowRunQueueRecord {
1132
1173
  if (this.getWorkflowRun(options.runId) === undefined) {
1133
1174
  this.reserveWorkflowRun({
1134
1175
  runId: options.runId,
@@ -2228,6 +2269,27 @@ export class SqliteControllerStore implements ControllerStore {
2228
2269
  return this.mapWorkflowRun(this.requireWorkflowRunRow(runId));
2229
2270
  }
2230
2271
 
2272
+ private assertWorkflowRunPreparationCompatible(
2273
+ row: RunRow,
2274
+ options: WorkflowRunReservationOptions,
2275
+ definitionDigest: Buffer,
2276
+ ): void {
2277
+ const compatible =
2278
+ row.workflowName === options.workflowName &&
2279
+ row.workflowRef === options.workflowSourceRef &&
2280
+ row.definitionDigest.equals(definitionDigest) &&
2281
+ canonicalJson(readQueuedRunSources(this.state, row)) ===
2282
+ canonicalJson(queuedWorkflowSource(options.workflowSource)) &&
2283
+ canonicalJson(this.state.readJson(row.inputHash)) === canonicalJson(options.input ?? null) &&
2284
+ canonicalJson(this.state.readJson(row.launchOptionsHash)) ===
2285
+ canonicalJson(options.launchOptions ?? {}) &&
2286
+ row.originSessionId === options.originSessionId &&
2287
+ row.parentRunId === (options.parentRunId ?? null);
2288
+ if (!compatible) {
2289
+ throw new Error(`Workflow run preparation conflicts: ${options.runId}`);
2290
+ }
2291
+ }
2292
+
2231
2293
  /* istanbul ignore next -- pure projection covered by integration tests */
2232
2294
  private mapWorkflowRun(row: RunRow): WorkflowRunQueueRecord {
2233
2295
  return {
@@ -2267,56 +2329,65 @@ export class SqliteControllerStore implements ControllerStore {
2267
2329
  leaseMs: number,
2268
2330
  now: number,
2269
2331
  ): WorkflowRunQueueRecord | undefined {
2270
- return this.state.transaction(() => {
2271
- const row = this.workflowRunRow(runId);
2272
- if (row === undefined || ["done", "failed", "cancelled"].includes(row.status))
2273
- return undefined;
2274
- const lease = this.requireLease(row.resourceId);
2275
- if (
2276
- lease.ownerId !== null &&
2277
- lease.expiresAt !== null &&
2278
- lease.expiresAt > now &&
2279
- lease.ownerId !== runnerId
2280
- )
2281
- return undefined;
2282
- const generation = lease.generation + 1;
2283
- const expiresAt = now + leaseMs;
2284
- const result = this.state.connection
2285
- .prepare(
2286
- `UPDATE leases SET generation = ?, owner_type = ?, owner_id = ?, token_hash = ?,
2332
+ return this.state.transaction(() =>
2333
+ this.claimRunInTransaction(runId, runnerId, claimToken, leaseMs, now),
2334
+ );
2335
+ }
2336
+
2337
+ private claimRunInTransaction(
2338
+ runId: string,
2339
+ runnerId: string,
2340
+ claimToken: string,
2341
+ leaseMs: number,
2342
+ now: number,
2343
+ ): WorkflowRunQueueRecord | undefined {
2344
+ const row = this.workflowRunRow(runId);
2345
+ if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) return undefined;
2346
+ const lease = this.requireLease(row.resourceId);
2347
+ if (
2348
+ lease.ownerId !== null &&
2349
+ lease.expiresAt !== null &&
2350
+ lease.expiresAt > now &&
2351
+ lease.ownerId !== runnerId
2352
+ )
2353
+ return undefined;
2354
+ const generation = lease.generation + 1;
2355
+ const expiresAt = now + leaseMs;
2356
+ const result = this.state.connection
2357
+ .prepare(
2358
+ `UPDATE leases SET generation = ?, owner_type = ?, owner_id = ?, token_hash = ?,
2287
2359
  acquired_at = ?, heartbeat_at = ?, expires_at = ?
2288
2360
  WHERE resource_id = ? AND generation = ?`,
2289
- )
2290
- .run(
2291
- generation,
2292
- runnerId.startsWith("host-") ? "host" : "session",
2293
- runnerId,
2294
- tokenHash(claimToken),
2295
- now,
2296
- now,
2297
- expiresAt,
2298
- row.resourceId,
2299
- lease.generation,
2300
- );
2301
- /* istanbul ignore if -- impossible after exact schema and transaction checks */
2302
- if (result.changes !== 1) return undefined;
2303
- this.state.connection
2304
- .prepare("UPDATE run_queue SET status = 'starting', updated_at = ? WHERE run_id = ?")
2305
- .run(now, runId);
2306
- const revision = this.resourceRevision(row.resourceId);
2307
- this.bumpResource(row.resourceId, revision, now);
2308
- this.insertEvent(
2309
- row.resourceId,
2310
- revision + 1,
2311
- "lease.claimed",
2361
+ )
2362
+ .run(
2363
+ generation,
2312
2364
  runnerId.startsWith("host-") ? "host" : "session",
2313
2365
  runnerId,
2314
- { expiresAt },
2366
+ tokenHash(claimToken),
2315
2367
  now,
2316
- generation,
2368
+ now,
2369
+ expiresAt,
2370
+ row.resourceId,
2371
+ lease.generation,
2317
2372
  );
2318
- return { ...this.requireWorkflowRun(runId), claimToken };
2319
- });
2373
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
2374
+ if (result.changes !== 1) return undefined;
2375
+ this.state.connection
2376
+ .prepare("UPDATE run_queue SET status = 'starting', updated_at = ? WHERE run_id = ?")
2377
+ .run(now, runId);
2378
+ const revision = this.resourceRevision(row.resourceId);
2379
+ this.bumpResource(row.resourceId, revision, now);
2380
+ this.insertEvent(
2381
+ row.resourceId,
2382
+ revision + 1,
2383
+ "lease.claimed",
2384
+ runnerId.startsWith("host-") ? "host" : "session",
2385
+ runnerId,
2386
+ { expiresAt },
2387
+ now,
2388
+ generation,
2389
+ );
2390
+ return { ...this.requireWorkflowRun(runId), claimToken };
2320
2391
  }
2321
2392
 
2322
2393
  private updateClaimedRunStatus(