@mingchuno/agent-workflows 0.3.0 → 0.5.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.
- package/README.md +25 -14
- package/dist/src/adapters/agents.js +9 -0
- package/dist/src/cli-config.d.ts +2 -0
- package/dist/src/cli-config.js +35 -0
- package/dist/src/cli.js +22 -24
- package/dist/src/config.d.ts +10 -0
- package/dist/src/config.js +9 -11
- package/dist/src/domain.d.ts +6 -0
- package/dist/src/invocation.d.ts +2 -4
- package/dist/src/invocation.js +204 -107
- package/dist/src/operations.d.ts +1 -2
- package/dist/src/operations.js +18 -33
- package/dist/src/recovery.d.ts +20 -2
- package/dist/src/recovery.js +49 -1
- package/dist/src/runner.d.ts +6 -2
- package/dist/src/runner.js +81 -83
- package/dist/src/runtime/process.d.ts +1 -0
- package/dist/src/runtime/process.js +6 -2
- package/dist/src/store.d.ts +4 -2
- package/dist/src/store.js +69 -51
- package/dist/src/tui/data.d.ts +1 -1
- package/dist/src/tui/dialogs.js +1 -0
- package/dist/src/tui/monitor-navigation.d.ts +76 -0
- package/dist/src/tui/monitor-navigation.js +187 -0
- package/dist/src/tui/monitor.js +70 -183
- package/dist/src/tui/projection.d.ts +22 -0
- package/dist/src/tui/projection.js +49 -0
- package/dist/src/validation-selection.d.ts +6 -0
- package/dist/src/validation-selection.js +29 -0
- package/dist/src/workspace.js +3 -2
- package/docs/api.md +11 -7
- package/docs/configuration.md +61 -18
- package/docs/database.md +2 -18
- package/docs/operations.md +21 -28
- package/docs/providers.md +2 -2
- package/package.json +1 -1
- package/dist/src/tui/actions.d.ts +0 -16
- package/dist/src/tui/actions.js +0 -23
- package/docs/architecture.md +0 -41
package/dist/src/invocation.js
CHANGED
|
@@ -8,8 +8,46 @@ import { verifyEvidence } from "./evidence.js";
|
|
|
8
8
|
import { resolveStagePrompt, sha256 } from "./prompts.js";
|
|
9
9
|
const maxInvocationAttempts = 2;
|
|
10
10
|
/** One logical stage; only returned format errors admit a second response attempt. */
|
|
11
|
-
export async function invokeStage(
|
|
12
|
-
const {
|
|
11
|
+
export async function invokeStage(execution) {
|
|
12
|
+
const { run, name, stepId, dependencies } = execution;
|
|
13
|
+
const directory = join(dependencies.artifacts, run.id);
|
|
14
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
15
|
+
const path = join(directory, `stage-${name}-${stepId}.log`);
|
|
16
|
+
const executionId = run.executions?.at(-1)?.id ?? run.id;
|
|
17
|
+
await dependencies.store.patchRun(run.id, {
|
|
18
|
+
stageLogs: [
|
|
19
|
+
...(run.stageLogs ?? []).filter((item) => item.executionId !== executionId || item.step !== name),
|
|
20
|
+
{ executionId, step: name, path },
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
const note = (message) => appendFile(path, `${new Date().toISOString()} ${dependencies.redact(message)}\n`, { mode: 0o600 });
|
|
24
|
+
try {
|
|
25
|
+
await note(`Preparing ${name} agent stage`);
|
|
26
|
+
const prepared = await prepareStage(execution, note);
|
|
27
|
+
await note(`Agent preflight passed: ${prepared.profile.provider}`);
|
|
28
|
+
let attempt = { number: 1, correction: "" };
|
|
29
|
+
while (attempt.number <= maxInvocationAttempts) {
|
|
30
|
+
await note(`Starting invocation ${attempt.number}`);
|
|
31
|
+
const result = await invokeAttempt(execution, prepared, attempt);
|
|
32
|
+
if (result.kind === "completed") {
|
|
33
|
+
await note(`${name} agent stage completed`);
|
|
34
|
+
return result.output;
|
|
35
|
+
}
|
|
36
|
+
attempt = {
|
|
37
|
+
number: attempt.number + 1,
|
|
38
|
+
correction: result.correction,
|
|
39
|
+
contribution: result.contribution,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
throw new Error("Format correction exhausted");
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
await note(`Failed: ${String(error)}`);
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function refusePreviousInvocations(execution) {
|
|
50
|
+
const { run, name, stepId, dependencies: { store }, } = execution;
|
|
13
51
|
const previous = (await store.invocations(run.id)).filter((item) => item.stepId === stepId);
|
|
14
52
|
if (previous.length) {
|
|
15
53
|
for (const record of previous) {
|
|
@@ -23,11 +61,10 @@ export async function invokeStage({ run, name, stage, task, stepId, dependencies
|
|
|
23
61
|
}
|
|
24
62
|
throw new BlockedError(`Interrupted agent stage ${name}; inspect existing sessions before explicit retry`);
|
|
25
63
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
await verifyEvidence(task.evidence);
|
|
64
|
+
}
|
|
65
|
+
function prepareRequest(execution) {
|
|
66
|
+
const { run, stage, task, dependencies } = execution;
|
|
67
|
+
const { project } = dependencies;
|
|
31
68
|
const resolved = resolveStagePrompt(stage, task.defaultPrompt, dependencies.promptBaseDirectory);
|
|
32
69
|
const outputSchema = task.outputContract
|
|
33
70
|
? z.toJSONSchema(task.outputContract)
|
|
@@ -44,6 +81,19 @@ export async function invokeStage({ run, name, stage, task, stepId, dependencies
|
|
|
44
81
|
contract,
|
|
45
82
|
].join("\n\n");
|
|
46
83
|
const profile = resolveProfile(project.agent, stage.profile);
|
|
84
|
+
return { resolved, outputSchema, fullPrompt, profile };
|
|
85
|
+
}
|
|
86
|
+
async function prepareStage(execution, note) {
|
|
87
|
+
const { run, stage, task, dependencies } = execution;
|
|
88
|
+
const { project, agents, signal, workspace } = dependencies;
|
|
89
|
+
await refusePreviousInvocations(execution);
|
|
90
|
+
if (!run.snapshot)
|
|
91
|
+
throw new BlockedError("Missing workspace snapshot");
|
|
92
|
+
await workspace.verify(project, run.snapshot);
|
|
93
|
+
if (task.evidence)
|
|
94
|
+
await verifyEvidence(task.evidence);
|
|
95
|
+
const request = prepareRequest(execution);
|
|
96
|
+
const { profile } = request;
|
|
47
97
|
const adapter = agents[profile.provider];
|
|
48
98
|
if (!adapter)
|
|
49
99
|
throw new Error(`Missing agent adapter ${profile.provider}`);
|
|
@@ -52,115 +102,162 @@ export async function invokeStage({ run, name, stage, task, stepId, dependencies
|
|
|
52
102
|
signal,
|
|
53
103
|
AbortSignal.timeout(stage.timeoutMs),
|
|
54
104
|
]);
|
|
105
|
+
await note(`Validating ${profile.provider} agent profile`);
|
|
55
106
|
const effective = await adapter.validate(profile, invocationSignal);
|
|
56
107
|
const directory = join(dependencies.artifacts, run.id);
|
|
57
108
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
109
|
+
return {
|
|
110
|
+
...request,
|
|
111
|
+
adapter,
|
|
112
|
+
effective,
|
|
113
|
+
deadline,
|
|
114
|
+
invocationSignal,
|
|
115
|
+
directory,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
async function invokeAttempt(execution, prepared, attempt) {
|
|
119
|
+
const { run, name, task, stepId, dependencies } = execution;
|
|
120
|
+
const { store, project, workspace, redact } = dependencies;
|
|
121
|
+
const { profile, effective, resolved, outputSchema, fullPrompt, directory, invocationSignal, } = prepared;
|
|
122
|
+
invocationSignal.throwIfAborted();
|
|
123
|
+
const expected = (await store.run(run.id)).snapshot;
|
|
124
|
+
await workspace.verify(project, expected);
|
|
125
|
+
if (task.evidence)
|
|
126
|
+
await verifyEvidence(task.evidence);
|
|
127
|
+
const id = randomUUID();
|
|
128
|
+
const prompt = fullPrompt + attempt.correction;
|
|
129
|
+
const readOnly = task.readOnly === true || attempt.number === maxInvocationAttempts;
|
|
130
|
+
const record = {
|
|
131
|
+
id,
|
|
132
|
+
runId: run.id,
|
|
133
|
+
projectId: project.id,
|
|
134
|
+
step: name,
|
|
135
|
+
stepId,
|
|
136
|
+
attempt: attempt.number,
|
|
137
|
+
provider: profile.provider,
|
|
138
|
+
sessionId: null,
|
|
139
|
+
sessionState: "pending",
|
|
140
|
+
requested: profile,
|
|
141
|
+
effective,
|
|
142
|
+
prompt: redact(prompt),
|
|
143
|
+
taskPrompt: { ...resolved, content: redact(resolved.content) },
|
|
144
|
+
outputContract: outputSchema
|
|
145
|
+
? sha256(JSON.stringify(outputSchema))
|
|
146
|
+
: undefined,
|
|
147
|
+
evidence: task.evidence,
|
|
148
|
+
outcome: "running",
|
|
149
|
+
startedAt: new Date().toISOString(),
|
|
150
|
+
log: join(directory, `${id}.jsonl`),
|
|
151
|
+
};
|
|
152
|
+
await store.saveInvocation(record);
|
|
153
|
+
try {
|
|
154
|
+
invocationSignal.throwIfAborted();
|
|
155
|
+
const output = await invokeProvider(execution, prepared, {
|
|
156
|
+
record,
|
|
157
|
+
prompt,
|
|
158
|
+
readOnly,
|
|
159
|
+
});
|
|
160
|
+
await appendFile(record.log, "", { mode: 0o600 });
|
|
61
161
|
invocationSignal.throwIfAborted();
|
|
62
|
-
|
|
63
|
-
|
|
162
|
+
let contribution = attempt.contribution;
|
|
163
|
+
if (readOnly)
|
|
164
|
+
await workspace.verify(project, expected);
|
|
165
|
+
else
|
|
166
|
+
contribution = await saveImplementationSnapshot(run.id, expected, profile.provider, dependencies);
|
|
64
167
|
if (task.evidence)
|
|
65
168
|
await verifyEvidence(task.evidence);
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
sessionState: "pending",
|
|
79
|
-
requested: profile,
|
|
80
|
-
effective,
|
|
81
|
-
prompt: redact(prompt),
|
|
82
|
-
taskPrompt: { ...resolved, content: redact(resolved.content) },
|
|
83
|
-
outputContract: outputSchema
|
|
84
|
-
? sha256(JSON.stringify(outputSchema))
|
|
85
|
-
: undefined,
|
|
86
|
-
evidence: task.evidence,
|
|
87
|
-
outcome: "running",
|
|
88
|
-
startedAt: new Date().toISOString(),
|
|
89
|
-
log: join(directory, `${id}.jsonl`),
|
|
169
|
+
invocationSignal.throwIfAborted();
|
|
170
|
+
const response = validateResponse(output, task.outputContract);
|
|
171
|
+
if (response.kind === "invalid") {
|
|
172
|
+
const correction = await recordInvalidResponse(record, output, response.error, redact);
|
|
173
|
+
return { kind: "correction", correction, contribution };
|
|
174
|
+
}
|
|
175
|
+
if (contribution)
|
|
176
|
+
await acceptContribution(run.id, contribution, dependencies);
|
|
177
|
+
record.outcome = "completed";
|
|
178
|
+
return {
|
|
179
|
+
kind: "completed",
|
|
180
|
+
output: redact(task.outputContract ? JSON.stringify(response.parsed) : output),
|
|
90
181
|
};
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (record.outcome === "running")
|
|
185
|
+
record.outcome = "failed";
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
record.finishedAt = new Date().toISOString();
|
|
190
|
+
if (!record.sessionId)
|
|
191
|
+
record.sessionState = "unavailable";
|
|
91
192
|
await store.saveInvocation(record);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async function invokeProvider(execution, prepared, attempt) {
|
|
196
|
+
const { run, name, dependencies: { project, store, redact }, } = execution;
|
|
197
|
+
const { adapter, profile, outputSchema, invocationSignal, deadline } = prepared;
|
|
198
|
+
const { record, prompt, readOnly } = attempt;
|
|
199
|
+
return adapter.invoke({
|
|
200
|
+
id: record.id,
|
|
201
|
+
runId: run.id,
|
|
202
|
+
step: name,
|
|
203
|
+
cwd: project.checkout,
|
|
204
|
+
prompt,
|
|
205
|
+
profile,
|
|
206
|
+
outputSchema,
|
|
207
|
+
processFile: record.log + ".process.json",
|
|
208
|
+
readOnly,
|
|
209
|
+
signal: invocationSignal,
|
|
210
|
+
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
211
|
+
session: async (sessionId) => {
|
|
212
|
+
record.sessionId = sessionId;
|
|
213
|
+
record.sessionState = "available";
|
|
214
|
+
await store.saveInvocation(record);
|
|
215
|
+
},
|
|
216
|
+
event: async (event) => {
|
|
217
|
+
await appendFile(record.log, redact(JSON.stringify(event)) + "\n", {
|
|
218
|
+
mode: 0o600,
|
|
116
219
|
});
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
try {
|
|
129
|
-
parsed = task.outputContract
|
|
130
|
-
? task.outputContract.parse(JSON.parse(output))
|
|
131
|
-
: undefined;
|
|
132
|
-
}
|
|
133
|
-
catch (error) {
|
|
134
|
-
if (!(error instanceof SyntaxError) && !(error instanceof z.ZodError))
|
|
135
|
-
throw error;
|
|
136
|
-
record.outcome = "invalid-output";
|
|
137
|
-
record.validationError = redact(String(error));
|
|
138
|
-
await appendFile(record.log, redact(JSON.stringify({
|
|
139
|
-
type: "invalid-output",
|
|
140
|
-
output,
|
|
141
|
-
error: String(error),
|
|
142
|
-
})) + "\n");
|
|
143
|
-
if (attempt === maxInvocationAttempts)
|
|
144
|
-
throw new Error(`Invalid output after one correction: ${String(error)}`);
|
|
145
|
-
correction = `\n\nCorrect the prior response format in this fresh inspection-only session. Do not modify files.\nPrior invalid response:\n${output}\nValidation errors:\n${String(error)}`;
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
if (pendingContribution)
|
|
149
|
-
await acceptContribution(run.id, pendingContribution);
|
|
150
|
-
record.outcome = "completed";
|
|
151
|
-
return redact(task.outputContract ? JSON.stringify(parsed) : output);
|
|
152
|
-
}
|
|
153
|
-
catch (error) {
|
|
154
|
-
if (record.outcome === "running")
|
|
155
|
-
record.outcome = "failed";
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/** Only returned output validation failures qualify for correction. */
|
|
224
|
+
function validateResponse(output, contract) {
|
|
225
|
+
let parsed;
|
|
226
|
+
try {
|
|
227
|
+
parsed = contract ? contract.parse(JSON.parse(output)) : undefined;
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
if (!(error instanceof SyntaxError) && !(error instanceof z.ZodError))
|
|
156
231
|
throw error;
|
|
157
|
-
}
|
|
158
|
-
finally {
|
|
159
|
-
record.finishedAt = new Date().toISOString();
|
|
160
|
-
if (!record.sessionId)
|
|
161
|
-
record.sessionState = "unavailable";
|
|
162
|
-
await store.saveInvocation(record);
|
|
163
|
-
}
|
|
232
|
+
return { kind: "invalid", error };
|
|
164
233
|
}
|
|
165
|
-
|
|
234
|
+
return { kind: "valid", parsed };
|
|
235
|
+
}
|
|
236
|
+
async function recordInvalidResponse(record, output, error, redact) {
|
|
237
|
+
record.outcome = "invalid-output";
|
|
238
|
+
record.validationError = redact(String(error));
|
|
239
|
+
await appendFile(record.log, redact(JSON.stringify({ type: "invalid-output", output, error: String(error) })) + "\n");
|
|
240
|
+
if (record.attempt === maxInvocationAttempts)
|
|
241
|
+
throw new Error(`Invalid output after one correction: ${String(error)}`);
|
|
242
|
+
return `\n\nCorrect the prior response format in this fresh inspection-only session. Do not modify files.\nPrior invalid response:\n${output}\nValidation errors:\n${String(error)}`;
|
|
243
|
+
}
|
|
244
|
+
async function saveImplementationSnapshot(runId, expected, provider, dependencies) {
|
|
245
|
+
const { workspace, project, store } = dependencies;
|
|
246
|
+
const snapshot = await workspace.inspect(project);
|
|
247
|
+
if (snapshot.head !== expected.head || snapshot.branch !== expected.branch)
|
|
248
|
+
throw new BlockedError("Agent changed branch or committed unexpectedly");
|
|
249
|
+
await store.patchRun(runId, { snapshot });
|
|
250
|
+
return snapshot.fingerprint === expected.fingerprint
|
|
251
|
+
? undefined
|
|
252
|
+
: {
|
|
253
|
+
provider,
|
|
254
|
+
beforeFiles: expected.files,
|
|
255
|
+
afterFiles: snapshot.files,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
async function acceptContribution(runId, candidate, dependencies) {
|
|
259
|
+
const run = await dependencies.store.run(runId);
|
|
260
|
+
await dependencies.store.patchRun(runId, {
|
|
261
|
+
contributionCandidates: [...(run.contributionCandidates ?? []), candidate],
|
|
262
|
+
});
|
|
166
263
|
}
|
package/dist/src/operations.d.ts
CHANGED
|
@@ -25,10 +25,9 @@ export declare class Operations {
|
|
|
25
25
|
eligible(): Promise<boolean>;
|
|
26
26
|
prepare(): Promise<void>;
|
|
27
27
|
invoke(name: string, stage: Stage, task: InvocationTask): Promise<string>;
|
|
28
|
-
private saveImplementationSnapshot;
|
|
29
|
-
private acceptContribution;
|
|
30
28
|
implement(): Promise<void>;
|
|
31
29
|
validate(): Promise<boolean>;
|
|
30
|
+
private recordValidation;
|
|
32
31
|
private prepareEvidence;
|
|
33
32
|
writePublication(): Promise<void>;
|
|
34
33
|
commit(): Promise<void>;
|
package/dist/src/operations.js
CHANGED
|
@@ -9,6 +9,7 @@ import { invokeStage } from "./invocation.js";
|
|
|
9
9
|
import { defaultStagePrompts } from "./prompts.js";
|
|
10
10
|
import { publicationSteps } from "./recovery.js";
|
|
11
11
|
import { command } from "./runtime/process.js";
|
|
12
|
+
import { selectValidation } from "./validation-selection.js";
|
|
12
13
|
const maxStepAttempts = 3;
|
|
13
14
|
/** Reusable durable coding operations. Call from a registered DBOS workflow. */
|
|
14
15
|
export class Operations {
|
|
@@ -76,8 +77,14 @@ export class Operations {
|
|
|
76
77
|
return this.step("eligibility", async (run) => {
|
|
77
78
|
const { hosting, project } = this.dependencies;
|
|
78
79
|
const issue = await hosting.getIssue(run.issue.number);
|
|
79
|
-
|
|
80
|
-
project.labels.every((label) => issue.labels.includes(label)))
|
|
80
|
+
if (!issue.open ||
|
|
81
|
+
!project.labels.every((label) => issue.labels.includes(label)))
|
|
82
|
+
return false;
|
|
83
|
+
const { profile } = selectValidation(run.issue.body, project);
|
|
84
|
+
await this.dependencies.store.patchRun(run.id, {
|
|
85
|
+
validationProfile: profile,
|
|
86
|
+
});
|
|
87
|
+
return true;
|
|
81
88
|
});
|
|
82
89
|
}
|
|
83
90
|
async prepare() {
|
|
@@ -104,33 +111,8 @@ export class Operations {
|
|
|
104
111
|
task,
|
|
105
112
|
stepId: DBOS.stepID,
|
|
106
113
|
dependencies: this.dependencies,
|
|
107
|
-
saveImplementationSnapshot: (runId, expected, provider) => this.saveImplementationSnapshot(runId, expected, provider),
|
|
108
|
-
acceptContribution: (runId, candidate) => this.acceptContribution(runId, candidate),
|
|
109
114
|
}));
|
|
110
115
|
}
|
|
111
|
-
async saveImplementationSnapshot(runId, expected, provider) {
|
|
112
|
-
const { workspace, project, store } = this.dependencies;
|
|
113
|
-
const snapshot = await workspace.inspect(project);
|
|
114
|
-
if (snapshot.head !== expected.head || snapshot.branch !== expected.branch)
|
|
115
|
-
throw new BlockedError("Agent changed branch or committed unexpectedly");
|
|
116
|
-
await store.patchRun(runId, { snapshot });
|
|
117
|
-
return snapshot.fingerprint === expected.fingerprint
|
|
118
|
-
? undefined
|
|
119
|
-
: {
|
|
120
|
-
provider,
|
|
121
|
-
beforeFiles: expected.files,
|
|
122
|
-
afterFiles: snapshot.files,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
async acceptContribution(runId, candidate) {
|
|
126
|
-
const run = await this.dependencies.store.run(runId);
|
|
127
|
-
await this.dependencies.store.patchRun(runId, {
|
|
128
|
-
contributionCandidates: [
|
|
129
|
-
...(run.contributionCandidates ?? []),
|
|
130
|
-
candidate,
|
|
131
|
-
],
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
116
|
async implement() {
|
|
135
117
|
await this.invoke("implementation", this.dependencies.project.stages.implementation, {
|
|
136
118
|
defaultPrompt: defaultStagePrompts.implementation,
|
|
@@ -139,7 +121,7 @@ export class Operations {
|
|
|
139
121
|
}
|
|
140
122
|
async validate() {
|
|
141
123
|
return this.step("validation", async (run) => {
|
|
142
|
-
const { project, workspace,
|
|
124
|
+
const { project, workspace, signal, redact } = this.dependencies;
|
|
143
125
|
if (!run.snapshot)
|
|
144
126
|
throw new BlockedError("Missing implementation snapshot");
|
|
145
127
|
await workspace.verify(project, run.snapshot);
|
|
@@ -148,7 +130,8 @@ export class Operations {
|
|
|
148
130
|
const validation = [];
|
|
149
131
|
const directory = join(this.dependencies.artifacts, run.id);
|
|
150
132
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
151
|
-
|
|
133
|
+
const checks = selectValidation(run.issue.body, project).commands;
|
|
134
|
+
for (const [index, check] of checks.entries()) {
|
|
152
135
|
const startedAt = new Date().toISOString();
|
|
153
136
|
const log = join(directory, `validation-${index}.log`);
|
|
154
137
|
let captured = "";
|
|
@@ -169,25 +152,23 @@ export class Operations {
|
|
|
169
152
|
await appendFile(log, redact(captured + "\n" + String(error)), {
|
|
170
153
|
mode: 0o600,
|
|
171
154
|
});
|
|
172
|
-
|
|
155
|
+
await this.recordValidation(run.id, validation, {
|
|
173
156
|
...check,
|
|
174
157
|
exitCode: -1,
|
|
175
158
|
log,
|
|
176
159
|
startedAt,
|
|
177
160
|
finishedAt: new Date().toISOString(),
|
|
178
161
|
});
|
|
179
|
-
await store.patchRun(run.id, { validation });
|
|
180
162
|
throw error;
|
|
181
163
|
}
|
|
182
164
|
await appendFile(log, redact(captured), { mode: 0o600 });
|
|
183
|
-
|
|
165
|
+
await this.recordValidation(run.id, validation, {
|
|
184
166
|
...check,
|
|
185
167
|
exitCode: result.exitCode,
|
|
186
168
|
log,
|
|
187
169
|
startedAt,
|
|
188
170
|
finishedAt: new Date().toISOString(),
|
|
189
171
|
});
|
|
190
|
-
await store.patchRun(run.id, { validation });
|
|
191
172
|
if (result.exitCode !== 0)
|
|
192
173
|
throw new Error(`Validation failed: ${check.command} (exit ${result.exitCode})`);
|
|
193
174
|
}
|
|
@@ -195,6 +176,10 @@ export class Operations {
|
|
|
195
176
|
return true;
|
|
196
177
|
});
|
|
197
178
|
}
|
|
179
|
+
async recordValidation(runId, validation, result) {
|
|
180
|
+
validation.push(result);
|
|
181
|
+
await this.dependencies.store.patchRun(runId, { validation });
|
|
182
|
+
}
|
|
198
183
|
async prepareEvidence(name, published = false) {
|
|
199
184
|
return this.step(name, async (run) => {
|
|
200
185
|
const { project, workspace, artifacts, signal } = this.dependencies;
|
package/dist/src/recovery.d.ts
CHANGED
|
@@ -2,6 +2,11 @@ import type { Project } from "./config.js";
|
|
|
2
2
|
import { type HostingAdapter, type RunRecord, type Workspace } from "./domain.js";
|
|
3
3
|
import type { Store } from "./store.js";
|
|
4
4
|
export declare const publicationSteps: readonly string[];
|
|
5
|
+
interface WorkflowStep {
|
|
6
|
+
functionID: number;
|
|
7
|
+
name?: string;
|
|
8
|
+
error?: unknown;
|
|
9
|
+
}
|
|
5
10
|
export declare function recoveryUnavailable(run: RunRecord): string | undefined;
|
|
6
11
|
/** Hash execution inputs without reading credential environment values. */
|
|
7
12
|
export declare function executionFingerprint(project: Project, workflowVersion: string, signal?: AbortSignal): Promise<string>;
|
|
@@ -14,6 +19,19 @@ interface RecoveryDependencies {
|
|
|
14
19
|
workflowVersion: string;
|
|
15
20
|
signal?: AbortSignal;
|
|
16
21
|
}
|
|
17
|
-
/**
|
|
18
|
-
export declare function
|
|
22
|
+
/** Verify checkpoint history and live state before persisting recovery intent. */
|
|
23
|
+
export declare function verifyPublicationRecoveryAdmission(run: RunRecord, checkpoints: {
|
|
24
|
+
status: {
|
|
25
|
+
status: string;
|
|
26
|
+
applicationVersion?: string;
|
|
27
|
+
} | null | undefined;
|
|
28
|
+
steps: readonly WorkflowStep[] | undefined;
|
|
29
|
+
applicationVersion: string;
|
|
30
|
+
}, dependencies: RecoveryDependencies): Promise<string[]>;
|
|
31
|
+
interface RecoveryStartDependencies extends RecoveryDependencies {
|
|
32
|
+
store: Pick<Store, "invocations" | "project" | "patchRun">;
|
|
33
|
+
signal: AbortSignal;
|
|
34
|
+
}
|
|
35
|
+
/** Revalidate a recovered Execution at its first non-replayed step. */
|
|
36
|
+
export declare function resumePublicationExecution(run: RunRecord, workflowId: string, stepId: number, dependencies: RecoveryStartDependencies): Promise<void>;
|
|
19
37
|
export {};
|
package/dist/src/recovery.js
CHANGED
|
@@ -6,11 +6,38 @@ import { verifyEvidence } from "./evidence.js";
|
|
|
6
6
|
import { projectPrompts } from "./prompts.js";
|
|
7
7
|
import { assertProcessesStopped } from "./runtime/ownership.js";
|
|
8
8
|
import { command } from "./runtime/process.js";
|
|
9
|
+
const recoveryGatePollIntervalMs = 100;
|
|
9
10
|
export const publicationSteps = [
|
|
10
11
|
"push",
|
|
11
12
|
"change-request",
|
|
12
13
|
"review-publication",
|
|
13
14
|
];
|
|
15
|
+
/** Prove that a fork will replay only completed publication checkpoints. */
|
|
16
|
+
function completedPublicationCheckpoints(run, status, steps, applicationVersion) {
|
|
17
|
+
if (!status || !["SUCCESS", "ERROR"].includes(status.status))
|
|
18
|
+
throw new Error("Source execution has not finished");
|
|
19
|
+
if (status.applicationVersion !== applicationVersion)
|
|
20
|
+
throw new Error("Workflow version changed; use retry");
|
|
21
|
+
const failed = steps?.find((step) => step.functionID === run.executions?.at(-1)?.failedStep);
|
|
22
|
+
if (!failed?.error || failed.name !== run.phase)
|
|
23
|
+
throw new Error("Failed publication checkpoint is unavailable");
|
|
24
|
+
const prefix = steps.filter((step) => step.functionID < failed.functionID);
|
|
25
|
+
if (prefix.length !== failed.functionID ||
|
|
26
|
+
prefix.some((step, index) => step.error || step.functionID !== index) ||
|
|
27
|
+
!prefix.some((step) => step.name === "commit"))
|
|
28
|
+
throw new Error("Completed publication checkpoints are unavailable");
|
|
29
|
+
return prefix.map((step) => step.name);
|
|
30
|
+
}
|
|
31
|
+
/** Check the identity of the first step that DBOS did not replay. */
|
|
32
|
+
function verifyPublicationExecutionStart(run, workflowId, stepId) {
|
|
33
|
+
const execution = run.executions?.at(-1);
|
|
34
|
+
if (!execution?.recoveryOf)
|
|
35
|
+
return;
|
|
36
|
+
if (execution.id !== workflowId)
|
|
37
|
+
throw new BlockedError("Execution has been superseded");
|
|
38
|
+
if (stepId < execution.startStep)
|
|
39
|
+
throw new BlockedError("A reused checkpoint is missing; recovery refused");
|
|
40
|
+
}
|
|
14
41
|
export function recoveryUnavailable(run) {
|
|
15
42
|
if (run.outcome !== "failed")
|
|
16
43
|
return "Only failed publication runs can be recovered";
|
|
@@ -47,8 +74,29 @@ export async function executionFingerprint(project, workflowVersion, signal) {
|
|
|
47
74
|
}))
|
|
48
75
|
.digest("hex");
|
|
49
76
|
}
|
|
77
|
+
/** Verify checkpoint history and live state before persisting recovery intent. */
|
|
78
|
+
export async function verifyPublicationRecoveryAdmission(run, checkpoints, dependencies) {
|
|
79
|
+
const completedSteps = completedPublicationCheckpoints(run, checkpoints.status, checkpoints.steps, checkpoints.applicationVersion);
|
|
80
|
+
await verifyPublicationRecovery(run, dependencies);
|
|
81
|
+
return completedSteps;
|
|
82
|
+
}
|
|
83
|
+
/** Revalidate a recovered Execution at its first non-replayed step. */
|
|
84
|
+
export async function resumePublicationExecution(run, workflowId, stepId, dependencies) {
|
|
85
|
+
verifyPublicationExecutionStart(run, workflowId, stepId);
|
|
86
|
+
while (true) {
|
|
87
|
+
dependencies.signal.throwIfAborted();
|
|
88
|
+
const state = await dependencies.store.project(dependencies.project.id);
|
|
89
|
+
if (state.blocked)
|
|
90
|
+
throw new BlockedError(state.blocked);
|
|
91
|
+
if (!state.paused)
|
|
92
|
+
break;
|
|
93
|
+
await new Promise((resolve) => setTimeout(resolve, recoveryGatePollIntervalMs));
|
|
94
|
+
}
|
|
95
|
+
await dependencies.store.patchRun(run.id, { outcome: "running" });
|
|
96
|
+
await verifyPublicationRecovery(run, dependencies);
|
|
97
|
+
}
|
|
50
98
|
/** Read-only checks shared by admission and the first non-replayed operation. */
|
|
51
|
-
|
|
99
|
+
async function verifyPublicationRecovery(run, dependencies) {
|
|
52
100
|
const { project, workspace, hosting, store, stateDirectory, workflowVersion, signal, } = dependencies;
|
|
53
101
|
if (run.checkout !== project.checkout)
|
|
54
102
|
throw new BlockedError("Configured checkout differs from the recorded run checkout");
|
package/dist/src/runner.d.ts
CHANGED
|
@@ -43,12 +43,16 @@ export declare class Runner {
|
|
|
43
43
|
private recordWorkflowFailure;
|
|
44
44
|
private execute;
|
|
45
45
|
private executeOwned;
|
|
46
|
+
private checkExecutionStart;
|
|
47
|
+
private recordExecutionFailure;
|
|
46
48
|
pause(projectId: string): Promise<void>;
|
|
47
49
|
private initializeExecution;
|
|
48
50
|
resume(projectId: string): Promise<void>;
|
|
49
51
|
stop(runId: string): Promise<void>;
|
|
50
|
-
retry(runId: string, commandId?: string
|
|
52
|
+
retry(runId: string, commandId?: string, options?: {
|
|
53
|
+
refreshIssue?: boolean;
|
|
54
|
+
}): Promise<string>;
|
|
51
55
|
recover(runId: string, commandId?: string): Promise<string>;
|
|
52
|
-
private
|
|
56
|
+
private projectForRun;
|
|
53
57
|
shutdown(): Promise<void>;
|
|
54
58
|
}
|