@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.
Files changed (39) hide show
  1. package/README.md +25 -14
  2. package/dist/src/adapters/agents.js +9 -0
  3. package/dist/src/cli-config.d.ts +2 -0
  4. package/dist/src/cli-config.js +35 -0
  5. package/dist/src/cli.js +22 -24
  6. package/dist/src/config.d.ts +10 -0
  7. package/dist/src/config.js +9 -11
  8. package/dist/src/domain.d.ts +6 -0
  9. package/dist/src/invocation.d.ts +2 -4
  10. package/dist/src/invocation.js +204 -107
  11. package/dist/src/operations.d.ts +1 -2
  12. package/dist/src/operations.js +18 -33
  13. package/dist/src/recovery.d.ts +20 -2
  14. package/dist/src/recovery.js +49 -1
  15. package/dist/src/runner.d.ts +6 -2
  16. package/dist/src/runner.js +81 -83
  17. package/dist/src/runtime/process.d.ts +1 -0
  18. package/dist/src/runtime/process.js +6 -2
  19. package/dist/src/store.d.ts +4 -2
  20. package/dist/src/store.js +69 -51
  21. package/dist/src/tui/data.d.ts +1 -1
  22. package/dist/src/tui/dialogs.js +1 -0
  23. package/dist/src/tui/monitor-navigation.d.ts +76 -0
  24. package/dist/src/tui/monitor-navigation.js +187 -0
  25. package/dist/src/tui/monitor.js +70 -183
  26. package/dist/src/tui/projection.d.ts +22 -0
  27. package/dist/src/tui/projection.js +49 -0
  28. package/dist/src/validation-selection.d.ts +6 -0
  29. package/dist/src/validation-selection.js +29 -0
  30. package/dist/src/workspace.js +3 -2
  31. package/docs/api.md +11 -7
  32. package/docs/configuration.md +61 -18
  33. package/docs/database.md +2 -18
  34. package/docs/operations.md +21 -28
  35. package/docs/providers.md +2 -2
  36. package/package.json +1 -1
  37. package/dist/src/tui/actions.d.ts +0 -16
  38. package/dist/src/tui/actions.js +0 -23
  39. package/docs/architecture.md +0 -41
@@ -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({ run, name, stage, task, stepId, dependencies, saveImplementationSnapshot, acceptContribution, }) {
12
- const { store, project, agents, signal, workspace, redact } = dependencies;
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
- if (!run.snapshot)
27
- throw new BlockedError("Missing workspace snapshot");
28
- await workspace.verify(project, run.snapshot);
29
- if (task.evidence)
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
- let correction = "";
59
- let pendingContribution;
60
- for (let attempt = 1; attempt <= maxInvocationAttempts; attempt++) {
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
- const expected = (await store.run(run.id)).snapshot;
63
- await workspace.verify(project, expected);
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
- const id = randomUUID();
67
- const prompt = fullPrompt + correction;
68
- const readOnly = task.readOnly === true || attempt === maxInvocationAttempts;
69
- const record = {
70
- id,
71
- runId: run.id,
72
- projectId: project.id,
73
- step: name,
74
- stepId,
75
- attempt,
76
- provider: profile.provider,
77
- sessionId: null,
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
- try {
93
- invocationSignal.throwIfAborted();
94
- const output = await adapter.invoke({
95
- id,
96
- runId: run.id,
97
- step: name,
98
- cwd: project.checkout,
99
- prompt,
100
- profile,
101
- outputSchema,
102
- processFile: record.log + ".process.json",
103
- readOnly,
104
- signal: invocationSignal,
105
- timeoutMs: Math.max(1, deadline - Date.now()),
106
- session: async (sessionId) => {
107
- record.sessionId = sessionId;
108
- record.sessionState = "available";
109
- await store.saveInvocation(record);
110
- },
111
- event: async (event) => {
112
- await appendFile(record.log, redact(JSON.stringify(event)) + "\n", {
113
- mode: 0o600,
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
- await appendFile(record.log, "", { mode: 0o600 });
118
- invocationSignal.throwIfAborted();
119
- if (readOnly)
120
- await workspace.verify(project, expected);
121
- else
122
- pendingContribution = await saveImplementationSnapshot(run.id, expected, profile.provider);
123
- if (task.evidence)
124
- await verifyEvidence(task.evidence);
125
- invocationSignal.throwIfAborted();
126
- // Only returned output validation failures qualify for correction.
127
- let parsed;
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
- throw new Error("Format correction exhausted");
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
  }
@@ -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>;
@@ -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
- return (issue.open &&
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, store, signal, redact } = this.dependencies;
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
- for (const [index, check] of project.validation.entries()) {
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
- validation.push({
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
- validation.push({
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;
@@ -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
- /** Read-only checks shared by admission and the first non-replayed operation. */
18
- export declare function verifyPublicationRecovery(run: RunRecord, dependencies: RecoveryDependencies): Promise<void>;
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 {};
@@ -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
- export async function verifyPublicationRecovery(run, dependencies) {
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");
@@ -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): Promise<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 checkRecoveryState;
56
+ private projectForRun;
53
57
  shutdown(): Promise<void>;
54
58
  }