@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.
- package/LICENSE +201 -0
- package/README.md +752 -0
- package/agents/step.md +17 -0
- package/dist/index.js +4576 -0
- package/examples/mr-comments.workflow.yaml +115 -0
- package/examples/prompts/mr-comments/implement.md +8 -0
- package/examples/prompts/mr-comments/inspect.md +5 -0
- package/examples/prompts/mr-comments/plan.md +13 -0
- package/examples/prompts/mr-comments/verify.md +7 -0
- package/examples/settings.yaml +19 -0
- package/package.json +81 -0
- package/schemas/settings.schema.json +22 -0
- package/schemas/workflow.schema.json +585 -0
- package/src/command-names.ts +46 -0
- package/src/commands.ts +80 -0
- package/src/config/ceiling.ts +153 -0
- package/src/config/command-conflicts.ts +31 -0
- package/src/config/load.ts +327 -0
- package/src/config/types.ts +187 -0
- package/src/config/validate.ts +1145 -0
- package/src/digest.ts +23 -0
- package/src/engine/checkpoint.ts +30 -0
- package/src/engine/resume.ts +44 -0
- package/src/engine/state.ts +186 -0
- package/src/engine/transitions.ts +426 -0
- package/src/harness.ts +1676 -0
- package/src/index.ts +15 -0
- package/src/integrations/plannotator.ts +235 -0
- package/src/integrations/prompt-gate.ts +54 -0
- package/src/integrations/subagents/child-runtime.ts +306 -0
- package/src/integrations/subagents/client.ts +239 -0
- package/src/integrations/subagents/protocol.ts +304 -0
- package/src/policy/approved-commands.ts +225 -0
- package/src/policy/bash.ts +355 -0
- package/src/policy/completion-batch.ts +36 -0
- package/src/policy/immutable-input.ts +18 -0
- package/src/policy/tools.ts +150 -0
- package/src/preflight.ts +76 -0
- package/src/prompt.ts +146 -0
- package/src/runtime/completion-tool.ts +22 -0
- package/src/runtime/main-step-runtime.ts +227 -0
- package/src/runtime/serial-task-queue.ts +17 -0
- package/src/runtime/step-result.ts +85 -0
- package/src/workflow-list.ts +25 -0
- 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
|
+
}
|