@wichayutdew/pi-workflows 0.1.1

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 (45) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +752 -0
  3. package/agents/step.md +17 -0
  4. package/dist/index.js +4576 -0
  5. package/examples/mr-comments.workflow.yaml +115 -0
  6. package/examples/prompts/mr-comments/implement.md +8 -0
  7. package/examples/prompts/mr-comments/inspect.md +5 -0
  8. package/examples/prompts/mr-comments/plan.md +13 -0
  9. package/examples/prompts/mr-comments/verify.md +7 -0
  10. package/examples/settings.yaml +19 -0
  11. package/package.json +81 -0
  12. package/schemas/settings.schema.json +22 -0
  13. package/schemas/workflow.schema.json +585 -0
  14. package/src/command-names.ts +46 -0
  15. package/src/commands.ts +80 -0
  16. package/src/config/ceiling.ts +153 -0
  17. package/src/config/command-conflicts.ts +31 -0
  18. package/src/config/load.ts +327 -0
  19. package/src/config/types.ts +187 -0
  20. package/src/config/validate.ts +1145 -0
  21. package/src/digest.ts +23 -0
  22. package/src/engine/checkpoint.ts +30 -0
  23. package/src/engine/resume.ts +44 -0
  24. package/src/engine/state.ts +186 -0
  25. package/src/engine/transitions.ts +426 -0
  26. package/src/harness.ts +1676 -0
  27. package/src/index.ts +15 -0
  28. package/src/integrations/plannotator.ts +235 -0
  29. package/src/integrations/prompt-gate.ts +54 -0
  30. package/src/integrations/subagents/child-runtime.ts +306 -0
  31. package/src/integrations/subagents/client.ts +239 -0
  32. package/src/integrations/subagents/protocol.ts +304 -0
  33. package/src/policy/approved-commands.ts +225 -0
  34. package/src/policy/bash.ts +355 -0
  35. package/src/policy/completion-batch.ts +36 -0
  36. package/src/policy/immutable-input.ts +18 -0
  37. package/src/policy/tools.ts +150 -0
  38. package/src/preflight.ts +76 -0
  39. package/src/prompt.ts +146 -0
  40. package/src/runtime/completion-tool.ts +22 -0
  41. package/src/runtime/main-step-runtime.ts +227 -0
  42. package/src/runtime/serial-task-queue.ts +17 -0
  43. package/src/runtime/step-result.ts +85 -0
  44. package/src/workflow-list.ts +25 -0
  45. package/src/workflow-status.ts +611 -0
