@narumitw/pi-subagents 0.52.0 → 0.53.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.
@@ -0,0 +1,636 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { isDeepStrictEqual } from "node:util";
5
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
6
+ import type { AgentConfig } from "./agents.js";
7
+ import {
8
+ parseAutomationRequest,
9
+ parseWorkflowPlan,
10
+ type WorkflowPlan,
11
+ type WorkflowPlanPatch,
12
+ type WorkflowPlanTask,
13
+ workflowPlanIdentity,
14
+ } from "./automation-contract.js";
15
+ import { redactPrivateText } from "./context.js";
16
+ import type { TargetPolicyAudit } from "./cwd-policy.js";
17
+ import { rotateExecutionPlanGeneration } from "./execution-plan.js";
18
+ import {
19
+ WorkItemLedger,
20
+ type WorkItemLedgerSnapshot,
21
+ type WorkItemRecord,
22
+ } from "./work-item-ledger.js";
23
+ import { type CompiledWorkflowPlan, compileWorkflowPlan } from "./workflow-plan-compiler.js";
24
+
25
+ export const WORKFLOW_PLAN_STATE_VERSION = "pi-subagents:workflow-plan-state:v1" as const;
26
+ const MAX_STATE_BYTES = 1024 * 1024;
27
+
28
+ export interface WorkflowPlanHistoryEntry {
29
+ planId: string;
30
+ workflowGeneration: number;
31
+ revision: number;
32
+ acceptedTaskIds: string[];
33
+ acceptedArtifactIds: string[];
34
+ verificationReceiptTaskIds: string[];
35
+ }
36
+
37
+ export interface WorkflowPlanRecord {
38
+ version: typeof WORKFLOW_PLAN_STATE_VERSION;
39
+ planId: string;
40
+ workflowGeneration: number;
41
+ revision: number;
42
+ maxRevisions: number;
43
+ request: CompiledWorkflowPlan["request"];
44
+ plan: WorkflowPlan;
45
+ cancelledTaskIds: string[];
46
+ invalidatedTaskIds: string[];
47
+ history: WorkflowPlanHistoryEntry[];
48
+ }
49
+
50
+ export interface ApplyWorkflowPlanPatchInput {
51
+ record: WorkflowPlanRecord;
52
+ ledger: WorkItemLedgerSnapshot;
53
+ patch: WorkflowPlanPatch;
54
+ agents: readonly AgentConfig[];
55
+ target: TargetPolicyAudit;
56
+ }
57
+
58
+ export interface AppliedWorkflowPlanPatch {
59
+ record: WorkflowPlanRecord;
60
+ ledger: WorkItemLedgerSnapshot;
61
+ compiled?: CompiledWorkflowPlan;
62
+ taskGenerations: Record<string, number>;
63
+ replayedTaskIds: string[];
64
+ }
65
+
66
+ export interface PersistedAutomationPlan {
67
+ record: WorkflowPlanRecord;
68
+ ledger: WorkItemLedgerSnapshot;
69
+ }
70
+
71
+ export function createWorkflowPlanRecord(compiled: CompiledWorkflowPlan): WorkflowPlanRecord {
72
+ return {
73
+ version: WORKFLOW_PLAN_STATE_VERSION,
74
+ planId: compiled.planId,
75
+ workflowGeneration: compiled.workflowGeneration,
76
+ revision: compiled.revision,
77
+ maxRevisions: compiled.request.aggregateBudget.maxRevisions,
78
+ request: structuredClone(compiled.request),
79
+ plan: structuredClone(compiled.plan),
80
+ cancelledTaskIds: [],
81
+ invalidatedTaskIds: [],
82
+ history: [],
83
+ };
84
+ }
85
+
86
+ export function applyWorkflowPlanPatch(
87
+ input: ApplyWorkflowPlanPatchInput,
88
+ ): AppliedWorkflowPlanPatch {
89
+ validateRecord(input.record);
90
+ const ledger = WorkItemLedger.restore(input.ledger).snapshot();
91
+ if (input.patch.planId !== input.record.planId) {
92
+ throw new Error("Workflow plan patch has a stale or forged plan identity");
93
+ }
94
+ if (input.patch.workflowGeneration !== input.record.workflowGeneration) {
95
+ throw new Error("Workflow plan patch has a stale or forged workflow generation");
96
+ }
97
+ if (input.record.revision >= input.record.maxRevisions) {
98
+ throw new Error("Workflow plan revision limit is exhausted");
99
+ }
100
+ if (
101
+ ledger.items.some((item) => item.state === "running" || item.state === "awaiting-verification")
102
+ ) {
103
+ throw new Error("Workflow plan patches require a settled workflow snapshot");
104
+ }
105
+ const byState = new Map(ledger.items.map((item) => [item.id, item]));
106
+ const tasks = input.record.plan.tasks.map((task) => structuredClone(task));
107
+ const cancelled = new Set(input.record.cancelledTaskIds);
108
+ const invalidated = new Set(input.record.invalidatedTaskIds);
109
+ const modified = new Set<string>();
110
+ const acceptedArtifactIds = new Set(
111
+ ledger.items
112
+ .filter((item) => item.state === "completed" || item.verificationAccepted)
113
+ .flatMap((item) => item.artifacts.map((artifact) => artifact.id)),
114
+ );
115
+
116
+ for (const operation of input.patch.operations) {
117
+ if (operation.type === "add-task") {
118
+ if (tasks.some((task) => task.id === operation.task.id)) {
119
+ throw new Error(`Workflow patch cannot add duplicate task ${operation.task.id}`);
120
+ }
121
+ assertNoAcceptedArtifactForgery(operation.task, acceptedArtifactIds);
122
+ tasks.push(structuredClone(operation.task));
123
+ modified.add(operation.task.id);
124
+ continue;
125
+ }
126
+ const targetId = operation.taskId;
127
+ if (cancelled.has(targetId)) {
128
+ throw new Error(`Workflow patch cannot revive cancelled task ${targetId}`);
129
+ }
130
+ const taskIndex = tasks.findIndex((task) => task.id === targetId);
131
+ if (taskIndex < 0) throw new Error(`Workflow patch targets unknown task ${targetId}`);
132
+ const state = byState.get(targetId);
133
+ if (!state || !isPatchEligible(state)) {
134
+ throw new Error(`Workflow task ${targetId} is immutable while ${state?.state ?? "missing"}`);
135
+ }
136
+ if (operation.type === "replace-task") {
137
+ if (operation.task.id !== targetId) {
138
+ throw new Error("Workflow replacement must preserve the executor-owned task id");
139
+ }
140
+ assertNoAcceptedArtifactForgery(operation.task, acceptedArtifactIds);
141
+ tasks[taskIndex] = structuredClone(operation.task);
142
+ modified.add(targetId);
143
+ continue;
144
+ }
145
+ if (operation.type === "add-dependency") {
146
+ if (!tasks.some((task) => task.id === operation.dependsOn)) {
147
+ throw new Error(`Workflow patch dependency ${operation.dependsOn} is missing`);
148
+ }
149
+ const current = tasks[taskIndex];
150
+ if (!current.dependsOn.includes(operation.dependsOn)) {
151
+ current.dependsOn.push(operation.dependsOn);
152
+ }
153
+ modified.add(targetId);
154
+ continue;
155
+ }
156
+ if (operation.type === "cancel-task") {
157
+ const current = tasks[taskIndex];
158
+ if (
159
+ current.verifierFor &&
160
+ tasks.some(
161
+ (task) =>
162
+ task.id === current.verifierFor &&
163
+ task.sideEffectPolicy === "mutating" &&
164
+ !cancelled.has(task.id),
165
+ )
166
+ ) {
167
+ throw new Error(`Workflow patch cannot remove required verification ${targetId}`);
168
+ }
169
+ for (const affected of downstreamTaskIds(tasks, targetId, true)) {
170
+ const affectedState = byState.get(affected);
171
+ if (affectedState && !isPatchEligible(affectedState)) {
172
+ throw new Error(`Workflow cancellation would rewrite immutable task ${affected}`);
173
+ }
174
+ cancelled.add(affected);
175
+ modified.add(affected);
176
+ }
177
+ continue;
178
+ }
179
+ if (operation.type === "request-verification") {
180
+ if (tasks.some((task) => task.verifierFor === targetId && !cancelled.has(task.id))) {
181
+ throw new Error(`Workflow task ${targetId} already has required verification`);
182
+ }
183
+ if (
184
+ operation.verifier.verifierFor !== targetId ||
185
+ operation.verifier.dependsOn.length !== 1 ||
186
+ operation.verifier.dependsOn[0] !== targetId
187
+ ) {
188
+ throw new Error("Workflow verification patch must add one direct verifier");
189
+ }
190
+ tasks.push(structuredClone(operation.verifier));
191
+ modified.add(operation.verifier.id);
192
+ continue;
193
+ }
194
+ if (operation.type === "invalidate-downstream") {
195
+ for (const affected of downstreamTaskIds(tasks, targetId, false)) {
196
+ const affectedState = byState.get(affected);
197
+ if (affectedState && !isPatchEligible(affectedState)) {
198
+ throw new Error(`Workflow invalidation would rewrite immutable task ${affected}`);
199
+ }
200
+ invalidated.add(affected);
201
+ modified.add(affected);
202
+ }
203
+ }
204
+ }
205
+
206
+ const candidatePlan = parseWorkflowPlan({ ...input.record.plan, tasks });
207
+ const activeTasks = candidatePlan.tasks.filter((task) => !cancelled.has(task.id));
208
+ let baseCompiled: CompiledWorkflowPlan | undefined;
209
+ if (activeTasks.length > 0) {
210
+ const activePlan = parseWorkflowPlan({ ...candidatePlan, tasks: activeTasks });
211
+ const result = compileWorkflowPlan({
212
+ request: input.record.request,
213
+ proposal: activePlan,
214
+ agents: input.agents,
215
+ target: input.target,
216
+ depth: 0,
217
+ });
218
+ if (result.status !== "compiled") {
219
+ throw new Error(
220
+ `Workflow patch rejected by compiler: ${result.reasonCodes.join(", ") || result.status}`,
221
+ );
222
+ }
223
+ baseCompiled = result;
224
+ }
225
+ const normalizedPlan = mergeCompiledActivePlan(
226
+ candidatePlan,
227
+ baseCompiled,
228
+ cancelled,
229
+ modified,
230
+ byState,
231
+ );
232
+ const nextGeneration = input.record.workflowGeneration + 1;
233
+ const nextRevision = input.record.revision + 1;
234
+ const historyEntry = captureHistory(input.record, ledger);
235
+ const nextPlanId = revisionIdentity(
236
+ input.record.planId,
237
+ normalizedPlan,
238
+ nextGeneration,
239
+ nextRevision,
240
+ cancelled,
241
+ invalidated,
242
+ );
243
+ const nextLedger = buildPatchedLedger(
244
+ normalizedPlan,
245
+ ledger,
246
+ baseCompiled,
247
+ modified,
248
+ cancelled,
249
+ invalidated,
250
+ input.patch.reason,
251
+ );
252
+ const taskGenerations = Object.fromEntries(
253
+ nextLedger.items.map((item) => [item.id, item.taskGeneration]),
254
+ );
255
+ const compiled = baseCompiled
256
+ ? rotateCompiledPlan(baseCompiled, nextPlanId, nextGeneration, nextRevision, taskGenerations)
257
+ : undefined;
258
+ return {
259
+ record: {
260
+ ...input.record,
261
+ planId: nextPlanId,
262
+ workflowGeneration: nextGeneration,
263
+ revision: nextRevision,
264
+ plan: normalizedPlan,
265
+ cancelledTaskIds: [...cancelled].sort(),
266
+ invalidatedTaskIds: [...invalidated].sort(),
267
+ history: [...input.record.history, historyEntry],
268
+ },
269
+ ledger: nextLedger,
270
+ ...(compiled ? { compiled } : {}),
271
+ taskGenerations,
272
+ replayedTaskIds: [],
273
+ };
274
+ }
275
+
276
+ function mergeCompiledActivePlan(
277
+ candidate: WorkflowPlan,
278
+ compiled: CompiledWorkflowPlan | undefined,
279
+ cancelled: ReadonlySet<string>,
280
+ modified: Set<string>,
281
+ byState: ReadonlyMap<string, WorkItemRecord>,
282
+ ): WorkflowPlan {
283
+ if (!compiled) return candidate;
284
+ const compiledById = new Map(compiled.plan.tasks.map((task) => [task.id, task]));
285
+ const candidateIds = new Set(candidate.tasks.map((task) => task.id));
286
+ const tasks = candidate.tasks.map((task) => {
287
+ if (cancelled.has(task.id)) return task;
288
+ const normalized = compiledById.get(task.id);
289
+ if (!normalized) {
290
+ throw new Error(`Compiled patch omitted active workflow task ${task.id}`);
291
+ }
292
+ if (!isDeepStrictEqual(normalized, task)) {
293
+ const state = byState.get(task.id);
294
+ if (state && !isPatchEligible(state)) {
295
+ throw new Error(`Workflow normalization would rewrite immutable task ${task.id}`);
296
+ }
297
+ modified.add(task.id);
298
+ }
299
+ return normalized;
300
+ });
301
+ for (const task of compiled.plan.tasks) {
302
+ if (!candidateIds.has(task.id)) tasks.push(task);
303
+ }
304
+ return parseWorkflowPlan({ ...candidate, tasks });
305
+ }
306
+
307
+ function rotateCompiledPlan(
308
+ compiled: CompiledWorkflowPlan,
309
+ planId: string,
310
+ workflowGeneration: number,
311
+ revision: number,
312
+ taskGenerations: Readonly<Record<string, number>>,
313
+ ): CompiledWorkflowPlan {
314
+ const executionPlans = compiled.executionPlans.map((plan) => {
315
+ const taskId = plan.taskId;
316
+ const targetGeneration = taskId ? taskGenerations[taskId] : undefined;
317
+ if (!taskId || !targetGeneration || targetGeneration < plan.taskGeneration) {
318
+ throw new Error(`Patched workflow has an invalid task generation for ${taskId ?? "unknown"}`);
319
+ }
320
+ let rotated = plan;
321
+ while (rotated.taskGeneration < targetGeneration) {
322
+ rotated = rotateExecutionPlanGeneration(rotated);
323
+ }
324
+ return rotated;
325
+ });
326
+ return {
327
+ ...compiled,
328
+ planId,
329
+ workflowGeneration,
330
+ revision,
331
+ workflow: {
332
+ ...compiled.workflow,
333
+ id: `auto-${planId.slice(0, 24)}`,
334
+ },
335
+ executionPlans,
336
+ };
337
+ }
338
+
339
+ function buildPatchedLedger(
340
+ plan: WorkflowPlan,
341
+ previous: WorkItemLedgerSnapshot,
342
+ compiled: CompiledWorkflowPlan | undefined,
343
+ modified: ReadonlySet<string>,
344
+ cancelled: ReadonlySet<string>,
345
+ invalidated: ReadonlySet<string>,
346
+ reason: string,
347
+ ): WorkItemLedgerSnapshot {
348
+ const previousById = new Map(previous.items.map((item) => [item.id, item]));
349
+ const compiledById = new Map((compiled?.workflow.tasks ?? []).map((task) => [task.id, task]));
350
+ const fresh = WorkItemLedger.create({
351
+ workflowId: previous.workflowId,
352
+ items: plan.tasks.map((task) => ({
353
+ id: task.id,
354
+ objective: task.objective,
355
+ dependencies: [...task.dependsOn],
356
+ inputArtifacts: [...task.inputArtifacts],
357
+ inputArtifactVersions: Object.fromEntries(
358
+ task.inputArtifacts.flatMap((artifactId) => {
359
+ const artifact = plan.tasks
360
+ .flatMap((candidate) => candidate.producesArtifacts)
361
+ .find((candidate) => candidate.id === artifactId);
362
+ return artifact ? [[artifact.id, artifact.version]] : [];
363
+ }),
364
+ ),
365
+ requiredCapabilities: [...task.requiredCapabilities],
366
+ requiredTools: [...task.requiredTools],
367
+ selectedAgentName:
368
+ compiledById.get(task.id)?.agent ?? previousById.get(task.id)?.selectedAgentName,
369
+ sideEffectPolicy: task.sideEffectPolicy,
370
+ readPaths: [...task.readPaths],
371
+ writePaths: [...task.writePaths],
372
+ ownershipKeys: [...task.ownershipKeys],
373
+ acceptanceCriteria: [...task.acceptanceCriteria],
374
+ integrationOwner: cancelled.has(task.id) ? false : task.integrationOwner,
375
+ verifierFor: task.verifierFor,
376
+ })),
377
+ }).snapshot();
378
+ let generation = previous.generation;
379
+ for (const item of fresh.items) {
380
+ const stored = previousById.get(item.id);
381
+ if (stored && !modified.has(item.id)) {
382
+ const dependencies = item.dependencies;
383
+ const dependents = item.dependents;
384
+ Object.assign(item, structuredClone(stored), { dependencies, dependents });
385
+ continue;
386
+ }
387
+ item.taskGeneration = stored ? stored.taskGeneration + 1 : 1;
388
+ item.state = cancelled.has(item.id) || invalidated.has(item.id) ? "invalidated" : "pending";
389
+ item.assignedAgentId = undefined;
390
+ item.acceptedExecutionPlanId = undefined;
391
+ item.artifactHistory = [
392
+ ...(stored?.artifactHistory ?? []).map((artifact) => structuredClone(artifact)),
393
+ ...(stored?.artifacts ?? []).map((artifact) => structuredClone(artifact)),
394
+ ];
395
+ item.artifacts = [];
396
+ item.inputArtifactVersions = {};
397
+ item.verificationAccepted = false;
398
+ item.stagedTreeIdentity = undefined;
399
+ item.verificationReceipt = undefined;
400
+ item.invalidationReasons = [...(stored?.invalidationReasons ?? []), `${item.id}:${reason}`];
401
+ item.outcomeReason = item.state === "invalidated" ? reason : undefined;
402
+ item.generation = ++generation;
403
+ }
404
+ fresh.generation = generation;
405
+ return WorkItemLedger.restore(fresh).snapshot();
406
+ }
407
+
408
+ export class AutomationPlanPersistence {
409
+ constructor(readonly filePath: string) {}
410
+
411
+ async save(value: PersistedAutomationPlan): Promise<void> {
412
+ validateRecord(value.record);
413
+ WorkItemLedger.restore(value.ledger);
414
+ const filePath = path.resolve(this.filePath);
415
+ const sanitized = sanitizePersisted(value);
416
+ const content = `${JSON.stringify(sanitized)}\n`;
417
+ if (Buffer.byteLength(content, "utf8") > MAX_STATE_BYTES) {
418
+ throw new Error("Automation workflow state exceeds the persistence size limit");
419
+ }
420
+ await withFileMutationQueue(filePath, async () => {
421
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
422
+ const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
423
+ try {
424
+ await fs.promises.writeFile(temporary, content, { mode: 0o600 });
425
+ await fs.promises.rename(temporary, filePath);
426
+ } finally {
427
+ await fs.promises.rm(temporary, { force: true });
428
+ }
429
+ });
430
+ }
431
+
432
+ load(): PersistedAutomationPlan | undefined {
433
+ const filePath = path.resolve(this.filePath);
434
+ let source: string;
435
+ try {
436
+ const stat = fs.statSync(filePath);
437
+ if (stat.size > MAX_STATE_BYTES) throw new Error("automation workflow state exceeds limit");
438
+ source = fs.readFileSync(filePath, "utf8");
439
+ } catch (error) {
440
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
441
+ throw error;
442
+ }
443
+ try {
444
+ const decoded = JSON.parse(source) as PersistedAutomationPlan;
445
+ validateRecord(decoded.record);
446
+ const ledger = WorkItemLedger.restore(decoded.ledger).snapshot();
447
+ return { record: structuredClone(decoded.record), ledger };
448
+ } catch {
449
+ try {
450
+ fs.renameSync(filePath, `${filePath}.invalid-${Date.now()}`);
451
+ } catch {
452
+ // A concurrent owner may already have quarantined the invalid record.
453
+ }
454
+ return undefined;
455
+ }
456
+ }
457
+ }
458
+
459
+ function validateRecord(record: WorkflowPlanRecord): void {
460
+ if (!record || record.version !== WORKFLOW_PLAN_STATE_VERSION) {
461
+ throw new Error("Unsupported automation workflow state");
462
+ }
463
+ if (
464
+ !Number.isSafeInteger(record.workflowGeneration) ||
465
+ record.workflowGeneration < 0 ||
466
+ !Number.isSafeInteger(record.revision) ||
467
+ record.revision < 0 ||
468
+ !Number.isSafeInteger(record.maxRevisions) ||
469
+ record.maxRevisions < 0 ||
470
+ record.revision > record.maxRevisions
471
+ ) {
472
+ throw new Error("Automation workflow state has an invalid generation or revision");
473
+ }
474
+ const request = parseAutomationRequest(record.request);
475
+ const plan = parseWorkflowPlan(record.plan);
476
+ if (record.maxRevisions !== request.aggregateBudget.maxRevisions) {
477
+ throw new Error("Automation workflow state has an invalid revision ceiling");
478
+ }
479
+ if (!Array.isArray(record.cancelledTaskIds) || !Array.isArray(record.invalidatedTaskIds)) {
480
+ throw new Error("Automation workflow state has invalid task state lists");
481
+ }
482
+ const taskIds = new Set(plan.tasks.map((task) => task.id));
483
+ for (const list of [record.cancelledTaskIds, record.invalidatedTaskIds]) {
484
+ if (new Set(list).size !== list.length || list.some((id) => !taskIds.has(id))) {
485
+ throw new Error("Automation workflow state has invalid task state identities");
486
+ }
487
+ }
488
+ if (!Array.isArray(record.history) || record.history.length !== record.revision) {
489
+ throw new Error("Automation workflow state has invalid history");
490
+ }
491
+ for (const [index, entry] of record.history.entries()) validateHistoryEntry(entry, index);
492
+ const expected =
493
+ record.revision === 0
494
+ ? workflowPlanIdentity(record.plan, record.workflowGeneration, record.revision)
495
+ : revisionIdentity(
496
+ record.history.at(-1)?.planId ?? "",
497
+ record.plan,
498
+ record.workflowGeneration,
499
+ record.revision,
500
+ new Set(record.cancelledTaskIds),
501
+ new Set(record.invalidatedTaskIds),
502
+ );
503
+ if (record.planId !== expected)
504
+ throw new Error("Automation workflow state has a forged identity");
505
+ }
506
+
507
+ function validateHistoryEntry(entry: WorkflowPlanHistoryEntry, index: number): void {
508
+ if (
509
+ !entry ||
510
+ !/^[a-f0-9]{64}$/u.test(entry.planId) ||
511
+ !Number.isSafeInteger(entry.workflowGeneration) ||
512
+ entry.workflowGeneration < 0 ||
513
+ !Number.isSafeInteger(entry.revision) ||
514
+ entry.revision !== index ||
515
+ !Array.isArray(entry.acceptedTaskIds) ||
516
+ !Array.isArray(entry.acceptedArtifactIds) ||
517
+ !Array.isArray(entry.verificationReceiptTaskIds)
518
+ ) {
519
+ throw new Error("Automation workflow state has malformed accepted history");
520
+ }
521
+ for (const list of [
522
+ entry.acceptedTaskIds,
523
+ entry.acceptedArtifactIds,
524
+ entry.verificationReceiptTaskIds,
525
+ ]) {
526
+ if (
527
+ list.length > 64 ||
528
+ new Set(list).size !== list.length ||
529
+ list.some((value) => typeof value !== "string" || !value || value.length > 256)
530
+ ) {
531
+ throw new Error("Automation workflow state has malformed accepted history identities");
532
+ }
533
+ }
534
+ }
535
+
536
+ function captureHistory(
537
+ record: WorkflowPlanRecord,
538
+ ledger: WorkItemLedgerSnapshot,
539
+ ): WorkflowPlanHistoryEntry {
540
+ const accepted = ledger.items.filter((item) => item.state === "completed");
541
+ return {
542
+ planId: record.planId,
543
+ workflowGeneration: record.workflowGeneration,
544
+ revision: record.revision,
545
+ acceptedTaskIds: accepted.map((item) => item.id).sort(),
546
+ acceptedArtifactIds: accepted
547
+ .flatMap((item) => item.artifacts.map((artifact) => artifact.id))
548
+ .sort(),
549
+ verificationReceiptTaskIds: accepted
550
+ .filter((item) => item.verificationReceipt)
551
+ .map((item) => item.id)
552
+ .sort(),
553
+ };
554
+ }
555
+
556
+ function revisionIdentity(
557
+ previousPlanId: string,
558
+ plan: WorkflowPlan,
559
+ workflowGeneration: number,
560
+ revision: number,
561
+ cancelled: ReadonlySet<string>,
562
+ invalidated: ReadonlySet<string>,
563
+ ): string {
564
+ return createHash("sha256")
565
+ .update(
566
+ JSON.stringify({
567
+ previousPlanId,
568
+ plan,
569
+ workflowGeneration,
570
+ revision,
571
+ cancelledTaskIds: [...cancelled].sort(),
572
+ invalidatedTaskIds: [...invalidated].sort(),
573
+ }),
574
+ )
575
+ .digest("hex");
576
+ }
577
+
578
+ function isPatchEligible(item: WorkItemRecord): boolean {
579
+ return (
580
+ ["pending", "ready", "needs-input", "stale", "invalidated"].includes(item.state) ||
581
+ (item.state === "blocked" && item.outcomeReason === "verification-rework")
582
+ );
583
+ }
584
+
585
+ function downstreamTaskIds(
586
+ tasks: readonly WorkflowPlanTask[],
587
+ rootId: string,
588
+ includeRoot: boolean,
589
+ ): string[] {
590
+ const result: string[] = includeRoot ? [rootId] : [];
591
+ const queue = [rootId];
592
+ const seen = new Set(queue);
593
+ while (queue.length > 0) {
594
+ const current = queue.shift();
595
+ if (!current) continue;
596
+ for (const task of tasks) {
597
+ if (!task.dependsOn.includes(current) || seen.has(task.id)) continue;
598
+ seen.add(task.id);
599
+ result.push(task.id);
600
+ queue.push(task.id);
601
+ }
602
+ }
603
+ return result;
604
+ }
605
+
606
+ function assertNoAcceptedArtifactForgery(
607
+ task: WorkflowPlanTask,
608
+ acceptedArtifactIds: ReadonlySet<string>,
609
+ ): void {
610
+ const forged = task.producesArtifacts.find((artifact) => acceptedArtifactIds.has(artifact.id));
611
+ if (forged) throw new Error(`Workflow patch cannot forge accepted artifact ${forged.id}`);
612
+ }
613
+
614
+ function sanitizePersisted(value: PersistedAutomationPlan): PersistedAutomationPlan {
615
+ const clone = structuredClone(value);
616
+ const visit = (candidate: unknown): void => {
617
+ if (Array.isArray(candidate)) {
618
+ for (let index = 0; index < candidate.length; index++) {
619
+ if (typeof candidate[index] === "string") {
620
+ candidate[index] = redactPrivateText(candidate[index] as string).trim();
621
+ } else visit(candidate[index]);
622
+ }
623
+ return;
624
+ }
625
+ if (!candidate || typeof candidate !== "object") return;
626
+ for (const [key, item] of Object.entries(candidate as Record<string, unknown>)) {
627
+ if (typeof item === "string") {
628
+ (candidate as Record<string, unknown>)[key] = redactPrivateText(item).trim();
629
+ } else visit(item);
630
+ }
631
+ };
632
+ visit(clone);
633
+ validateRecord(clone.record);
634
+ WorkItemLedger.restore(clone.ledger);
635
+ return clone;
636
+ }