@mingchuno/agent-workflows 0.1.0 → 0.3.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 (76) hide show
  1. package/README.md +37 -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/attribution.d.ts +9 -0
  7. package/dist/src/attribution.js +57 -0
  8. package/dist/src/cli.d.ts +1 -1
  9. package/dist/src/cli.js +80 -25
  10. package/dist/src/config.d.ts +24 -30
  11. package/dist/src/config.js +32 -27
  12. package/dist/src/defaults.d.ts +2 -0
  13. package/dist/src/defaults.js +2 -0
  14. package/dist/src/domain.d.ts +31 -4
  15. package/dist/src/domain.js +10 -3
  16. package/dist/src/evidence.d.ts +54 -0
  17. package/dist/src/evidence.js +214 -0
  18. package/dist/src/index.d.ts +1 -0
  19. package/dist/src/index.js +1 -0
  20. package/dist/src/invocation.d.ts +25 -0
  21. package/dist/src/invocation.js +166 -0
  22. package/dist/src/operations.d.ts +8 -2
  23. package/dist/src/operations.js +100 -136
  24. package/dist/src/prompts.d.ts +28 -0
  25. package/dist/src/prompts.js +63 -0
  26. package/dist/src/recovery.d.ts +19 -0
  27. package/dist/src/recovery.js +99 -0
  28. package/dist/src/runner.d.ts +7 -0
  29. package/dist/src/runner.js +170 -18
  30. package/dist/src/runtime/process.d.ts +2 -0
  31. package/dist/src/runtime/process.js +41 -12
  32. package/dist/src/store.d.ts +21 -2
  33. package/dist/src/store.js +122 -1
  34. package/dist/src/tui/actions.d.ts +16 -0
  35. package/dist/src/tui/actions.js +23 -0
  36. package/dist/src/tui/constants.d.ts +6 -0
  37. package/dist/src/tui/constants.js +3 -0
  38. package/dist/src/{tui-data.d.ts → tui/data.d.ts} +10 -7
  39. package/dist/src/tui/data.js +146 -0
  40. package/dist/src/tui/dialogs.d.ts +17 -0
  41. package/dist/src/tui/dialogs.js +149 -0
  42. package/dist/src/tui/format.d.ts +7 -0
  43. package/dist/src/tui/format.js +62 -0
  44. package/dist/src/tui/index.d.ts +3 -0
  45. package/dist/src/tui/index.js +2 -0
  46. package/dist/src/tui/layout.d.ts +25 -0
  47. package/dist/src/tui/layout.js +36 -0
  48. package/dist/src/tui/log-file.d.ts +26 -0
  49. package/dist/src/tui/log-file.js +156 -0
  50. package/dist/src/tui/log.d.ts +11 -0
  51. package/dist/src/tui/log.js +90 -0
  52. package/dist/src/tui/monitor.d.ts +10 -0
  53. package/dist/src/tui/monitor.js +284 -0
  54. package/dist/src/tui/notifications.d.ts +23 -0
  55. package/dist/src/tui/notifications.js +104 -0
  56. package/dist/src/tui/text.d.ts +3 -0
  57. package/dist/src/tui/text.js +10 -0
  58. package/dist/src/tui/use-log-controller.d.ts +27 -0
  59. package/dist/src/tui/use-log-controller.js +192 -0
  60. package/dist/src/tui/views.d.ts +25 -0
  61. package/dist/src/tui/views.js +327 -0
  62. package/dist/src/workspace.js +21 -8
  63. package/docs/api.md +132 -8
  64. package/docs/architecture.md +21 -4
  65. package/docs/configuration.md +181 -8
  66. package/docs/database.md +7 -0
  67. package/docs/operations.md +160 -5
  68. package/docs/providers.md +58 -2
  69. package/docs/releases.md +34 -79
  70. package/examples/config.ts +6 -6
  71. package/examples/run.ts +4 -1
  72. package/package.json +4 -2
  73. package/dist/src/tui-data.js +0 -89
  74. package/dist/src/tui.d.ts +0 -5
  75. package/dist/src/tui.js +0 -69
  76. package/docs/acceptance.md +0 -35