package/src/digest.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ function canonicalize(value: unknown): unknown {
4
+ if (Array.isArray(value)) {
5
+ return value.map(canonicalize);
6
+ }
7
+
8
+ if (value !== null && typeof value === 'object') {
9
+ return Object.fromEntries(
10
+ Object.entries(value as Record<string, unknown>)
11
+ .sort(([left], [right]) => left.localeCompare(right))
12
+ .map(([key, child]) => [key, canonicalize(child)]),
13
+ );
14
+ }
15
+
16
+ return value;
17
+ }
18
+
19
+ export function digest(value: unknown): string {
20
+ return createHash('sha256')
21
+ .update(JSON.stringify(canonicalize(value)))
22
+ .digest('hex');
23
+ }
@@ -0,0 +1,30 @@
1
+ import { isWorkflowRun, type WorkflowRun } from './state.ts';
2
+
3
+ export type CheckpointResult =
4
+ | { status: 'none' }
5
+ | { status: 'invalid' }
6
+ | { status: 'valid'; run: WorkflowRun };
7
+
8
+ interface SessionEntryLike {
9
+ type?: unknown;
10
+ customType?: unknown;
11
+ data?: unknown;
12
+ }
13
+
14
+ /**
15
+ * Only the newest entry for this checkpoint type is authoritative. Falling
16
+ * back past corrupt or newer-version state could repeat already-finished work.
17
+ */
18
+ export function readLatestCheckpoint(
19
+ entries: readonly SessionEntryLike[],
20
+ customType: string,
21
+ ): CheckpointResult {
22
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
23
+ const entry = entries[index];
24
+ if (entry?.type !== 'custom' || entry.customType !== customType) continue;
25
+ return isWorkflowRun(entry.data)
26
+ ? { status: 'valid', run: structuredClone(entry.data) }
27
+ : { status: 'invalid' };
28
+ }
29
+ return { status: 'none' };
30
+ }
@@ -0,0 +1,44 @@
1
+ import type { WorkflowRun } from './state.ts';
2
+
3
+ export interface ResumeCheckpoint {
4
+ sessionEpoch: number;
5
+ runId: string;
6
+ workflowId: string;
7
+ currentStepId: string;
8
+ reviewId?: string;
9
+ }
10
+
11
+ export function captureResumeCheckpoint(
12
+ run: WorkflowRun,
13
+ sessionEpoch: number,
14
+ ): ResumeCheckpoint {
15
+ return {
16
+ sessionEpoch,
17
+ runId: run.runId,
18
+ workflowId: run.workflowId,
19
+ currentStepId: run.currentStepId,
20
+ ...(run.pendingGate?.reviewId
21
+ ? { reviewId: run.pendingGate.reviewId }
22
+ : {}),
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Async resume work may overlap an abort, a session-tree switch, or a gate
28
+ * result. A gate result may update the same paused checkpoint and is safe to
29
+ * merge; a different session, run, step, review, or status is not.
30
+ */
31
+ export function matchesResumeCheckpoint(
32
+ run: WorkflowRun | undefined,
33
+ sessionEpoch: number,
34
+ checkpoint: ResumeCheckpoint,
35
+ ): run is WorkflowRun {
36
+ return (
37
+ sessionEpoch === checkpoint.sessionEpoch &&
38
+ run?.status === 'paused' &&
39
+ run.runId === checkpoint.runId &&
40
+ run.workflowId === checkpoint.workflowId &&
41
+ run.currentStepId === checkpoint.currentStepId &&
42
+ run.pendingGate?.reviewId === checkpoint.reviewId
43
+ );
44
+ }
@@ -0,0 +1,186 @@
1
+ import type { LoadedWorkflow } from '../config/types.ts';
2
+
3
+ export const RUN_STATE_VERSION = 1 as const;
4
+
5
+ export type WorkflowRunStatus =
6
+ 'running' | 'paused' | 'awaiting-gate' | 'completed' | 'aborted';
7
+
8
+ export interface StepHistoryEntry {
9
+ stepId: string;
10
+ stepDigest: string;
11
+ outcome: string;
12
+ summary: string;
13
+ completedAt: number;
14
+ }
15
+
16
+ export interface GateResolution {
17
+ approved: boolean;
18
+ feedback: string;
19
+ resolvedAt: number;
20
+ }
21
+
22
+ export interface PendingGate {
23
+ provider: 'prompt' | 'plannotator';
24
+ requestId: string;
25
+ stepId: string;
26
+ artifact: string;
27
+ /** Legacy v1 field. New runs use the reviewed artifact as the handoff. */
28
+ summary?: string | undefined;
29
+ submittedOutcome: string;
30
+ requestedAt: number;
31
+ reviewId?: string | undefined;
32
+ resolution?: GateResolution | undefined;
33
+ }
34
+
35
+ export interface WorkflowRun {
36
+ stateVersion: typeof RUN_STATE_VERSION;
37
+ runId: string;
38
+ workflowId: string;
39
+ workflowDigest: string;
40
+ input: string;
41
+ status: WorkflowRunStatus;
42
+ currentStepId: string;
43
+ currentStepDigest: string;
44
+ baselineTools: string[];
45
+ visits: Record<string, number>;
46
+ history: StepHistoryEntry[];
47
+ startedAt: number;
48
+ updatedAt: number;
49
+ /**
50
+ * Most recent human-approved gate artifact. This is the only provenance
51
+ * source from which reviewed Bash capabilities may be derived.
52
+ */
53
+ reviewedArtifact?: string | undefined;
54
+ /**
55
+ * Input inherited from the previous completed step. Unlike `lastSummary`,
56
+ * this survives a paused attempt of the current step.
57
+ */
58
+ stepHandoff?: string | undefined;
59
+ lastSummary: string;
60
+ gateFeedback: string;
61
+ pauseReason?: string | undefined;
62
+ pausedFrom?: 'running' | 'awaiting-gate' | undefined;
63
+ pendingGate?: PendingGate | undefined;
64
+ }
65
+
66
+ export function createRun(
67
+ workflow: LoadedWorkflow,
68
+ input: string,
69
+ baselineTools: string[],
70
+ runId: string,
71
+ now: number,
72
+ ): WorkflowRun {
73
+ const start = workflow.definition.start;
74
+ return {
75
+ stateVersion: RUN_STATE_VERSION,
76
+ runId,
77
+ workflowId: workflow.definition.id,
78
+ workflowDigest: workflow.digest,
79
+ input,
80
+ status: 'running',
81
+ currentStepId: start,
82
+ currentStepDigest: workflow.stepDigests[start] ?? '',
83
+ baselineTools: [...new Set(baselineTools)],
84
+ visits: { [start]: 1 },
85
+ history: [],
86
+ startedAt: now,
87
+ updatedAt: now,
88
+ reviewedArtifact: '',
89
+ stepHandoff: '',
90
+ lastSummary: '',
91
+ gateFeedback: '',
92
+ };
93
+ }
94
+
95
+ export function isWorkflowRun(value: unknown): value is WorkflowRun {
96
+ if (value === null || typeof value !== 'object') return false;
97
+ const run = value as Partial<WorkflowRun>;
98
+ const historyIsValid =
99
+ Array.isArray(run.history) &&
100
+ run.history.every(
101
+ (entry) =>
102
+ entry !== null &&
103
+ typeof entry === 'object' &&
104
+ typeof entry.stepId === 'string' &&
105
+ typeof entry.stepDigest === 'string' &&
106
+ typeof entry.outcome === 'string' &&
107
+ typeof entry.summary === 'string' &&
108
+ typeof entry.completedAt === 'number',
109
+ );
110
+ const visitsAreValid =
111
+ run.visits !== null &&
112
+ typeof run.visits === 'object' &&
113
+ !Array.isArray(run.visits) &&
114
+ Object.values(run.visits).every(
115
+ (count) => Number.isInteger(count) && count >= 0,
116
+ );
117
+ const gateIsValid =
118
+ run.pendingGate === undefined ||
119
+ (run.pendingGate !== null &&
120
+ typeof run.pendingGate === 'object' &&
121
+ (run.pendingGate.provider === 'prompt' ||
122
+ run.pendingGate.provider === 'plannotator') &&
123
+ typeof run.pendingGate.requestId === 'string' &&
124
+ run.pendingGate.requestId.length > 0 &&
125
+ typeof run.pendingGate.stepId === 'string' &&
126
+ typeof run.pendingGate.artifact === 'string' &&
127
+ (run.pendingGate.summary === undefined ||
128
+ typeof run.pendingGate.summary === 'string') &&
129
+ typeof run.pendingGate.submittedOutcome === 'string' &&
130
+ typeof run.pendingGate.requestedAt === 'number' &&
131
+ (run.pendingGate.reviewId === undefined ||
132
+ typeof run.pendingGate.reviewId === 'string') &&
133
+ (run.pendingGate.resolution === undefined ||
134
+ (run.pendingGate.resolution !== null &&
135
+ typeof run.pendingGate.resolution === 'object' &&
136
+ typeof run.pendingGate.resolution.approved === 'boolean' &&
137
+ typeof run.pendingGate.resolution.feedback === 'string' &&
138
+ typeof run.pendingGate.resolution.resolvedAt === 'number')));
139
+ const optionalsAreValid =
140
+ (run.reviewedArtifact === undefined ||
141
+ typeof run.reviewedArtifact === 'string') &&
142
+ (run.stepHandoff === undefined || typeof run.stepHandoff === 'string') &&
143
+ (run.pauseReason === undefined || typeof run.pauseReason === 'string') &&
144
+ (run.pausedFrom === undefined ||
145
+ run.pausedFrom === 'running' ||
146
+ run.pausedFrom === 'awaiting-gate');
147
+ const statusIsValid =
148
+ run.status === 'running' ||
149
+ run.status === 'paused' ||
150
+ run.status === 'awaiting-gate' ||
151
+ run.status === 'completed' ||
152
+ run.status === 'aborted';
153
+ const pauseStateIsValid =
154
+ run.status === 'paused'
155
+ ? run.pausedFrom === 'running' || run.pausedFrom === 'awaiting-gate'
156
+ : run.pausedFrom === undefined;
157
+ const gateStateIsValid = !gateIsValid
158
+ ? false
159
+ : run.pendingGate === undefined
160
+ ? run.status !== 'awaiting-gate' && run.pausedFrom !== 'awaiting-gate'
161
+ : run.pendingGate.stepId === run.currentStepId &&
162
+ (run.status === 'awaiting-gate' ||
163
+ (run.status === 'paused' && run.pausedFrom === 'awaiting-gate'));
164
+ return (
165
+ run.stateVersion === RUN_STATE_VERSION &&
166
+ typeof run.runId === 'string' &&
167
+ typeof run.workflowId === 'string' &&
168
+ typeof run.workflowDigest === 'string' &&
169
+ typeof run.input === 'string' &&
170
+ typeof run.currentStepId === 'string' &&
171
+ typeof run.currentStepDigest === 'string' &&
172
+ Array.isArray(run.baselineTools) &&
173
+ run.baselineTools.every((tool) => typeof tool === 'string') &&
174
+ historyIsValid &&
175
+ visitsAreValid &&
176
+ gateIsValid &&
177
+ optionalsAreValid &&
178
+ statusIsValid &&
179
+ pauseStateIsValid &&
180
+ gateStateIsValid &&
181
+ typeof run.startedAt === 'number' &&
182
+ typeof run.updatedAt === 'number' &&
183
+ typeof run.lastSummary === 'string' &&
184
+ typeof run.gateFeedback === 'string'
185
+ );
186
+ }
@@ -0,0 +1,426 @@
1
+ import type { LoadedWorkflow } from '../config/types.ts';
2
+ import type { GateResolution, StepHistoryEntry, WorkflowRun } from './state.ts';
3
+
4
+ export interface ReconcileResult {
5
+ run?: WorkflowRun;
6
+ changed: boolean;
7
+ restartedStep?: string;
8
+ error?: string;
9
+ }
10
+
11
+ function currentStep(workflow: LoadedWorkflow, run: WorkflowRun) {
12
+ return workflow.definition.steps[run.currentStepId];
13
+ }
14
+
15
+ function withUpdate(
16
+ run: WorkflowRun,
17
+ changes: Partial<WorkflowRun>,
18
+ now: number,
19
+ ): WorkflowRun {
20
+ return { ...run, ...changes, updatedAt: now };
21
+ }
22
+
23
+ export function allowedOutcomes(
24
+ workflow: LoadedWorkflow,
25
+ run: WorkflowRun,
26
+ ): string[] {
27
+ const step = currentStep(workflow, run);
28
+ if (!step) return [];
29
+ const gateResolutionOutcomes = step.gate
30
+ ? new Set([step.gate.approvedOutcome, step.gate.rejectedOutcome])
31
+ : undefined;
32
+ return [
33
+ ...Object.keys(step.transitions).filter(
34
+ (outcome) => !gateResolutionOutcomes?.has(outcome),
35
+ ),
36
+ ...(step.gate ? [step.gate.submitOutcome] : []),
37
+ ];
38
+ }
39
+
40
+ export function pauseRun(
41
+ run: WorkflowRun,
42
+ reason: string,
43
+ now: number,
44
+ ): WorkflowRun {
45
+ if (run.status !== 'running' && run.status !== 'awaiting-gate') {
46
+ return withUpdate(run, { pauseReason: reason || run.pauseReason }, now);
47
+ }
48
+ return withUpdate(
49
+ run,
50
+ {
51
+ status: 'paused',
52
+ pausedFrom: run.status,
53
+ pauseReason: reason || `Paused during step "${run.currentStepId}"`,
54
+ },
55
+ now,
56
+ );
57
+ }
58
+
59
+ export function resumeRun(run: WorkflowRun, now: number): WorkflowRun {
60
+ if (run.status !== 'paused') return run;
61
+ return withUpdate(
62
+ run,
63
+ {
64
+ status: run.pausedFrom ?? (run.pendingGate ? 'awaiting-gate' : 'running'),
65
+ pauseReason: undefined,
66
+ pausedFrom: undefined,
67
+ },
68
+ now,
69
+ );
70
+ }
71
+
72
+ export function abortRun(
73
+ run: WorkflowRun,
74
+ reason: string,
75
+ now: number,
76
+ ): WorkflowRun {
77
+ return withUpdate(
78
+ run,
79
+ {
80
+ status: 'aborted',
81
+ pauseReason: reason || 'Aborted by user',
82
+ pausedFrom: undefined,
83
+ pendingGate: undefined,
84
+ },
85
+ now,
86
+ );
87
+ }
88
+
89
+ export function advanceRun(
90
+ workflow: LoadedWorkflow,
91
+ run: WorkflowRun,
92
+ outcome: string,
93
+ summary: string,
94
+ now: number,
95
+ ): WorkflowRun {
96
+ if (run.status !== 'running') {
97
+ throw new Error(
98
+ `workflow is ${run.status}; only a running workflow can advance`,
99
+ );
100
+ }
101
+ const step = currentStep(workflow, run);
102
+ if (!step)
103
+ throw new Error(`current step "${run.currentStepId}" no longer exists`);
104
+ if (step.gate?.submitOutcome === outcome) {
105
+ throw new Error(
106
+ `outcome "${outcome}" must be submitted through the configured gate`,
107
+ );
108
+ }
109
+
110
+ const target = step.transitions[outcome];
111
+ if (!target) {
112
+ throw new Error(
113
+ `outcome "${outcome}" is not valid for step "${run.currentStepId}"`,
114
+ );
115
+ }
116
+ if (target === '$pause') {
117
+ return withUpdate(
118
+ run,
119
+ {
120
+ status: 'paused',
121
+ pausedFrom: 'running',
122
+ pauseReason: summary || `Step "${run.currentStepId}" requested a pause`,
123
+ lastSummary: summary,
124
+ },
125
+ now,
126
+ );
127
+ }
128
+
129
+ const completed: StepHistoryEntry = {
130
+ stepId: run.currentStepId,
131
+ stepDigest: run.currentStepDigest,
132
+ outcome,
133
+ summary,
134
+ completedAt: now,
135
+ };
136
+ if (target === '$done') {
137
+ return withUpdate(
138
+ run,
139
+ {
140
+ status: 'completed',
141
+ history: [...run.history, completed],
142
+ stepHandoff: summary,
143
+ lastSummary: summary,
144
+ gateFeedback: '',
145
+ pausedFrom: undefined,
146
+ pendingGate: undefined,
147
+ },
148
+ now,
149
+ );
150
+ }
151
+
152
+ const nextStep = workflow.definition.steps[target];
153
+ if (!nextStep)
154
+ throw new Error(`transition target "${target}" does not exist`);
155
+ const nextVisitCount = (run.visits[target] ?? 0) + 1;
156
+ const visits = { ...run.visits, [target]: nextVisitCount };
157
+ const overVisitLimit = nextVisitCount > workflow.definition.maxStepVisits;
158
+ return withUpdate(
159
+ run,
160
+ {
161
+ status: overVisitLimit ? 'paused' : 'running',
162
+ currentStepId: target,
163
+ currentStepDigest: workflow.stepDigests[target] ?? '',
164
+ visits,
165
+ history: [...run.history, completed],
166
+ stepHandoff: summary,
167
+ lastSummary: summary,
168
+ gateFeedback: '',
169
+ ...(overVisitLimit
170
+ ? {
171
+ pausedFrom: 'running' as const,
172
+ pauseReason: `Step "${target}" exceeded maxStepVisits (${workflow.definition.maxStepVisits})`,
173
+ }
174
+ : { pausedFrom: undefined, pauseReason: undefined }),
175
+ },
176
+ now,
177
+ );
178
+ }
179
+
180
+ export function beginGate(
181
+ workflow: LoadedWorkflow,
182
+ run: WorkflowRun,
183
+ outcome: string,
184
+ artifact: string,
185
+ requestId: string,
186
+ now: number,
187
+ ): WorkflowRun {
188
+ if (run.status !== 'running') {
189
+ throw new Error(
190
+ `workflow is ${run.status}; gate submission requires a running workflow`,
191
+ );
192
+ }
193
+ const step = currentStep(workflow, run);
194
+ if (!step?.gate) throw new Error(`step "${run.currentStepId}" has no gate`);
195
+ if (outcome !== step.gate.submitOutcome) {
196
+ throw new Error(`gate expects outcome "${step.gate.submitOutcome}"`);
197
+ }
198
+ if (!artifact.trim())
199
+ throw new Error('gate submission requires a non-empty artifact');
200
+ if (!requestId) throw new Error('gate submission requires a request id');
201
+
202
+ return withUpdate(
203
+ run,
204
+ {
205
+ status: 'awaiting-gate',
206
+ pendingGate: {
207
+ provider: step.gate.provider,
208
+ requestId,
209
+ stepId: run.currentStepId,
210
+ artifact,
211
+ submittedOutcome: outcome,
212
+ requestedAt: now,
213
+ },
214
+ },
215
+ now,
216
+ );
217
+ }
218
+
219
+ export function attachGateReviewId(
220
+ run: WorkflowRun,
221
+ reviewId: string,
222
+ now: number,
223
+ ): WorkflowRun {
224
+ if (!run.pendingGate) throw new Error('workflow has no pending gate');
225
+ if (run.pendingGate.provider !== 'plannotator') {
226
+ throw new Error('only a Plannotator gate can have a review id');
227
+ }
228
+ return withUpdate(
229
+ run,
230
+ { pendingGate: { ...run.pendingGate, reviewId } },
231
+ now,
232
+ );
233
+ }
234
+
235
+ export function failGate(
236
+ run: WorkflowRun,
237
+ reason: string,
238
+ now: number,
239
+ ): WorkflowRun {
240
+ if (!run.pendingGate) return run;
241
+ return withUpdate(
242
+ run,
243
+ {
244
+ status: 'running',
245
+ pendingGate: undefined,
246
+ gateFeedback: reason,
247
+ },
248
+ now,
249
+ );
250
+ }
251
+
252
+ export function storeGateResolution(
253
+ run: WorkflowRun,
254
+ resolution: GateResolution,
255
+ now: number,
256
+ ): WorkflowRun {
257
+ if (!run.pendingGate) return run;
258
+ return withUpdate(
259
+ run,
260
+ { pendingGate: { ...run.pendingGate, resolution } },
261
+ now,
262
+ );
263
+ }
264
+
265
+ export function resolveGate(
266
+ workflow: LoadedWorkflow,
267
+ run: WorkflowRun,
268
+ resolution: GateResolution,
269
+ now: number,
270
+ ): WorkflowRun {
271
+ const pending = run.pendingGate;
272
+ if (!pending) throw new Error('workflow has no pending gate');
273
+ const step = workflow.definition.steps[pending.stepId];
274
+ if (!step?.gate)
275
+ throw new Error(`gated step "${pending.stepId}" no longer exists`);
276
+ if (run.currentStepId !== pending.stepId) {
277
+ throw new Error('gate result does not match the current step');
278
+ }
279
+
280
+ const outcome = resolution.approved
281
+ ? step.gate.approvedOutcome
282
+ : step.gate.rejectedOutcome;
283
+ const summary = resolution.approved
284
+ ? pending.artifact
285
+ : resolution.feedback
286
+ ? `Gate ${resolution.approved ? 'approved' : 'rejected'}: ${resolution.feedback}`
287
+ : `Gate ${resolution.approved ? 'approved' : 'rejected'}`;
288
+ const runnable = withUpdate(
289
+ run,
290
+ {
291
+ status: 'running',
292
+ pendingGate: undefined,
293
+ ...(resolution.approved ? { reviewedArtifact: pending.artifact } : {}),
294
+ pausedFrom: undefined,
295
+ pauseReason: undefined,
296
+ gateFeedback: resolution.feedback,
297
+ },
298
+ now,
299
+ );
300
+ const advanced = advanceRun(workflow, runnable, outcome, summary, now);
301
+ return {
302
+ ...advanced,
303
+ gateFeedback: resolution.feedback,
304
+ };
305
+ }
306
+
307
+ function rebuildVisits(
308
+ history: readonly StepHistoryEntry[],
309
+ currentStepId: string,
310
+ ): Record<string, number> {
311
+ const visits: Record<string, number> = {};
312
+ for (const entry of history) {
313
+ visits[entry.stepId] = (visits[entry.stepId] ?? 0) + 1;
314
+ }
315
+ visits[currentStepId] = (visits[currentStepId] ?? 0) + 1;
316
+ return visits;
317
+ }
318
+
319
+ function retainedReviewedArtifact(
320
+ workflow: LoadedWorkflow,
321
+ run: WorkflowRun,
322
+ history: readonly StepHistoryEntry[],
323
+ ): string {
324
+ const reviewedArtifact = run.reviewedArtifact ?? '';
325
+ if (!reviewedArtifact) return '';
326
+ const sourceRetained = history.some((entry) => {
327
+ const gate = workflow.definition.steps[entry.stepId]?.gate;
328
+ return (
329
+ gate !== undefined &&
330
+ entry.outcome === gate.approvedOutcome &&
331
+ entry.summary === reviewedArtifact
332
+ );
333
+ });
334
+ return sourceRetained ? reviewedArtifact : '';
335
+ }
336
+
337
+ export function reconcileRun(
338
+ run: WorkflowRun,
339
+ workflow: LoadedWorkflow,
340
+ now: number,
341
+ ): ReconcileResult {
342
+ if (run.workflowId !== workflow.definition.id) {
343
+ return {
344
+ changed: false,
345
+ error: `run belongs to "${run.workflowId}", not "${workflow.definition.id}"`,
346
+ };
347
+ }
348
+ if (run.workflowDigest === workflow.digest) {
349
+ return { run, changed: false };
350
+ }
351
+ if (!workflow.definition.steps[run.currentStepId]) {
352
+ return {
353
+ changed: true,
354
+ error: `current step "${run.currentStepId}" was removed; abort or restore the configuration`,
355
+ };
356
+ }
357
+
358
+ const changedHistoryIndex = run.history.findIndex(
359
+ (entry) => workflow.stepDigests[entry.stepId] !== entry.stepDigest,
360
+ );
361
+ if (changedHistoryIndex >= 0) {
362
+ const changedEntry = run.history[changedHistoryIndex];
363
+ if (!changedEntry || !workflow.definition.steps[changedEntry.stepId]) {
364
+ return {
365
+ changed: true,
366
+ error:
367
+ 'a completed step was removed; abort or restore the configuration',
368
+ };
369
+ }
370
+ const retainedHistory = run.history.slice(0, changedHistoryIndex);
371
+ const restartedStep = changedEntry.stepId;
372
+ const stepHandoff = retainedHistory.at(-1)?.summary ?? '';
373
+ const reviewedArtifact = retainedReviewedArtifact(
374
+ workflow,
375
+ run,
376
+ retainedHistory,
377
+ );
378
+ return {
379
+ changed: true,
380
+ restartedStep,
381
+ run: withUpdate(
382
+ run,
383
+ {
384
+ workflowDigest: workflow.digest,
385
+ status: 'paused',
386
+ currentStepId: restartedStep,
387
+ currentStepDigest: workflow.stepDigests[restartedStep] ?? '',
388
+ history: retainedHistory,
389
+ visits: rebuildVisits(retainedHistory, restartedStep),
390
+ reviewedArtifact,
391
+ stepHandoff,
392
+ lastSummary: stepHandoff,
393
+ pendingGate: undefined,
394
+ pausedFrom: 'running',
395
+ pauseReason: `Configuration changed; restarted step "${restartedStep}"`,
396
+ gateFeedback: '',
397
+ },
398
+ now,
399
+ ),
400
+ };
401
+ }
402
+
403
+ const currentDigest = workflow.stepDigests[run.currentStepId] ?? '';
404
+ const currentChanged = currentDigest !== run.currentStepDigest;
405
+ return {
406
+ changed: true,
407
+ ...(currentChanged ? { restartedStep: run.currentStepId } : {}),
408
+ run: withUpdate(
409
+ run,
410
+ {
411
+ workflowDigest: workflow.digest,
412
+ currentStepDigest: currentDigest,
413
+ ...(currentChanged
414
+ ? {
415
+ status: 'paused' as const,
416
+ pendingGate: undefined,
417
+ pausedFrom: 'running' as const,
418
+ pauseReason: `Configuration changed; restarted step "${run.currentStepId}"`,
419
+ gateFeedback: '',
420
+ }
421
+ : {}),
422
+ },
423
+ now,
424
+ ),
425
+ };
426
+ }