@mingchuno/agent-workflows 0.1.0 → 0.2.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 +32 -6
- package/dist/src/adapters/agents.js +6 -3
- package/dist/src/adapters/hosting.js +16 -10
- package/dist/src/adapters/sdk-protocol.d.ts +3 -3
- package/dist/src/adapters/sdk-protocol.js +9 -7
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +40 -19
- package/dist/src/config.d.ts +22 -22
- package/dist/src/config.js +31 -26
- package/dist/src/defaults.d.ts +2 -0
- package/dist/src/defaults.js +2 -0
- package/dist/src/domain.d.ts +24 -4
- package/dist/src/domain.js +10 -3
- package/dist/src/evidence.d.ts +54 -0
- package/dist/src/evidence.js +214 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/invocation.d.ts +24 -0
- package/dist/src/invocation.js +163 -0
- package/dist/src/operations.d.ts +7 -2
- package/dist/src/operations.js +76 -134
- package/dist/src/prompts.d.ts +28 -0
- package/dist/src/prompts.js +63 -0
- package/dist/src/recovery.d.ts +19 -0
- package/dist/src/recovery.js +99 -0
- package/dist/src/runner.d.ts +5 -0
- package/dist/src/runner.js +145 -18
- package/dist/src/runtime/process.d.ts +2 -0
- package/dist/src/runtime/process.js +41 -12
- package/dist/src/store.d.ts +21 -2
- package/dist/src/store.js +122 -1
- package/dist/src/tui/actions.d.ts +16 -0
- package/dist/src/tui/actions.js +23 -0
- package/dist/src/tui/constants.d.ts +6 -0
- package/dist/src/tui/constants.js +3 -0
- package/dist/src/{tui-data.d.ts → tui/data.d.ts} +8 -6
- package/dist/src/tui/data.js +141 -0
- package/dist/src/tui/dialogs.d.ts +17 -0
- package/dist/src/tui/dialogs.js +149 -0
- package/dist/src/tui/format.d.ts +7 -0
- package/dist/src/tui/format.js +62 -0
- package/dist/src/tui/index.d.ts +2 -0
- package/dist/src/tui/index.js +1 -0
- package/dist/src/tui/layout.d.ts +25 -0
- package/dist/src/tui/layout.js +36 -0
- package/dist/src/tui/log-file.d.ts +26 -0
- package/dist/src/tui/log-file.js +156 -0
- package/dist/src/tui/log.d.ts +11 -0
- package/dist/src/tui/log.js +90 -0
- package/dist/src/tui/monitor.d.ts +8 -0
- package/dist/src/tui/monitor.js +222 -0
- package/dist/src/tui/text.d.ts +3 -0
- package/dist/src/tui/text.js +10 -0
- package/dist/src/tui/use-log-controller.d.ts +27 -0
- package/dist/src/tui/use-log-controller.js +192 -0
- package/dist/src/tui/views.d.ts +17 -0
- package/dist/src/tui/views.js +97 -0
- package/docs/api.md +119 -6
- package/docs/architecture.md +21 -4
- package/docs/configuration.md +137 -5
- package/docs/database.md +7 -0
- package/docs/operations.md +117 -2
- package/docs/providers.md +58 -2
- package/docs/releases.md +34 -79
- package/examples/config.ts +2 -2
- package/package.json +4 -2
- package/dist/src/tui-data.js +0 -89
- package/dist/src/tui.d.ts +0 -5
- package/dist/src/tui.js +0 -69
- package/docs/acceptance.md +0 -35
package/dist/src/operations.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { appendFile, mkdir
|
|
3
|
-
import { join
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
4
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
5
|
-
import { resolveProfile } from "./config.js";
|
|
6
5
|
import { BlockedError, isBlockedError, publicationSchema, reviewSchema, } from "./domain.js";
|
|
6
|
+
import { captureEvidence, evidenceContext, } from "./evidence.js";
|
|
7
|
+
import { invokeStage } from "./invocation.js";
|
|
8
|
+
import { defaultStagePrompts } from "./prompts.js";
|
|
9
|
+
import { publicationSteps } from "./recovery.js";
|
|
7
10
|
import { command } from "./runtime/process.js";
|
|
11
|
+
const maxStepAttempts = 3;
|
|
8
12
|
/** Reusable durable coding operations. Call from a registered DBOS workflow. */
|
|
9
13
|
export class Operations {
|
|
10
14
|
runId;
|
|
@@ -17,10 +21,12 @@ export class Operations {
|
|
|
17
21
|
return DBOS.runStep(async () => {
|
|
18
22
|
const { store, signal } = this.dependencies;
|
|
19
23
|
signal.throwIfAborted();
|
|
24
|
+
await this.dependencies.beforeStep?.();
|
|
20
25
|
const run = await store.run(this.runId);
|
|
21
26
|
await store.patchRun(this.runId, { phase: name });
|
|
22
27
|
await store.emit(this.runId, "step", {
|
|
23
28
|
name,
|
|
29
|
+
executionId: DBOS.workflowID,
|
|
24
30
|
stepId: DBOS.stepID,
|
|
25
31
|
attempt: DBOS.stepStatus?.currentAttempt ?? 1,
|
|
26
32
|
status: "running",
|
|
@@ -31,6 +37,7 @@ export class Operations {
|
|
|
31
37
|
const result = await operation(run);
|
|
32
38
|
await store.emit(this.runId, "step", {
|
|
33
39
|
name,
|
|
40
|
+
executionId: DBOS.workflowID,
|
|
34
41
|
stepId: DBOS.stepID,
|
|
35
42
|
attempt: DBOS.stepStatus?.currentAttempt ?? 1,
|
|
36
43
|
status: "completed",
|
|
@@ -38,8 +45,13 @@ export class Operations {
|
|
|
38
45
|
return result;
|
|
39
46
|
}
|
|
40
47
|
catch (error) {
|
|
48
|
+
if (!publicationSteps.includes(name) ||
|
|
49
|
+
isBlockedError(error) ||
|
|
50
|
+
DBOS.stepStatus?.currentAttempt === maxStepAttempts)
|
|
51
|
+
await store.patchRun(this.runId, { failedStep: DBOS.stepID });
|
|
41
52
|
await store.emit(this.runId, "step", {
|
|
42
53
|
name,
|
|
54
|
+
executionId: DBOS.workflowID,
|
|
43
55
|
stepId: DBOS.stepID,
|
|
44
56
|
attempt: DBOS.stepStatus?.currentAttempt ?? 1,
|
|
45
57
|
status: "failed",
|
|
@@ -52,12 +64,8 @@ export class Operations {
|
|
|
52
64
|
}
|
|
53
65
|
}, {
|
|
54
66
|
name,
|
|
55
|
-
retriesAllowed:
|
|
56
|
-
|
|
57
|
-
"change-request",
|
|
58
|
-
"review-publication",
|
|
59
|
-
].includes(name),
|
|
60
|
-
maxAttempts: 3,
|
|
67
|
+
retriesAllowed: publicationSteps.includes(name),
|
|
68
|
+
maxAttempts: maxStepAttempts,
|
|
61
69
|
intervalSeconds: 0.2,
|
|
62
70
|
backoffRate: 2,
|
|
63
71
|
shouldRetry: (error) => !isBlockedError(error),
|
|
@@ -75,128 +83,28 @@ export class Operations {
|
|
|
75
83
|
await this.step("prepare", async (run) => {
|
|
76
84
|
const { workspace, project, store } = this.dependencies;
|
|
77
85
|
const snapshot = await workspace.prepare(project, run.branch, this.dependencies.signal);
|
|
86
|
+
const execution = run.executions?.at(-1);
|
|
87
|
+
if (execution?.recoverySupported &&
|
|
88
|
+
this.dependencies.executionFingerprint)
|
|
89
|
+
execution.fingerprint = await this.dependencies.executionFingerprint();
|
|
78
90
|
await store.patchRun(run.id, {
|
|
79
91
|
base: snapshot.head,
|
|
80
92
|
snapshot,
|
|
81
93
|
outcome: "running",
|
|
94
|
+
executions: run.executions,
|
|
82
95
|
});
|
|
83
96
|
});
|
|
84
97
|
}
|
|
85
|
-
async invoke(name, stage,
|
|
86
|
-
return this.step(name,
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (!invocation.sessionId)
|
|
95
|
-
invocation.sessionState = "unavailable";
|
|
96
|
-
await store.saveInvocation(invocation);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
throw new BlockedError(`Interrupted agent stage ${name}; inspect existing sessions before explicit retry`);
|
|
100
|
-
}
|
|
101
|
-
if (!run.snapshot)
|
|
102
|
-
throw new BlockedError("Missing workspace snapshot");
|
|
103
|
-
await workspace.verify(project, run.snapshot);
|
|
104
|
-
const profile = resolveProfile(project.agent, stage.profile);
|
|
105
|
-
const adapter = agents[profile.provider];
|
|
106
|
-
if (!adapter)
|
|
107
|
-
throw new Error(`Missing agent adapter ${profile.provider}`);
|
|
108
|
-
const invocationSignal = AbortSignal.any([
|
|
109
|
-
signal,
|
|
110
|
-
AbortSignal.timeout(stage.timeoutMs),
|
|
111
|
-
]);
|
|
112
|
-
const effective = await adapter.validate(profile, invocationSignal);
|
|
113
|
-
const { skills, fullPrompt } = await this.prepareInvocationPrompt(stage, () => prompt(run));
|
|
114
|
-
const id = randomUUID();
|
|
115
|
-
const directory = join(this.dependencies.artifacts, run.id);
|
|
116
|
-
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
117
|
-
const record = {
|
|
118
|
-
id,
|
|
119
|
-
runId: run.id,
|
|
120
|
-
projectId: project.id,
|
|
121
|
-
step: name,
|
|
122
|
-
stepId: DBOS.stepID,
|
|
123
|
-
attempt: previous.length + 1,
|
|
124
|
-
provider: profile.provider,
|
|
125
|
-
sessionId: null,
|
|
126
|
-
sessionState: "pending",
|
|
127
|
-
requested: profile,
|
|
128
|
-
effective,
|
|
129
|
-
prompt: redact(fullPrompt),
|
|
130
|
-
skills: skills.map((skill) => ({
|
|
131
|
-
...skill,
|
|
132
|
-
content: redact(skill.content),
|
|
133
|
-
})),
|
|
134
|
-
outcome: "running",
|
|
135
|
-
startedAt: new Date().toISOString(),
|
|
136
|
-
log: join(directory, `${id}.jsonl`),
|
|
137
|
-
};
|
|
138
|
-
await store.saveInvocation(record);
|
|
139
|
-
try {
|
|
140
|
-
const output = await adapter.invoke({
|
|
141
|
-
id,
|
|
142
|
-
runId: run.id,
|
|
143
|
-
step: name,
|
|
144
|
-
cwd: project.checkout,
|
|
145
|
-
prompt: fullPrompt,
|
|
146
|
-
profile,
|
|
147
|
-
skills: skills.map((skill) => skill.path),
|
|
148
|
-
processFile: record.log + ".process.json",
|
|
149
|
-
readOnly,
|
|
150
|
-
signal: invocationSignal,
|
|
151
|
-
timeoutMs: stage.timeoutMs,
|
|
152
|
-
session: async (sessionId) => {
|
|
153
|
-
record.sessionId = sessionId;
|
|
154
|
-
record.sessionState = "available";
|
|
155
|
-
await store.saveInvocation(record);
|
|
156
|
-
},
|
|
157
|
-
event: async (event) => {
|
|
158
|
-
await appendFile(record.log, redact(JSON.stringify(event)) + "\n", {
|
|
159
|
-
mode: 0o600,
|
|
160
|
-
});
|
|
161
|
-
},
|
|
162
|
-
});
|
|
163
|
-
invocationSignal.throwIfAborted();
|
|
164
|
-
if (readOnly)
|
|
165
|
-
await workspace.verify(project, run.snapshot);
|
|
166
|
-
else
|
|
167
|
-
await this.saveImplementationSnapshot(run.id, run.snapshot);
|
|
168
|
-
record.outcome = "completed";
|
|
169
|
-
return redact(output);
|
|
170
|
-
}
|
|
171
|
-
catch (error) {
|
|
172
|
-
record.outcome = "failed";
|
|
173
|
-
throw error;
|
|
174
|
-
}
|
|
175
|
-
finally {
|
|
176
|
-
record.finishedAt = new Date().toISOString();
|
|
177
|
-
if (!record.sessionId)
|
|
178
|
-
record.sessionState = "unavailable";
|
|
179
|
-
await store.saveInvocation(record);
|
|
180
|
-
}
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
async prepareInvocationPrompt(stage, prompt) {
|
|
184
|
-
const { project } = this.dependencies;
|
|
185
|
-
const skills = await Promise.all(stage.skills.map(async (path) => {
|
|
186
|
-
const absolute = resolve(project.checkout, path);
|
|
187
|
-
const content = await readFile(absolute, "utf8");
|
|
188
|
-
return {
|
|
189
|
-
path: absolute,
|
|
190
|
-
sha256: createHash("sha256").update(content).digest("hex"),
|
|
191
|
-
content,
|
|
192
|
-
};
|
|
98
|
+
async invoke(name, stage, task) {
|
|
99
|
+
return this.step(name, (run) => invokeStage({
|
|
100
|
+
run,
|
|
101
|
+
name,
|
|
102
|
+
stage,
|
|
103
|
+
task,
|
|
104
|
+
stepId: DBOS.stepID,
|
|
105
|
+
dependencies: this.dependencies,
|
|
106
|
+
saveImplementationSnapshot: (runId, expected) => this.saveImplementationSnapshot(runId, expected),
|
|
193
107
|
}));
|
|
194
|
-
const fullPrompt = [
|
|
195
|
-
stage.prompt,
|
|
196
|
-
prompt(),
|
|
197
|
-
...skills.map((skill) => `Apply this selected skill (${skill.path}):\n${skill.content}`),
|
|
198
|
-
].join("\n\n");
|
|
199
|
-
return { skills, fullPrompt };
|
|
200
108
|
}
|
|
201
109
|
async saveImplementationSnapshot(runId, expected) {
|
|
202
110
|
const { workspace, project, store } = this.dependencies;
|
|
@@ -206,7 +114,10 @@ export class Operations {
|
|
|
206
114
|
await store.patchRun(runId, { snapshot });
|
|
207
115
|
}
|
|
208
116
|
async implement() {
|
|
209
|
-
await this.invoke("implementation", this.dependencies.project.stages.implementation,
|
|
117
|
+
await this.invoke("implementation", this.dependencies.project.stages.implementation, {
|
|
118
|
+
defaultPrompt: defaultStagePrompts.implementation,
|
|
119
|
+
context: (run) => `Issue: ${JSON.stringify(run.issue)}`,
|
|
120
|
+
});
|
|
210
121
|
}
|
|
211
122
|
async validate() {
|
|
212
123
|
return this.step("validation", async (run) => {
|
|
@@ -266,8 +177,34 @@ export class Operations {
|
|
|
266
177
|
return true;
|
|
267
178
|
});
|
|
268
179
|
}
|
|
180
|
+
async prepareEvidence(name, published = false) {
|
|
181
|
+
return this.step(name, async (run) => {
|
|
182
|
+
const { project, workspace, artifacts, signal } = this.dependencies;
|
|
183
|
+
if (!run.snapshot)
|
|
184
|
+
throw new BlockedError("Missing workspace snapshot");
|
|
185
|
+
if (published && (!run.base || !run.head))
|
|
186
|
+
throw new BlockedError("Missing published revisions");
|
|
187
|
+
await workspace.verify(project, run.snapshot);
|
|
188
|
+
const evidence = await captureEvidence({
|
|
189
|
+
project,
|
|
190
|
+
snapshot: run.snapshot,
|
|
191
|
+
directory: join(artifacts, run.id, `${name}-${randomUUID()}`),
|
|
192
|
+
signal,
|
|
193
|
+
revisions: published ? { base: run.base, head: run.head } : undefined,
|
|
194
|
+
});
|
|
195
|
+
await workspace.verify(project, run.snapshot);
|
|
196
|
+
return evidence;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
269
199
|
async writePublication() {
|
|
270
|
-
const
|
|
200
|
+
const evidence = await this.prepareEvidence("publication-input");
|
|
201
|
+
const output = await this.invoke("publication", this.dependencies.project.stages.publication, {
|
|
202
|
+
defaultPrompt: defaultStagePrompts.publication,
|
|
203
|
+
readOnly: true,
|
|
204
|
+
outputContract: publicationSchema,
|
|
205
|
+
evidence,
|
|
206
|
+
context: (run) => `${evidenceContext(evidence)}\nIssue: ${JSON.stringify(run.issue)}\nValidation: ${JSON.stringify(run.validation ?? [])}`,
|
|
207
|
+
});
|
|
271
208
|
await this.step("publication-content", async (run) => {
|
|
272
209
|
await this.dependencies.store.patchRun(run.id, {
|
|
273
210
|
publication: publicationSchema.parse(JSON.parse(output)),
|
|
@@ -314,19 +251,22 @@ export class Operations {
|
|
|
314
251
|
});
|
|
315
252
|
}
|
|
316
253
|
async review() {
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
254
|
+
const evidence = await this.prepareEvidence("review-input", true);
|
|
255
|
+
const output = await this.invoke("review", this.dependencies.project.stages.review, {
|
|
256
|
+
defaultPrompt: defaultStagePrompts.review,
|
|
257
|
+
readOnly: true,
|
|
258
|
+
outputContract: reviewSchema,
|
|
259
|
+
evidence,
|
|
260
|
+
context: (run) => `${evidenceContext(evidence)}\nIssue: ${JSON.stringify(run.issue)}\nValidation: ${JSON.stringify(run.validation ?? [])}\nSet complete=false with limitations if any required evidence cannot be inspected. Never report an incomplete review as clean. Use null for finding path/line where no valid added-line location exists.`,
|
|
323
261
|
});
|
|
324
|
-
const output = await this.invoke("review", this.dependencies.project.stages.review, (run) => `Independently review this published revision without editing files. Return ONLY JSON {"summary":"...","findings":[{"body":"...","path":"optional relative path","line":1}]}. Omit path/line where no valid added-line location exists.\nIssue: ${JSON.stringify(run.issue)}\nExact head: ${run.head}\nValidation: ${JSON.stringify(run.validation ?? [])}\nPublished diff:\n${diff}`, true);
|
|
325
262
|
await this.step("review-content", async (run) => {
|
|
263
|
+
const review = reviewSchema.parse(JSON.parse(output));
|
|
326
264
|
await this.dependencies.store.patchRun(run.id, {
|
|
327
|
-
review
|
|
265
|
+
review,
|
|
328
266
|
reviewHead: run.head,
|
|
329
267
|
});
|
|
268
|
+
if (!review.complete)
|
|
269
|
+
throw new BlockedError("Incomplete review; partial findings and limitations retained locally");
|
|
330
270
|
});
|
|
331
271
|
}
|
|
332
272
|
async publishReview() {
|
|
@@ -334,6 +274,8 @@ export class Operations {
|
|
|
334
274
|
const { hosting, project } = this.dependencies;
|
|
335
275
|
if (!run.change || !run.review || !run.reviewHead || !run.base)
|
|
336
276
|
throw new Error("Missing review");
|
|
277
|
+
if (run.review.complete !== true)
|
|
278
|
+
throw new BlockedError("Incomplete or historical review; use retry for a fresh inspection");
|
|
337
279
|
if ((await hosting.head(run.change)) !== run.reviewHead)
|
|
338
280
|
throw new BlockedError("Review stale: remote head changed");
|
|
339
281
|
const diff = (await command("git", ["diff", "--unified=0", run.base, run.reviewHead], { cwd: project.checkout })).stdout;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Project, Stage } from "./config.js";
|
|
2
|
+
export declare const defaultStagePrompts: {
|
|
3
|
+
readonly implementation: `Implement the supplied issue in the current checkout. Follow repository
|
|
4
|
+
instructions and existing conventions. Keep changes focused on the issue's
|
|
5
|
+
requirements, and add or update tests where needed to verify the behavior.`;
|
|
6
|
+
readonly publication: `Prepare a Git commit message and a pull request or merge request title and
|
|
7
|
+
description for the supplied changes. Follow repository conventions. Describe
|
|
8
|
+
what changed and why, summarize the recorded validation accurately, and state
|
|
9
|
+
material limitations. Do not claim checks passed unless the supplied evidence
|
|
10
|
+
shows they ran and passed.`;
|
|
11
|
+
readonly review: `Independently review the supplied published changes against the issue's
|
|
12
|
+
requirements and repository conventions. Inspect the change artifacts and
|
|
13
|
+
relevant source for correctness, regressions, and missing validation. Report
|
|
14
|
+
actionable findings with supporting locations where possible. State any gaps
|
|
15
|
+
in inspection explicitly; do not present an incomplete review as a clean review.`;
|
|
16
|
+
};
|
|
17
|
+
export interface ResolvedPrompt {
|
|
18
|
+
source: "default" | "inline" | "file";
|
|
19
|
+
path?: string;
|
|
20
|
+
content: string;
|
|
21
|
+
sha256: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function sha256(content: string | Buffer): string;
|
|
24
|
+
/** Resolve overrides once. Defaults belong to the caller, not the stage name. */
|
|
25
|
+
export declare function resolveStagePrompt(stage: Stage, defaultTask: string, baseDirectory?: string): ResolvedPrompt;
|
|
26
|
+
export declare function projectPrompts(project: Project, baseDirectory?: string): {
|
|
27
|
+
[k: string]: ResolvedPrompt;
|
|
28
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
|
+
export const defaultStagePrompts = {
|
|
5
|
+
implementation: `Implement the supplied issue in the current checkout. Follow repository
|
|
6
|
+
instructions and existing conventions. Keep changes focused on the issue's
|
|
7
|
+
requirements, and add or update tests where needed to verify the behavior.`,
|
|
8
|
+
publication: `Prepare a Git commit message and a pull request or merge request title and
|
|
9
|
+
description for the supplied changes. Follow repository conventions. Describe
|
|
10
|
+
what changed and why, summarize the recorded validation accurately, and state
|
|
11
|
+
material limitations. Do not claim checks passed unless the supplied evidence
|
|
12
|
+
shows they ran and passed.`,
|
|
13
|
+
review: `Independently review the supplied published changes against the issue's
|
|
14
|
+
requirements and repository conventions. Inspect the change artifacts and
|
|
15
|
+
relevant source for correctness, regressions, and missing validation. Report
|
|
16
|
+
actionable findings with supporting locations where possible. State any gaps
|
|
17
|
+
in inspection explicitly; do not present an incomplete review as a clean review.`,
|
|
18
|
+
};
|
|
19
|
+
export function sha256(content) {
|
|
20
|
+
return createHash("sha256").update(content).digest("hex");
|
|
21
|
+
}
|
|
22
|
+
const overrides = new WeakMap();
|
|
23
|
+
/** Resolve overrides once. Defaults belong to the caller, not the stage name. */
|
|
24
|
+
export function resolveStagePrompt(stage, defaultTask, baseDirectory) {
|
|
25
|
+
const cached = overrides.get(stage);
|
|
26
|
+
if (cached)
|
|
27
|
+
return cached;
|
|
28
|
+
if (stage.prompt !== undefined && stage.promptFile !== undefined)
|
|
29
|
+
throw new Error("Specify either prompt or promptFile, never both");
|
|
30
|
+
let content = stage.prompt ?? defaultTask;
|
|
31
|
+
let path;
|
|
32
|
+
if (stage.promptFile !== undefined) {
|
|
33
|
+
if (!isAbsolute(stage.promptFile) && !baseDirectory)
|
|
34
|
+
throw new Error("Relative promptFile requires an explicit base directory");
|
|
35
|
+
path = resolve(baseDirectory ?? "/", stage.promptFile);
|
|
36
|
+
try {
|
|
37
|
+
content = new TextDecoder("utf-8", {
|
|
38
|
+
fatal: true,
|
|
39
|
+
ignoreBOM: true,
|
|
40
|
+
}).decode(readFileSync(path));
|
|
41
|
+
}
|
|
42
|
+
catch (cause) {
|
|
43
|
+
throw new Error(`Cannot read UTF-8 promptFile ${path}`, { cause });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!content.trim())
|
|
47
|
+
throw new Error(`Stage prompt must be nonblank${path ? `: ${path}` : ""}`);
|
|
48
|
+
const result = {
|
|
49
|
+
source: path ? "file" : stage.prompt !== undefined ? "inline" : "default",
|
|
50
|
+
...(path ? { path } : {}),
|
|
51
|
+
content,
|
|
52
|
+
sha256: sha256(content),
|
|
53
|
+
};
|
|
54
|
+
if (result.source === "file")
|
|
55
|
+
overrides.set(stage, result);
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
export function projectPrompts(project, baseDirectory) {
|
|
59
|
+
return Object.fromEntries(Object.entries(defaultStagePrompts).map(([name, task]) => [
|
|
60
|
+
name,
|
|
61
|
+
resolveStagePrompt(project.stages[name], task, baseDirectory),
|
|
62
|
+
]));
|
|
63
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Project } from "./config.js";
|
|
2
|
+
import { type HostingAdapter, type RunRecord, type Workspace } from "./domain.js";
|
|
3
|
+
import type { Store } from "./store.js";
|
|
4
|
+
export declare const publicationSteps: readonly string[];
|
|
5
|
+
export declare function recoveryUnavailable(run: RunRecord): string | undefined;
|
|
6
|
+
/** Hash execution inputs without reading credential environment values. */
|
|
7
|
+
export declare function executionFingerprint(project: Project, workflowVersion: string, signal?: AbortSignal): Promise<string>;
|
|
8
|
+
interface RecoveryDependencies {
|
|
9
|
+
project: Project;
|
|
10
|
+
workspace: Workspace;
|
|
11
|
+
hosting: HostingAdapter;
|
|
12
|
+
store: Pick<Store, "invocations">;
|
|
13
|
+
stateDirectory: string;
|
|
14
|
+
workflowVersion: string;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
/** Read-only checks shared by admission and the first non-replayed operation. */
|
|
18
|
+
export declare function verifyPublicationRecovery(run: RunRecord, dependencies: RecoveryDependencies): Promise<void>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { access } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { BlockedError, } from "./domain.js";
|
|
5
|
+
import { verifyEvidence } from "./evidence.js";
|
|
6
|
+
import { projectPrompts } from "./prompts.js";
|
|
7
|
+
import { assertProcessesStopped } from "./runtime/ownership.js";
|
|
8
|
+
import { command } from "./runtime/process.js";
|
|
9
|
+
export const publicationSteps = [
|
|
10
|
+
"push",
|
|
11
|
+
"change-request",
|
|
12
|
+
"review-publication",
|
|
13
|
+
];
|
|
14
|
+
export function recoveryUnavailable(run) {
|
|
15
|
+
if (run.outcome !== "failed")
|
|
16
|
+
return "Only failed publication runs can be recovered";
|
|
17
|
+
if (!publicationSteps.includes(run.phase))
|
|
18
|
+
return `Recovery does not support ${run.phase}`;
|
|
19
|
+
const execution = run.executions?.at(-1);
|
|
20
|
+
if (!execution?.fingerprint || execution.failedStep === undefined)
|
|
21
|
+
return "Run has no recovery metadata; use retry for a fresh attempt";
|
|
22
|
+
if (!execution.recoverySupported)
|
|
23
|
+
return "Publication recovery supports the default workflow only";
|
|
24
|
+
if (!run.head || !run.base || !run.snapshot || !run.publication)
|
|
25
|
+
return "Missing committed publication evidence";
|
|
26
|
+
if (run.phase === "review-publication" &&
|
|
27
|
+
(!run.change || !run.review || run.reviewHead !== run.head))
|
|
28
|
+
return "Missing review for the published revision";
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
/** Hash execution inputs without reading credential environment values. */
|
|
32
|
+
export async function executionFingerprint(project, workflowVersion, signal) {
|
|
33
|
+
const { pollIntervalMs: _poll, labels: _labels, branchTemplate: _branch, hosting, ...execution } = project;
|
|
34
|
+
const { tokenEnv: _token, ...destination } = hosting;
|
|
35
|
+
const prompts = projectPrompts(project);
|
|
36
|
+
const remote = await command("git", ["remote", "get-url", "--push", "--all", project.remote], { cwd: project.checkout, signal });
|
|
37
|
+
const fetchRemote = await command("git", ["remote", "get-url", "--all", project.remote], { cwd: project.checkout, signal });
|
|
38
|
+
return createHash("sha256")
|
|
39
|
+
.update(JSON.stringify({
|
|
40
|
+
version: 2,
|
|
41
|
+
workflowVersion,
|
|
42
|
+
execution,
|
|
43
|
+
destination,
|
|
44
|
+
prompts,
|
|
45
|
+
remote: remote.stdout,
|
|
46
|
+
fetchRemote: fetchRemote.stdout,
|
|
47
|
+
}))
|
|
48
|
+
.digest("hex");
|
|
49
|
+
}
|
|
50
|
+
/** Read-only checks shared by admission and the first non-replayed operation. */
|
|
51
|
+
export async function verifyPublicationRecovery(run, dependencies) {
|
|
52
|
+
const { project, workspace, hosting, store, stateDirectory, workflowVersion, signal, } = dependencies;
|
|
53
|
+
if (run.checkout !== project.checkout)
|
|
54
|
+
throw new BlockedError("Configured checkout differs from the recorded run checkout");
|
|
55
|
+
const fingerprint = await executionFingerprint(project, workflowVersion, signal);
|
|
56
|
+
if (fingerprint !== run.executions?.at(-1)?.fingerprint)
|
|
57
|
+
throw new BlockedError("Execution configuration or prompts changed; use retry");
|
|
58
|
+
await assertProcessesStopped(resolve(stateDirectory, run.id));
|
|
59
|
+
const gitDirectory = (await command("git", ["rev-parse", "--absolute-git-dir"], {
|
|
60
|
+
cwd: project.checkout,
|
|
61
|
+
signal,
|
|
62
|
+
})).stdout.trim();
|
|
63
|
+
await assertProcessesStopped(resolve(gitDirectory, "agent-workflows-processes"));
|
|
64
|
+
await workspace.check(project);
|
|
65
|
+
const snapshot = await workspace.inspect(project);
|
|
66
|
+
if (snapshot.branch !== run.branch || snapshot.head !== run.head)
|
|
67
|
+
throw new BlockedError("Recovery requires the original branch and commit");
|
|
68
|
+
await workspace.verify(project, run.snapshot);
|
|
69
|
+
await command("git", ["cat-file", "-e", `${run.base}^{commit}`], {
|
|
70
|
+
cwd: project.checkout,
|
|
71
|
+
signal,
|
|
72
|
+
});
|
|
73
|
+
const invocations = await store.invocations(run.id);
|
|
74
|
+
for (const invocation of invocations) {
|
|
75
|
+
if (invocation.evidence)
|
|
76
|
+
await verifyEvidence(invocation.evidence);
|
|
77
|
+
}
|
|
78
|
+
for (const path of [
|
|
79
|
+
...invocations.map((item) => item.log),
|
|
80
|
+
...(run.validation ?? []).map((item) => item.log),
|
|
81
|
+
]) {
|
|
82
|
+
try {
|
|
83
|
+
await access(path);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new BlockedError(`Recovery artifact unavailable: ${path}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const remote = (await command("git", ["ls-remote", "--heads", project.remote, `refs/heads/${run.branch}`], { cwd: project.checkout, signal })).stdout.trim();
|
|
90
|
+
if ((remote && remote.split(/\s/)[0] !== run.head) ||
|
|
91
|
+
(!remote && run.phase !== "push"))
|
|
92
|
+
throw new BlockedError("Published remote revision changed; recovery refused");
|
|
93
|
+
const change = await hosting.findChange(run.branch);
|
|
94
|
+
if (change && change.head !== run.head)
|
|
95
|
+
throw new BlockedError("Existing change request has a different head");
|
|
96
|
+
if (run.phase === "review-publication" &&
|
|
97
|
+
(!run.change || (await hosting.head(run.change)) !== run.reviewHead))
|
|
98
|
+
throw new BlockedError("Review stale: remote head changed");
|
|
99
|
+
}
|
package/dist/src/runner.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type AgentAdapter, type HostingAdapter, type Workspace } from "./domain
|
|
|
3
3
|
import { Operations } from "./operations.js";
|
|
4
4
|
import { Store } from "./store.js";
|
|
5
5
|
export interface RunnerOptions {
|
|
6
|
+
promptBaseDirectory?: string;
|
|
6
7
|
config: Configuration;
|
|
7
8
|
databaseUrl: string;
|
|
8
9
|
hosting: (project: Project) => HostingAdapter;
|
|
@@ -36,12 +37,16 @@ export declare class Runner {
|
|
|
36
37
|
private processCommands;
|
|
37
38
|
private pollDueProjects;
|
|
38
39
|
private dispatchRuns;
|
|
40
|
+
private dispatch;
|
|
39
41
|
private recordWorkflowFailure;
|
|
40
42
|
private execute;
|
|
41
43
|
private executeOwned;
|
|
42
44
|
pause(projectId: string): Promise<void>;
|
|
45
|
+
private initializeExecution;
|
|
43
46
|
resume(projectId: string): Promise<void>;
|
|
44
47
|
stop(runId: string): Promise<void>;
|
|
45
48
|
retry(runId: string, commandId?: string): Promise<string>;
|
|
49
|
+
recover(runId: string, commandId?: string): Promise<string>;
|
|
50
|
+
private checkRecoveryState;
|
|
46
51
|
shutdown(): Promise<void>;
|
|
47
52
|
}
|