@@ -0,0 +1,327 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import stringWidth from "string-width";
4
+ import { recoveryUnavailable } from "../recovery.js";
5
+ import { cells, colorFor, duration, elapsedRun, executionDuration, wrapLines, } from "./format.js";
6
+ // The detail panel contributes four columns of border and padding.
7
+ const detailWideBreakpoint = 136;
8
+ const detailLabelWidth = 19;
9
+ const hangingLabelMarker = "\u{e000}";
10
+ const conciseErrorWidth = 120;
11
+ const detailColumnGap = " │ ";
12
+ function localTimestamp(value) {
13
+ if (!value)
14
+ return "not recorded";
15
+ const date = new Date(value);
16
+ if (!Number.isFinite(date.valueOf()))
17
+ return "unavailable";
18
+ return new Intl.DateTimeFormat(undefined, {
19
+ dateStyle: "medium",
20
+ timeStyle: "medium",
21
+ }).format(date);
22
+ }
23
+ function profileValue(value, key) {
24
+ if (!value || typeof value !== "object")
25
+ return undefined;
26
+ const candidate = value[key];
27
+ return typeof candidate === "string" && candidate ? candidate : undefined;
28
+ }
29
+ function readableProfile(value, fallbackProvider) {
30
+ return [
31
+ profileValue(value, "provider") ??
32
+ fallbackProvider ??
33
+ "provider unavailable",
34
+ profileValue(value, "model") ?? "model unavailable",
35
+ profileValue(value, "reasoningEffort") ?? "reasoning unavailable",
36
+ ].join(" / ");
37
+ }
38
+ function sameProfile(requested, effective, provider) {
39
+ return (readableProfile(requested, provider) ===
40
+ readableProfile(effective, provider));
41
+ }
42
+ function json(value) {
43
+ return JSON.stringify(value) ?? "unavailable";
44
+ }
45
+ function conciseError(run) {
46
+ const error = run.error ?? run.executions?.at(-1)?.error;
47
+ if (!error)
48
+ return run.outcome === "cancelled"
49
+ ? "Run was cancelled."
50
+ : run.outcome === "blocked"
51
+ ? "Run is blocked."
52
+ : "Run requires operator attention.";
53
+ const firstLine = error.split("\n", 1)[0].replace(/^Error:\s*/i, "");
54
+ const summary = cells(firstLine, conciseErrorWidth);
55
+ return cells(firstLine, conciseErrorWidth + 1) === summary
56
+ ? summary
57
+ : `${cells(firstLine, conciseErrorWidth - 1)}…`;
58
+ }
59
+ function label(label, value) {
60
+ return `${hangingLabelMarker}${label.padEnd(detailLabelWidth)}${value}`;
61
+ }
62
+ export function wrapDetailLines(lines, width) {
63
+ return lines.flatMap((line) => {
64
+ if (!line.startsWith(hangingLabelMarker))
65
+ return wrapLines([line], width);
66
+ const content = line.slice(hangingLabelMarker.length);
67
+ const prefix = content.slice(0, detailLabelWidth);
68
+ const value = content.slice(detailLabelWidth);
69
+ const wrappedValue = wrapLines([value], Math.max(1, width - detailLabelWidth));
70
+ return wrappedValue.map((part, index) => index === 0
71
+ ? `${prefix}${part}`
72
+ : `${" ".repeat(detailLabelWidth)}${part}`);
73
+ });
74
+ }
75
+ function validationLines(run, now) {
76
+ return [
77
+ "VALIDATION",
78
+ ...(run.validation?.length
79
+ ? run.validation.flatMap((check) => [
80
+ `${check.command} ${check.args.join(" ")} · exit ${check.exitCode} · ${duration(check.startedAt, check.finishedAt, now)}`,
81
+ ])
82
+ : ["not recorded"]),
83
+ ];
84
+ }
85
+ function validationSummary(run) {
86
+ const checks = run.validation ?? [];
87
+ if (!checks.length)
88
+ return "not recorded";
89
+ const successful = checks.filter((check) => check.exitCode === 0).length;
90
+ const failed = checks.length - successful;
91
+ return [
92
+ `${checks.length} recorded`,
93
+ successful ? `${successful} exit 0` : "",
94
+ failed ? `${failed} nonzero` : "",
95
+ ]
96
+ .filter(Boolean)
97
+ .join(" · ");
98
+ }
99
+ function sessionLines(sessions, now) {
100
+ return [
101
+ "AGENT SESSIONS",
102
+ ...(sessions.length
103
+ ? sessions.flatMap((session) => {
104
+ const effective = readableProfile(session.effective, session.provider);
105
+ const profile = sameProfile(session.requested, session.effective, session.provider)
106
+ ? `Effective ${effective}`
107
+ : `Requested ${readableProfile(session.requested, session.provider)} → Effective ${effective}`;
108
+ return [
109
+ `${session.step} · invocation ${session.attempt} · ${session.outcome} · ${duration(session.startedAt, session.finishedAt, now)}`,
110
+ profile,
111
+ ];
112
+ })
113
+ : ["not recorded"]),
114
+ ];
115
+ }
116
+ function executionLines(run, now) {
117
+ const executions = [...(run.executions ?? [])].reverse();
118
+ if (!executions.length)
119
+ return ["CURRENT EXECUTION", "not started"];
120
+ return executions.flatMap((execution, index) => {
121
+ const executionTime = execution.startedAt
122
+ ? executionDuration(execution, now).replace("—", "unavailable")
123
+ : "not started";
124
+ const queueWait = duration(execution.createdAt, execution.startedAt ?? execution.finishedAt, now);
125
+ const recovery = execution.recoveryOf
126
+ ? [
127
+ label("Recovery", "continued from prior execution"),
128
+ label("Recovery gap", duration(run.executions?.find((candidate) => candidate.id === execution.recoveryOf)?.finishedAt, execution.createdAt, now).replace("—", "unavailable")),
129
+ label("Reused steps", execution.reusedSteps?.length
130
+ ? execution.reusedSteps.join(", ")
131
+ : "none recorded"),
132
+ ]
133
+ : [];
134
+ const lines = index === 0
135
+ ? [
136
+ "CURRENT EXECUTION",
137
+ label("Outcome", execution.outcome),
138
+ label("Phase", execution.phase),
139
+ label("Execution duration", executionTime),
140
+ label("Queue wait", queueWait),
141
+ label("Created", localTimestamp(execution.createdAt)),
142
+ label("Started", localTimestamp(execution.startedAt)),
143
+ label("Finished", localTimestamp(execution.finishedAt)),
144
+ ...recovery,
145
+ ]
146
+ : [
147
+ `PRIOR EXECUTION ${executions.length - index}`,
148
+ `${execution.outcome} · ${execution.phase} · duration ${executionTime} · queue ${queueWait}`,
149
+ `${localTimestamp(execution.startedAt)} → ${localTimestamp(execution.finishedAt)}`,
150
+ ...recovery,
151
+ ];
152
+ return index < executions.length - 1 ? [...lines, ""] : lines;
153
+ });
154
+ }
155
+ function padToWidth(value, width) {
156
+ return `${value}${" ".repeat(Math.max(0, width - stringWidth(value)))}`;
157
+ }
158
+ function composeDetailColumns(left, right, width) {
159
+ const leftWidth = Math.floor((width - detailColumnGap.length) * 0.58);
160
+ const rightWidth = width - leftWidth - detailColumnGap.length;
161
+ const leftLines = wrapDetailLines(left, leftWidth);
162
+ const rightLines = wrapDetailLines(right, rightWidth);
163
+ return Array.from({ length: Math.max(leftLines.length, rightLines.length) }, (_, index) => `${padToWidth(leftLines[index] ?? "", leftWidth)}${detailColumnGap}${rightLines[index] ?? ""}`);
164
+ }
165
+ function stepText(event) {
166
+ if (!event)
167
+ return "No steps recorded";
168
+ const payload = event.payload;
169
+ return `${payload.name ?? "step"} · ${payload.status ?? "recorded"} · attempt ${payload.attempt ?? "—"}`;
170
+ }
171
+ export function summaryLines(run, now, event) {
172
+ return [
173
+ `#${run.issue.number} ${run.issue.title}`,
174
+ `${run.outcome.toUpperCase()} · ${run.phase}`,
175
+ `Attempt ${run.attempt} · Execution ${executionDuration(run.executions?.at(-1), now)}`,
176
+ "",
177
+ "PROGRESS",
178
+ stepText(event),
179
+ "",
180
+ "VALIDATION",
181
+ ...(run.validation?.map((check) => `${check.command}: exit ${check.exitCode}`) ?? ["No checks recorded"]),
182
+ "",
183
+ "NEXT ACTION",
184
+ ...(run.error
185
+ ? [
186
+ `Error: ${run.error.split("\n")[0]}`,
187
+ "Enter details for the full error and recovery evidence",
188
+ ]
189
+ : [
190
+ run.outcome === "running"
191
+ ? "Workflow is running"
192
+ : run.outcome === "queued"
193
+ ? "Waiting for runner and project intake"
194
+ : ["failed", "blocked", "cancelled"].includes(run.outcome)
195
+ ? "Inspect details before retry or recovery"
196
+ : "Workflow finished; inspect the outcome",
197
+ ]),
198
+ ...(run.change ? [`Change request: ${run.change.url}`] : []),
199
+ ];
200
+ }
201
+ export function detailLines(run, sessions, now, recoveryReason = recoveryUnavailable(run), width = 76, available = {
202
+ stop: false,
203
+ retry: false,
204
+ recover: false,
205
+ }) {
206
+ const validation = validationSummary(run);
207
+ const recovery = run.outcome === "failed"
208
+ ? recoveryReason
209
+ ? "restricted"
210
+ : "eligible"
211
+ : "not applicable";
212
+ const attention = ["failed", "blocked", "cancelled"].includes(run.outcome)
213
+ ? [
214
+ "",
215
+ "ATTENTION",
216
+ label("Problem", conciseError(run)),
217
+ label("Recovery", recoveryReason ?? "Eligible; runner checks still apply"),
218
+ label("Action", [
219
+ available.recover ? "c recover" : "",
220
+ available.retry ? "r retry" : "",
221
+ available.stop ? "s stop" : "",
222
+ ]
223
+ .filter(Boolean)
224
+ .join(" · ") || "none available"),
225
+ ]
226
+ : [];
227
+ const executions = executionLines(run, now);
228
+ const evidence = [
229
+ ...validationLines(run, now),
230
+ "",
231
+ ...sessionLines(sessions, now),
232
+ ];
233
+ const evidenceArea = width >= detailWideBreakpoint
234
+ ? composeDetailColumns(executions, evidence, width)
235
+ : [...executions, "", ...evidence];
236
+ const executionsByRecency = [...(run.executions ?? [])].reverse();
237
+ const executionErrors = executionsByRecency.filter((execution) => execution.error);
238
+ const diagnostics = run.error || executionErrors.length
239
+ ? [
240
+ "",
241
+ "DIAGNOSTICS",
242
+ ...(run.error ? [label("Run error", run.error)] : []),
243
+ ...executionErrors.map((execution, index) => label(index === 0 ? "Current error" : "Prior error", execution.error)),
244
+ ]
245
+ : [];
246
+ const technical = [
247
+ "",
248
+ "TECHNICAL DETAILS",
249
+ label("Run ID", run.id),
250
+ label("Issue URL", run.issue.url || "unavailable"),
251
+ label("Checkout", run.checkout || "unavailable"),
252
+ label("Task key", run.taskKey),
253
+ label("Created ISO", run.createdAt),
254
+ label("Updated ISO", run.updatedAt),
255
+ ...(run.retryOf ? [label("Retry of", run.retryOf)] : []),
256
+ ...(run.base ? [label("Base revision", run.base)] : []),
257
+ ...(run.head ? [label("Head revision", run.head)] : []),
258
+ ...(run.change ? [label("Change URL", run.change.url)] : []),
259
+ ...executionsByRecency.flatMap((execution, index) => [
260
+ "",
261
+ `Execution ${executionsByRecency.length - index}`,
262
+ label("Execution ID", execution.id),
263
+ label("Created ISO", execution.createdAt),
264
+ label("Started ISO", execution.startedAt ?? "not recorded"),
265
+ label("Finished ISO", execution.finishedAt ?? "not recorded"),
266
+ ...(execution.recoveryOf
267
+ ? [label("Recovery source", execution.recoveryOf)]
268
+ : []),
269
+ ]),
270
+ ...(run.validation?.flatMap((check, index) => [
271
+ "",
272
+ `Validation ${index + 1}`,
273
+ label("Log path", check.log),
274
+ label("Started ISO", check.startedAt),
275
+ label("Finished ISO", check.finishedAt),
276
+ ]) ?? []),
277
+ ...sessions.flatMap((session, index) => [
278
+ "",
279
+ `Agent session ${index + 1}`,
280
+ label("Invocation ID", session.id),
281
+ label("Session ID", session.sessionId ?? session.sessionState),
282
+ label("Provider", session.provider),
283
+ label("Log path", session.log),
284
+ label("Started ISO", session.startedAt),
285
+ label("Finished ISO", session.finishedAt ?? "not recorded"),
286
+ label("Requested profile", json(session.requested)),
287
+ label("Effective profile", json(session.effective)),
288
+ ]),
289
+ ];
290
+ return [
291
+ `#${run.issue.number} ${run.issue.title}`,
292
+ label("Outcome", run.outcome),
293
+ label("Phase", run.phase),
294
+ label("Attempt", String(run.attempt)),
295
+ label("Total elapsed", elapsedRun(run, now).replace("—", "unavailable")),
296
+ label("Branch", run.branch || "unavailable"),
297
+ label("Health", `Error ${run.error || run.executions?.some((execution) => execution.error) ? "recorded" : "none"} · Validation ${validation} · Recovery ${recovery}`),
298
+ ...attention,
299
+ "",
300
+ ...evidenceArea,
301
+ ...diagnostics,
302
+ ...technical,
303
+ ];
304
+ }
305
+ export function Lines({ lines, width, height, offset = 0, outcome, }) {
306
+ const wrapped = wrapDetailLines(lines, width);
307
+ const start = Math.min(offset, Math.max(0, wrapped.length - height));
308
+ const summaryOutcomeIndex = wrapped.findIndex((line) => line.startsWith("Outcome"));
309
+ return (_jsx(Box, { flexDirection: "column", width: width, height: height, overflow: "hidden", children: wrapped.slice(start, start + height).map((line, index) => {
310
+ const absoluteIndex = start + index;
311
+ const divider = line.indexOf(detailColumnGap);
312
+ const left = divider >= 0 ? line.slice(0, divider) : line;
313
+ const right = divider >= 0
314
+ ? line.slice(divider + detailColumnGap.length)
315
+ : undefined;
316
+ const isHeading = (value) => /^[A-Z][A-Z0-9 ]+$/.test(value.trim());
317
+ return (_jsx(Box, { height: 1, flexShrink: 0, children: _jsxs(Text, { wrap: "truncate", color: absoluteIndex === summaryOutcomeIndex
318
+ ? colorFor(outcome ?? "")
319
+ : undefined, children: [_jsx(Text, { bold: isHeading(left), children: left || " " }), right !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: detailColumnGap }), _jsx(Text, { bold: isHeading(right), children: right })] })) : null] }) }, `${absoluteIndex}`));
320
+ }) }));
321
+ }
322
+ export function RunList({ runs, selected, width, height, now, }) {
323
+ const count = Math.max(1, Math.floor(height / 3));
324
+ const current = Math.max(0, runs.findIndex((run) => run.id === selected));
325
+ const start = Math.max(0, Math.min(current - Math.floor(count / 2), runs.length - count));
326
+ return (_jsx(Box, { flexDirection: "column", height: height, overflow: "hidden", children: runs.length ? (runs.slice(start, start + count).map((run) => (_jsxs(Box, { flexDirection: "column", height: 3, children: [_jsx(Text, { inverse: run.id === selected, bold: run.id === selected, wrap: "truncate", children: cells(`${run.id === selected ? ">" : " "} #${run.issue.number} ${run.issue.title}`, width) }), _jsx(Text, { color: colorFor(run.outcome), wrap: "truncate", children: cells(` ${run.outcome} · ${run.phase} · ${executionDuration(run.executions?.at(-1), now)}`, width) }), _jsx(Text, { dimColor: true, children: cells(` attempt ${run.attempt}`, width) })] }, run.id)))) : (_jsx(Text, { children: "No runs yet. Waiting for eligible issues." })) }));
327
+ }
@@ -3,6 +3,12 @@ import { access, lstat, mkdir, readFile, realpath } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { BlockedError, } from "./domain.js";
5
5
  import { command } from "./runtime/process.js";
