@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.
Files changed (70) hide show
  1. package/README.md +32 -6
  2. package/dist/src/adapters/agents.js +6 -3
  3. package/dist/src/adapters/hosting.js +16 -10
  4. package/dist/src/adapters/sdk-protocol.d.ts +3 -3
  5. package/dist/src/adapters/sdk-protocol.js +9 -7
  6. package/dist/src/cli.d.ts +1 -1
  7. package/dist/src/cli.js +40 -19
  8. package/dist/src/config.d.ts +22 -22
  9. package/dist/src/config.js +31 -26
  10. package/dist/src/defaults.d.ts +2 -0
  11. package/dist/src/defaults.js +2 -0
  12. package/dist/src/domain.d.ts +24 -4
  13. package/dist/src/domain.js +10 -3
  14. package/dist/src/evidence.d.ts +54 -0
  15. package/dist/src/evidence.js +214 -0
  16. package/dist/src/index.d.ts +1 -0
  17. package/dist/src/index.js +1 -0
  18. package/dist/src/invocation.d.ts +24 -0
  19. package/dist/src/invocation.js +163 -0
  20. package/dist/src/operations.d.ts +7 -2
  21. package/dist/src/operations.js +76 -134
  22. package/dist/src/prompts.d.ts +28 -0
  23. package/dist/src/prompts.js +63 -0
  24. package/dist/src/recovery.d.ts +19 -0
  25. package/dist/src/recovery.js +99 -0
  26. package/dist/src/runner.d.ts +5 -0
  27. package/dist/src/runner.js +145 -18
  28. package/dist/src/runtime/process.d.ts +2 -0
  29. package/dist/src/runtime/process.js +41 -12
  30. package/dist/src/store.d.ts +21 -2
  31. package/dist/src/store.js +122 -1
  32. package/dist/src/tui/actions.d.ts +16 -0
  33. package/dist/src/tui/actions.js +23 -0
  34. package/dist/src/tui/constants.d.ts +6 -0
  35. package/dist/src/tui/constants.js +3 -0
  36. package/dist/src/{tui-data.d.ts → tui/data.d.ts} +8 -6
  37. package/dist/src/tui/data.js +141 -0
  38. package/dist/src/tui/dialogs.d.ts +17 -0
  39. package/dist/src/tui/dialogs.js +149 -0
  40. package/dist/src/tui/format.d.ts +7 -0
  41. package/dist/src/tui/format.js +62 -0
  42. package/dist/src/tui/index.d.ts +2 -0
  43. package/dist/src/tui/index.js +1 -0
  44. package/dist/src/tui/layout.d.ts +25 -0
  45. package/dist/src/tui/layout.js +36 -0
  46. package/dist/src/tui/log-file.d.ts +26 -0
  47. package/dist/src/tui/log-file.js +156 -0
  48. package/dist/src/tui/log.d.ts +11 -0
  49. package/dist/src/tui/log.js +90 -0
  50. package/dist/src/tui/monitor.d.ts +8 -0
  51. package/dist/src/tui/monitor.js +222 -0
  52. package/dist/src/tui/text.d.ts +3 -0
  53. package/dist/src/tui/text.js +10 -0
  54. package/dist/src/tui/use-log-controller.d.ts +27 -0
  55. package/dist/src/tui/use-log-controller.js +192 -0
  56. package/dist/src/tui/views.d.ts +17 -0
  57. package/dist/src/tui/views.js +97 -0
  58. package/docs/api.md +119 -6
  59. package/docs/architecture.md +21 -4
  60. package/docs/configuration.md +137 -5
  61. package/docs/database.md +7 -0
  62. package/docs/operations.md +117 -2
  63. package/docs/providers.md +58 -2
  64. package/docs/releases.md +34 -79
  65. package/examples/config.ts +2 -2
  66. package/package.json +4 -2
  67. package/dist/src/tui-data.js +0 -89
  68. package/dist/src/tui.d.ts +0 -5
  69. package/dist/src/tui.js +0 -69
  70. package/docs/acceptance.md +0 -35
@@ -1,10 +1,14 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { appendFile, mkdir, readFile } from "node:fs/promises";
3
- import { join, resolve } from "node:path";
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
- "push",
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, prompt, readOnly = false) {
86
- return this.step(name, async (run) => {
87
- const { store, project, agents, signal, workspace, redact } = this.dependencies;
88
- const previous = (await store.invocations(run.id)).filter((invocation) => invocation.stepId === DBOS.stepID);
89
- if (previous.length) {
90
- for (const invocation of previous) {
91
- if (invocation.outcome === "running") {
92
- invocation.outcome = "interrupted";
93
- invocation.finishedAt = new Date().toISOString();
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, (run) => `Implement the following issue in the current checkout. Do not commit, push, or publish.\n${run.issue.title}\n${run.issue.body}`);
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 output = await this.invoke("writing", this.dependencies.project.stages.writing, (run) => `Return ONLY JSON with commitMessage, title, description (all nonempty strings). Describe actual changes and exact validation; do not claim unrun checks. Do not modify files.\nIssue: ${JSON.stringify(run.issue)}\nDiff: ${run.snapshot?.diff}\nValidation: ${JSON.stringify(run.validation ?? [])}`, true);
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 diff = await this.step("review-input", async (run) => {
318
- if (!run.base || !run.head)
319
- throw new Error("Missing published revision");
320
- return (await command("git", ["diff", "--no-ext-diff", run.base, run.head], {
321
- cwd: this.dependencies.project.checkout,
322
- })).stdout;
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: reviewSchema.parse(JSON.parse(output)),
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
+ }
@@ -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
  }