6
+ function fileStateDigest(content, mode) {
7
+ return createHash("sha256")
8
+ .update(content)
9
+ .update(String(mode))
10
+ .digest("hex");
11
+ }
6
12
  export class ExistingCheckout {
7
13
  processDirectories = new Map();
8
14
  processDirectory(project) {
@@ -97,7 +103,7 @@ export class ExistingCheckout {
97
103
  if (stat.isSymbolicLink())
98
104
  throw new BlockedError(`Changed symlink requires manual handling: ${path}`);
99
105
  const content = await readFile(absolute);
100
- files[path] = createHash("sha256").update(content).digest("hex");
106
+ files[path] = fileStateDigest(content, stat.mode);
101
107
  hash.update(path).update(content).update(String(stat.mode));
102
108
  if (untracked.split("\0").includes(path))
103
109
  fullDiff += `\n--- /dev/null\n+++ b/${path}\n${content.toString()}`;
@@ -125,12 +131,19 @@ export class ExistingCheckout {
125
131
  }
126
132
  async commit(project, expected, publication, runId, signal) {
127
133
  signal?.throwIfAborted();
134
+ const runMarkers = publication.commitMessage
135
+ .split(/\r?\n/)
136
+ .map((line) => line.trim())
137
+ .filter((line) => line.startsWith("Agent-Workflows-Run:"));
138
+ if (runMarkers.length !== 1 ||
139
+ runMarkers[0] !== `Agent-Workflows-Run: ${runId}`)
140
+ throw new BlockedError("Commit message must contain exactly one matching workflow run marker");
128
141
  const current = await this.inspect(project);
129
142
  if (current.head !== expected.head) {
130
143
  const message = await this.git(project, "log", "-1", "--format=%B");
131
144
  const parent = await this.git(project, "rev-parse", "HEAD^");
132
145
  if (parent === expected.head &&
133
- message.includes(`Agent-Workflows-Run: ${runId}`) &&
146
+ message.trimEnd() === publication.commitMessage.trimEnd() &&
134
147
  current.paths.length === 0) {
135
148
  const changed = (await this.git(project, "diff", "HEAD^", "HEAD", "--no-renames", "--name-only", "-z"))
136
149
  .split("\0")
@@ -139,7 +152,11 @@ export class ExistingCheckout {
139
152
  if (JSON.stringify(changed) !== JSON.stringify(expected.paths))
140
153
  throw new BlockedError("Reconciled commit has an unexpected change set");
141
154
  for (const [path, digest] of Object.entries(expected.files)) {
142
- const actual = await readFile(join(project.checkout, path)).then((content) => createHash("sha256").update(content).digest("hex"), (error) => {
155
+ const absolute = join(project.checkout, path);
156
+ const actual = await Promise.all([
157
+ readFile(absolute),
158
+ lstat(absolute),
159
+ ]).then(([content, stat]) => fileStateDigest(content, stat.mode), (error) => {
143
160
  if (error.code === "ENOENT")
144
161
  return null;
145
162
  throw error;
@@ -156,15 +173,11 @@ export class ExistingCheckout {
156
173
  throw new BlockedError("No changes to commit");
157
174
  await this.runGit(project, ["add", "--", ...expected.paths], { signal });
158
175
  await this.runGit(project, [
159
- "-c",
160
- `user.name=${project.gitIdentity.name}`,
161
- "-c",
162
- `user.email=${project.gitIdentity.email}`,
163
176
  "-c",
164
177
  "core.hooksPath=/dev/null",
165
178
  "commit",
166
179
  "-m",
167
- `${publication.commitMessage}\n\nAgent-Workflows-Run: ${runId}`,
180
+ publication.commitMessage,
168
181
  ], { signal });
169
182
  return this.git(project, "rev-parse", "HEAD");
170
183
  }
package/docs/api.md CHANGED
@@ -4,9 +4,15 @@ Exports are in `src/index.ts`; the built package resolves to `dist/src/index.js`
4
4
 
5
5
  ## Runner and controls
6
6
 
7
- `new Runner({config,databaseUrl,hosting,agents,workspace?,workflow?,workflowVersion?})` injects hosting/agent adapters and optionally a workspace strategy or workflow. `hosting(project)` returns a host-qualified adapter. `agents` maps provider names to adapters. The default workspace uses the existing checkout. One DBOS runtime runs per Node process; one runner owns each configuration and checkout.
7
+ `new Runner({config,databaseUrl,hosting,agents,workspace?,workflow?,workflowVersion?,pathBaseDirectory?,promptBaseDirectory?})` injects hosting/agent adapters and optionally a workspace strategy or workflow. `hosting(project)` returns a host-qualified adapter. `agents` maps provider names to adapters. The default workspace uses the existing checkout. One DBOS runtime runs per Node process; one runner owns each configuration and checkout.
8
8
 
9
- `start()` validates registration, acquires ownership, launches DBOS, registers concurrency-one project queues, and starts polling. `poll(projectId?)` performs an immediate scan. `pause(projectId)` stops new starts while active work continues. `resume(projectId)` refuses blocked checkouts. `stop(runId)` waits for the active invocation/process to end, or cancels queued work. `retry(runId)` requires a terminal failed/blocked/cancelled run and a clean checkout, then returns a new linked run ID. `shutdown()` stops intake, cancels and awaits active work, closes DBOS and releases ownership.
9
+ `pathBaseDirectory` must identify an existing directory. It resolves relative
10
+ state, checkout, and prompt-file paths once when the runner is constructed.
11
+ Omitting it preserves current-working-directory behavior for SDK callers.
12
+ `promptBaseDirectory` retains its narrower role and, when both are supplied,
13
+ overrides only relative prompt files.
14
+
15
+ `start()` validates registration, acquires ownership, launches DBOS, registers concurrency-one project queues, and starts polling. `poll(projectId?)` performs an immediate scan. `pause(projectId)` stops new starts while active work continues. `resume(projectId)` refuses blocked checkouts. `stop(runId)` waits for the active invocation/process to end, or cancels queued work. `retry(runId)` requires a terminal failed/blocked/cancelled run and a clean checkout, then returns a new linked run ID. `recover(runId)` returns a new execution ID for publication recovery of the same run. `shutdown()` stops intake, cancels and awaits active work, closes DBOS and releases ownership.
10
16
 
11
17
  Use `try/finally` to call `shutdown()`, including failed startup. A custom `workflowVersion` must change when its durable step order changes; finish existing work before replacing an incompatible version.
12
18
 
@@ -29,24 +35,117 @@ commit together; failure preserves the blocked state.
29
35
  | `prepare()` | Require clean Git state; fetch base and create unique branch |
30
36
  | `implement()` | Fresh implementation session; reject unexpected commits or branch changes |
31
37
  | `validate()` | Run commands and verify unchanged diff; false means no change |
32
- | `writePublication()` | Fresh writer session; schema-check and persist generated text |
38
+ | `writePublication()` | Validate text; finalize persisted run/co-author trailers from retained changes |
33
39
  | `commit()` / `push()` | Separate reconciled Git effects using the verified change set |
34
40
  | `publish()` | Find existing request by branch before creating a draft |
35
41
  | `review()` | Fresh read-only reviewer; exact published diff, head and validation evidence |
36
42
  | `publishReview()` | Reject stale head; reconcile review marker; map valid added-line findings |
37
43
  | `complete(outcome?)` | Require clean checkout and persist terminal outcome |
38
44
  | `step(name, operation)` | Custom durable operation receiving current `RunRecord` |
39
- | `invoke(name, stage, prompt, readOnly?)` | Custom agentic step with profile resolution and session history |
45
+ | `invoke(name, stage, task)` | Custom agentic step with profile resolution and session history |
46
+
47
+ `task` separates `defaultPrompt` from an optional `context(run)` supplier.
48
+ Optional `readOnly`, Zod `outputContract`, and captured `evidence` control runtime
49
+ checks. Stage overrides replace only `defaultPrompt`. Custom stages have no
50
+ inferred built-in default; resolve file-based custom stages before invocation
51
+ with `resolveStagePrompt(stage, defaultPrompt, baseDirectory)` if they must be
52
+ frozen alongside startup configuration. The runner resolves built-in prompt files
53
+ at construction using `promptBaseDirectory ?? pathBaseDirectory ?? process.cwd()`.
54
+
55
+ ```ts
56
+ await operations.invoke("report", stage, {
57
+ defaultPrompt: "Summarize the recorded validation.",
58
+ context: (run) => JSON.stringify(run.validation ?? []),
59
+ readOnly: true,
60
+ });
61
+ ```
62
+
63
+ An output contract enables strict response validation and one format correction
64
+ within the original deadline. Both attempts retain separate invocation/session
65
+ records under the same durable step. The returned string is validated JSON when
66
+ a contract is supplied. Codex output contracts must use its supported JSON-schema
67
+ subset: every property is required; use nullable defaults for optional locations.
68
+ Built-in review parsing accepts omitted locations and normalizes them to `null`. Interrupted calls are never automatically replayed.
40
69
 
41
70
  Put side effects inside `step`; custom effects must be idempotent or reconcile their own ambiguous results. A DBOS checkpoint does not snapshot a checkout. Returning from a custom workflow without calling a terminal operation is invalid. [Reporting workflow](../examples/custom-workflow.ts) inserts a validation report without editing provider code.
42
71
 
72
+ ## DBOS SDK direct usage
73
+
74
+ A custom `workflow(operations)` runs inside the runner's registered DBOS workflow.
75
+ It can mix predefined operations with direct SDK calls; no additional workflow
76
+ registration or DBOS runtime is needed. In a consuming application, declare
77
+ `@dbos-inc/dbos-sdk` as a direct dependency compatible with this package's SDK
78
+ version, and ensure both resolve to the same runtime instance.
79
+
80
+ This example adds a checkpointed health check against a local service before the
81
+ standard workflow. The service must expose `/health` on port 8080.
82
+
83
+ ```ts
84
+ import { DBOS } from "@dbos-inc/dbos-sdk";
85
+ import {
86
+ defaultWorkflow,
87
+ type Operations,
88
+ } from "@mingchuno/agent-workflows";
89
+
90
+ export async function customWorkflow(operations: Operations): Promise<void> {
91
+ await DBOS.runStep(
92
+ async () => {
93
+ const signal = AbortSignal.any([
94
+ operations.dependencies.signal,
95
+ AbortSignal.timeout(5_000),
96
+ ]);
97
+ signal.throwIfAborted();
98
+ const response = await fetch("http://127.0.0.1:8080/health", { signal });
99
+ await response.body?.cancel();
100
+ if (!response.ok) {
101
+ throw new Error(`Local service returned ${response.status}`);
102
+ }
103
+ },
104
+ { name: "check-local-service", retriesAllowed: false },
105
+ );
106
+
107
+ await defaultWorkflow(operations);
108
+ }
109
+ ```
110
+
111
+ In the [runner example](../examples/run.ts), import this function and replace its
112
+ `workflow` and `workflowVersion` options:
113
+
114
+ ```ts
115
+ workflow: customWorkflow,
116
+ workflowVersion: "local-health-v1",
117
+ ```
118
+
119
+ The successful check is checkpointed and skipped during recovery; it is not a
120
+ fresh health check on every restart. `defaultWorkflow` supplies the normal coding
121
+ operations and terminal outcome. See the [DBOS step API](https://docs.dbos.dev/typescript/reference/workflows-steps).
122
+
123
+ Caveats:
124
+
125
+ - `operations.step(name, callback)` adds cancellation and checkout checks, phase
126
+ updates, step events and error redaction around `DBOS.runStep`. Prefer it for
127
+ custom application operations. Raw steps retain DBOS history but bypass those
128
+ additions; pass the runner's abort signal to cancellable work, as above.
129
+ - Keep I/O, time reads and randomness inside durable steps. Call orchestration
130
+ APIs such as `DBOS.sleepms` at workflow level. Do not wrap predefined operations
131
+ or the whole workflow inside `runStep`; each operation owns its checkpoints.
132
+ - Disabling retries does not make external writes exactly-once. A crash after an
133
+ effect but before checkpointing can repeat it. Use stable idempotency keys or
134
+ reconcile ambiguous results. DBOS does not snapshot or restore checkout files.
135
+ - Finish custom paths with a terminal operation such as `operations.complete()`;
136
+ returning while a run remains queued/running is invalid.
137
+ - Change `workflowVersion` when durable step order changes, and finish or
138
+ explicitly resolve pending runs before deploying an incompatible workflow.
139
+ - Let `Runner` own `DBOS.setConfig`, `launch` and `shutdown`. Direct SDK use inside
140
+ a workflow does not grant another runtime or bypass checkout ownership rules.
141
+
43
142
  ## Extension contracts
44
143
 
45
- `Workspace` separates `check`, `prepare`, `inspect`, `verify`, `commit`, `push` and `release`. `Snapshot` contains branch/head, changed paths, diff and a content fingerprint. Never implement release by discarding files. A future isolated workspace implementation can replace this interface without changing workflow composition.
144
+ `Workspace` separates `check`, `prepare`, `inspect`, `verify`, `commit`, `push` and `release`. `Snapshot` contains branch/head, changed paths, diff and a content fingerprint. `commit` receives the already-finalized message, including attribution and run marker, and must preserve native Git identity selection. Never implement release by discarding files. A future isolated workspace implementation can replace this interface without changing workflow composition.
46
145
 
47
146
  `prepare`, `commit` and `push` receive an optional final `AbortSignal`. Custom workspaces must stop their subprocesses before settling a cancelled operation. The existing-checkout strategy journals Git processes under the Git directory; ownership acquisition rejects surviving process groups after a runner crash.
48
147
 
49
- `AgentAdapter.validate(profile)` returns observable effective settings. `invoke(input)` receives working directory, prompt/skills, read-only intent, abort signal, stage `timeoutMs` and session/event callbacks. The abort signal also covers cancellation and time spent validating the profile. Call `session(id)` immediately when available. Await event persistence; invocation must not settle until its work has stopped. SDK adapters enforce process-group lifecycle; custom adapters must uphold the same contract. `processFile` is available for controlled subprocess ownership.
148
+ `AgentAdapter.validate(profile)` returns observable effective settings. `invoke(input)` receives working directory, prompt, optional application-owned `outputSchema`, read-only intent, abort signal, stage `timeoutMs` and session/event callbacks. The abort signal also covers cancellation and time spent validating the profile. Call `session(id)` immediately when available. Await event persistence; invocation must not settle until its work has stopped. SDK adapters enforce process-group lifecycle; custom adapters must uphold the same contract. `processFile` is available for controlled subprocess ownership.
50
149
 
51
150
  `HostingAdapter` provides issue pagination/revalidation, instance-qualified `identity`, change-request lookup/create, remote head, and idempotent review publication. `preflight` is optional. Reconciliation keys must be stable across response loss; providers must never infer successful publication from agent prose.
52
151
 
@@ -59,6 +158,31 @@ and process safety check, which runs under the project lock for new admissions
59
158
  only. This callback must not mutate Store records. Operator tools should use
60
159
  `retry` commands or `Runner.retry`, preserving those safety checks.
61
160
 
62
- Invocation records include project/run IDs, stable DBOS step ID and name, invocation ID, attempt, timestamps, requested/effective profile, provider, prompt/skill snapshots, artifact path and session state (`pending`, `available`, `unavailable`). Repeated custom steps retain separate invocations. A retry has a separate run record linked to its predecessor.
161
+ Invocation records include project/run IDs, stable DBOS step ID and name, invocation ID, attempt, timestamps, requested/effective profile, provider, effective task prompt/source/hash, output-contract and evidence identities, artifact path and session state (`pending`, `available`, `unavailable`). Repeated custom steps retain separate invocations. A retry has a separate run record linked to its predecessor.
162
+
163
+ Writable invocation before/after evidence is retained on the run. Publication
164
+ finalization persists contributing provider identities in first-contribution
165
+ order, independent of custom stage names. Recovery reuses that provenance and
166
+ the finalized publication message.
167
+
168
+ `request(kind,target)` queues the same `pause`, `resume`, `stop`, `retry`, or `recover` commands used by the CLI/TUI; `commands()` reports pending/success/failure. A runner must be active to execute them. `finishCommand` and record-writing methods support adapters and custom workflows; operator tools should prefer commands over direct mutation.
169
+
170
+ ## Publication recovery
171
+
172
+ `Runner.recover(runId, commandId?)` supports failed publication steps in the
173
+ default workflow. It validates the source execution, checkout, configuration,
174
+ artifacts and remote state. `Store.admitRecovery` commits intent and its event
175
+ under the same project lock as retry admission; its safety callback must not
176
+ mutate Store records. Operator tools should use the runner or queued commands.
177
+
178
+ `RunRecord.executions` contains the initial execution and recovery executions,
179
+ including DBOS IDs, source execution, restart step, reused steps, configuration
180
+ fingerprint and outcomes. Run IDs remain stable for invocations and publication
181
+ markers. `Store.recoveryPlan(runId)` reports persisted eligibility and its reason;
182
+ live safety checks happen at admission and execution. Runs without execution
183
+ metadata remain readable and retryable, but cannot be recovered.
63
184
 
64
- `request(kind,target)` queues the same `pause`, `resume`, `stop`, or `retry` commands used by the CLI/TUI; `commands()` reports pending/success/failure. A runner must be active to execute them. `finishCommand` and record-writing methods support adapters and custom workflows; operator tools should prefer commands over direct mutation.
185
+ Recovery uses DBOS forks, retaining the original workflow input and checkpoint
186
+ prefix. The accepted command ID is the fork ID: dispatch adopts an existing fork
187
+ after an uncertain response or crash. Copied start gates do not replace live
188
+ checks in the first non-replayed operation. Successful prefixes cannot rerun.
@@ -5,20 +5,37 @@ DBOS owns workflow execution, durable steps and concurrency-one project queues.
5
5
  - `config.ts` / `domain.ts`: validated configuration, vocabulary and adapter contracts.
6
6
  - `runner.ts`: local ownership, intake/deduplication, DBOS lifecycle and operator controls.
7
7
  - `operations.ts`: reusable durable coding operations and the default workflow.
8
+ - `prompts.ts` / `invocation.ts`: resolved task text, output contracts and bounded format correction.
9
+ - `evidence.ts`: indexed, hashed change artifacts and capture limits.
10
+ - `recovery.ts`: publication recovery eligibility, input fingerprints and live safety checks.
8
11
  - `workspace.ts`: existing-checkout Git operations and change verification.
9
12
  - `store.ts`: typed Drizzle queries for run, invocation, project, command and event records; `db/schema.ts` and `drizzle/` own the application schema and migrations.
10
13
  - `adapters/`: provider clients and isolated SDK workers.
11
14
  - `runtime/`: process groups, ownership journals and redacted logging.
12
- - `cli.ts` / `tui.tsx`: shared command/query interfaces.
15
+ - `cli.ts` / `tui/`: shared command/query interfaces.
13
16
 
14
- Application records live in `agent_workflows`; DBOS maintains its own execution schema in the same PostgreSQL database. Large streamed agent/validation logs live under the configured state directory, referenced by records. Agent calls are never automatically retried. Publication retries reconcile external state first. Clean terminal state and terminal workflow outcome are deliberately separate.
17
+ Application records live in `agent_workflows`; DBOS maintains its own execution schema in the same PostgreSQL database. Large streamed agent/validation logs live under the configured state directory, referenced by records. Interrupted or failed agent calls are never automatically retried. A returned response that fails its output contract may receive one fresh inspection-only format-correction attempt within the same stage deadline. Publication retries and explicit recovery reconcile external state first. Clean terminal state and terminal workflow outcome are deliberately separate.
15
18
 
16
19
  Node/PostgreSQL/Git are the only runtime infrastructure; providers require their normal local authentication. Zod, Commander, Ink/React, Drizzle/node-postgres, Pino, Octokit and Gitbeaker handle standard infrastructure. Drizzle ORM and Codex SDK are Apache-2.0; the other listed runtime libraries and Copilot SDK are MIT-licensed. Exact dependency versions are pinned by the lockfile. No custom HTTP client, CLI parser or terminal renderer is introduced.
17
20
 
18
21
  The test boundary is the public runner/workflow API using real PostgreSQL, real temporary Git repositories and controlled adapters. Separate adapter contracts exercise SDK argument/event mapping and HTTP behavior. Process-level recovery tests terminate a runner after external effects and restart it against the same state. Runtime/provider smoke calls are intentionally separate from deterministic acceptance tests.
19
22
 
20
- Phase 1 supports existing checkouts only. Higher per-project concurrency requires isolated workspaces and lifecycle design; changing the DBOS queue limit alone is unsafe.
23
+ The runner supports existing checkouts only. Higher per-project concurrency requires isolated workspaces and lifecycle design; changing the DBOS queue limit alone is unsafe.
21
24
 
22
25
  ## Package boundary
23
26
 
24
- Keep one package while the SDK, CLI and TUI share a runtime, schema and release cycle. `src/adapters`, `src/runtime` and `src/db` provide internal boundaries without workspace packages. Split into a monorepo when a separately deployed app or independently versioned package needs its own dependencies and build. `pnpm-workspace.yaml` currently configures installation policy only.
27
+ Keep one package while the SDK, CLI and TUI share a runtime, schema and release cycle. `src/adapters`, `src/runtime`, `src/db` and `src/tui` provide internal boundaries without workspace packages. Split into a monorepo when a separately deployed app or independently versioned package needs its own dependencies and build. `pnpm-workspace.yaml` currently configures installation policy only.
28
+
29
+ ## Run and execution identity
30
+
31
+ A run owns the branch, commit and publication markers. Its initial DBOS execution
32
+ uses the run ID; publication recovery forks the failed execution at its failed
33
+ step under a new execution ID, preserving completed checkpoints and the original
34
+ run input. Execution history stays in the run record. Fresh retry creates a new
35
+ run and branch.
36
+
37
+ Recovery admission and retry share a project lock. Admission persists the fork ID
38
+ before dispatch so a restarted runner can adopt an existing fork. Recovery gates
39
+ run inside the first operation that actually executes, avoiding copied pause and
40
+ safety decisions. Recovery is limited to the default workflow's publication
41
+ steps; interrupted agents still require manual inspection.