@mingchuno/agent-workflows 0.1.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 (62) hide show
  1. package/LICENCE +21 -0
  2. package/README.md +74 -0
  3. package/dist/drizzle/0000_initial.sql +45 -0
  4. package/dist/drizzle/meta/0000_snapshot.json +264 -0
  5. package/dist/drizzle/meta/_journal.json +13 -0
  6. package/dist/src/adapters/agent-worker.d.ts +1 -0
  7. package/dist/src/adapters/agent-worker.js +16 -0
  8. package/dist/src/adapters/agents.d.ts +24 -0
  9. package/dist/src/adapters/agents.js +142 -0
  10. package/dist/src/adapters/hosting.d.ts +33 -0
  11. package/dist/src/adapters/hosting.js +275 -0
  12. package/dist/src/adapters/sdk-protocol.d.ts +43 -0
  13. package/dist/src/adapters/sdk-protocol.js +64 -0
  14. package/dist/src/cli.d.ts +2 -0
  15. package/dist/src/cli.js +175 -0
  16. package/dist/src/config.d.ts +224 -0
  17. package/dist/src/config.js +82 -0
  18. package/dist/src/db/locks.d.ts +4 -0
  19. package/dist/src/db/locks.js +14 -0
  20. package/dist/src/db/migrate.d.ts +1 -0
  21. package/dist/src/db/migrate.js +12 -0
  22. package/dist/src/db/migrations.d.ts +2 -0
  23. package/dist/src/db/migrations.js +22 -0
  24. package/dist/src/db/schema.d.ts +486 -0
  25. package/dist/src/db/schema.js +46 -0
  26. package/dist/src/domain.d.ts +133 -0
  27. package/dist/src/domain.js +24 -0
  28. package/dist/src/index.d.ts +8 -0
  29. package/dist/src/index.js +8 -0
  30. package/dist/src/operations.d.ts +35 -0
  31. package/dist/src/operations.js +378 -0
  32. package/dist/src/run-record.d.ts +7 -0
  33. package/dist/src/run-record.js +19 -0
  34. package/dist/src/runner.d.ts +47 -0
  35. package/dist/src/runner.js +370 -0
  36. package/dist/src/runtime/ownership.d.ts +8 -0
  37. package/dist/src/runtime/ownership.js +84 -0
  38. package/dist/src/runtime/process.d.ts +18 -0
  39. package/dist/src/runtime/process.js +98 -0
  40. package/dist/src/runtime/redaction.d.ts +8 -0
  41. package/dist/src/runtime/redaction.js +33 -0
  42. package/dist/src/store.d.ts +87 -0
  43. package/dist/src/store.js +355 -0
  44. package/dist/src/tui-data.d.ts +25 -0
  45. package/dist/src/tui-data.js +89 -0
  46. package/dist/src/tui.d.ts +5 -0
  47. package/dist/src/tui.js +69 -0
  48. package/dist/src/workspace.d.ts +16 -0
  49. package/dist/src/workspace.js +186 -0
  50. package/docs/acceptance.md +35 -0
  51. package/docs/api.md +64 -0
  52. package/docs/architecture.md +24 -0
  53. package/docs/configuration.md +41 -0
  54. package/docs/database.md +28 -0
  55. package/docs/operations.md +46 -0
  56. package/docs/providers.md +49 -0
  57. package/docs/releases.md +89 -0
  58. package/examples/config.ts +57 -0
  59. package/examples/custom-workflow.ts +32 -0
  60. package/examples/observe.ts +18 -0
  61. package/examples/run.ts +31 -0
  62. package/package.json +78 -0
@@ -0,0 +1,8 @@
1
+ export * from "./adapters/agents.js";
2
+ export * from "./adapters/hosting.js";
3
+ export * from "./config.js";
4
+ export * from "./domain.js";
5
+ export * from "./operations.js";
6
+ export * from "./runner.js";
7
+ export * from "./store.js";
8
+ export * from "./workspace.js";
@@ -0,0 +1,35 @@
1
+ import type { Project, Stage } from "./config.js";
2
+ import { type AgentAdapter, type HostingAdapter, type RunRecord, type Workspace } from "./domain.js";
3
+ import type { Store } from "./store.js";
4
+ export interface OperationDependencies {
5
+ store: Store;
6
+ project: Project;
7
+ workspace: Workspace;
8
+ hosting: HostingAdapter;
9
+ agents: Record<string, AgentAdapter>;
10
+ artifacts: string;
11
+ signal: AbortSignal;
12
+ redact: (text: string) => string;
13
+ }
14
+ /** Reusable durable coding operations. Call from a registered DBOS workflow. */
15
+ export declare class Operations {
16
+ readonly runId: string;
17
+ readonly dependencies: OperationDependencies;
18
+ constructor(runId: string, dependencies: OperationDependencies);
19
+ step<T>(name: string, operation: (run: RunRecord) => Promise<T>): Promise<T>;
20
+ eligible(): Promise<boolean>;
21
+ prepare(): Promise<void>;
22
+ invoke(name: string, stage: Stage, prompt: (run: RunRecord) => string, readOnly?: boolean): Promise<string>;
23
+ private prepareInvocationPrompt;
24
+ private saveImplementationSnapshot;
25
+ implement(): Promise<void>;
26
+ validate(): Promise<boolean>;
27
+ writePublication(): Promise<void>;
28
+ commit(): Promise<void>;
29
+ push(): Promise<void>;
30
+ publish(): Promise<void>;
31
+ review(): Promise<void>;
32
+ publishReview(): Promise<void>;
33
+ complete(outcome?: RunRecord["outcome"]): Promise<void>;
34
+ }
35
+ export declare function defaultWorkflow(operations: Operations): Promise<void>;
@@ -0,0 +1,378 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { DBOS } from "@dbos-inc/dbos-sdk";
5
+ import { resolveProfile } from "./config.js";
6
+ import { BlockedError, isBlockedError, publicationSchema, reviewSchema, } from "./domain.js";
7
+ import { command } from "./runtime/process.js";
8
+ /** Reusable durable coding operations. Call from a registered DBOS workflow. */
9
+ export class Operations {
10
+ runId;
11
+ dependencies;
12
+ constructor(runId, dependencies) {
13
+ this.runId = runId;
14
+ this.dependencies = dependencies;
15
+ }
16
+ async step(name, operation) {
17
+ return DBOS.runStep(async () => {
18
+ const { store, signal } = this.dependencies;
19
+ signal.throwIfAborted();
20
+ const run = await store.run(this.runId);
21
+ await store.patchRun(this.runId, { phase: name });
22
+ await store.emit(this.runId, "step", {
23
+ name,
24
+ stepId: DBOS.stepID,
25
+ attempt: DBOS.stepStatus?.currentAttempt ?? 1,
26
+ status: "running",
27
+ });
28
+ try {
29
+ if (run.checkout !== this.dependencies.project.checkout)
30
+ throw new BlockedError("Configured checkout differs from the recorded run checkout");
31
+ const result = await operation(run);
32
+ await store.emit(this.runId, "step", {
33
+ name,
34
+ stepId: DBOS.stepID,
35
+ attempt: DBOS.stepStatus?.currentAttempt ?? 1,
36
+ status: "completed",
37
+ });
38
+ return result;
39
+ }
40
+ catch (error) {
41
+ await store.emit(this.runId, "step", {
42
+ name,
43
+ stepId: DBOS.stepID,
44
+ attempt: DBOS.stepStatus?.currentAttempt ?? 1,
45
+ status: "failed",
46
+ error: this.dependencies.redact(String(error)),
47
+ });
48
+ const message = this.dependencies.redact(error instanceof Error ? error.message : String(error));
49
+ throw isBlockedError(error)
50
+ ? new BlockedError(message)
51
+ : new Error(message);
52
+ }
53
+ }, {
54
+ name,
55
+ retriesAllowed: [
56
+ "push",
57
+ "change-request",
58
+ "review-publication",
59
+ ].includes(name),
60
+ maxAttempts: 3,
61
+ intervalSeconds: 0.2,
62
+ backoffRate: 2,
63
+ shouldRetry: (error) => !isBlockedError(error),
64
+ });
65
+ }
66
+ async eligible() {
67
+ return this.step("eligibility", async (run) => {
68
+ const { hosting, project } = this.dependencies;
69
+ const issue = await hosting.getIssue(run.issue.number);
70
+ return (issue.open &&
71
+ project.labels.every((label) => issue.labels.includes(label)));
72
+ });
73
+ }
74
+ async prepare() {
75
+ await this.step("prepare", async (run) => {
76
+ const { workspace, project, store } = this.dependencies;
77
+ const snapshot = await workspace.prepare(project, run.branch, this.dependencies.signal);
78
+ await store.patchRun(run.id, {
79
+ base: snapshot.head,
80
+ snapshot,
81
+ outcome: "running",
82
+ });
83
+ });
84
+ }
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
+ };
193
+ }));
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
+ }
201
+ async saveImplementationSnapshot(runId, expected) {
202
+ const { workspace, project, store } = this.dependencies;
203
+ const snapshot = await workspace.inspect(project);
204
+ if (snapshot.head !== expected.head || snapshot.branch !== expected.branch)
205
+ throw new BlockedError("Agent changed branch or committed unexpectedly");
206
+ await store.patchRun(runId, { snapshot });
207
+ }
208
+ 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}`);
210
+ }
211
+ async validate() {
212
+ return this.step("validation", async (run) => {
213
+ const { project, workspace, store, signal, redact } = this.dependencies;
214
+ if (!run.snapshot)
215
+ throw new BlockedError("Missing implementation snapshot");
216
+ await workspace.verify(project, run.snapshot);
217
+ if (!run.snapshot.paths.length)
218
+ return false;
219
+ const validation = [];
220
+ const directory = join(this.dependencies.artifacts, run.id);
221
+ await mkdir(directory, { recursive: true, mode: 0o700 });
222
+ for (const [index, check] of project.validation.entries()) {
223
+ const startedAt = new Date().toISOString();
224
+ const log = join(directory, `validation-${index}.log`);
225
+ let captured = "";
226
+ let result;
227
+ try {
228
+ result = await command(check.command, check.args, {
229
+ cwd: project.checkout,
230
+ signal,
231
+ processFile: log + ".process.json",
232
+ timeoutMs: check.timeoutMs,
233
+ allowFailure: true,
234
+ onOutput: (chunk) => {
235
+ captured += chunk;
236
+ },
237
+ });
238
+ }
239
+ catch (error) {
240
+ await appendFile(log, redact(captured + "\n" + String(error)), {
241
+ mode: 0o600,
242
+ });
243
+ validation.push({
244
+ ...check,
245
+ exitCode: -1,
246
+ log,
247
+ startedAt,
248
+ finishedAt: new Date().toISOString(),
249
+ });
250
+ await store.patchRun(run.id, { validation });
251
+ throw error;
252
+ }
253
+ await appendFile(log, redact(captured), { mode: 0o600 });
254
+ validation.push({
255
+ ...check,
256
+ exitCode: result.exitCode,
257
+ log,
258
+ startedAt,
259
+ finishedAt: new Date().toISOString(),
260
+ });
261
+ await store.patchRun(run.id, { validation });
262
+ if (result.exitCode !== 0)
263
+ throw new Error(`Validation failed: ${check.command} (exit ${result.exitCode})`);
264
+ }
265
+ await workspace.verify(project, run.snapshot);
266
+ return true;
267
+ });
268
+ }
269
+ 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);
271
+ await this.step("publication-content", async (run) => {
272
+ await this.dependencies.store.patchRun(run.id, {
273
+ publication: publicationSchema.parse(JSON.parse(output)),
274
+ });
275
+ });
276
+ }
277
+ async commit() {
278
+ await this.step("commit", async (run) => {
279
+ if (!run.snapshot || !run.publication)
280
+ throw new Error("Missing validated publication input");
281
+ if (run.head) {
282
+ await this.dependencies.workspace.verify(this.dependencies.project, run.snapshot);
283
+ return;
284
+ }
285
+ const head = await this.dependencies.workspace.commit(this.dependencies.project, run.snapshot, run.publication, run.id, this.dependencies.signal);
286
+ const snapshot = await this.dependencies.workspace.inspect(this.dependencies.project);
287
+ await this.dependencies.store.patchRun(run.id, { head, snapshot });
288
+ });
289
+ }
290
+ async push() {
291
+ await this.step("push", async (run) => {
292
+ if (!run.head)
293
+ throw new Error("Missing commit");
294
+ await this.dependencies.workspace.push(this.dependencies.project, run.branch, run.head, this.dependencies.signal);
295
+ });
296
+ }
297
+ async publish() {
298
+ await this.step("change-request", async (run) => {
299
+ const { hosting, project, store } = this.dependencies;
300
+ if (!run.head || !run.publication)
301
+ throw new Error("Missing publication");
302
+ const change = (await hosting.findChange(run.branch)) ??
303
+ (await hosting.createChange({
304
+ branch: run.branch,
305
+ base: project.baseBranch,
306
+ head: run.head,
307
+ issue: run.issue,
308
+ publication: run.publication,
309
+ runId: run.id,
310
+ }));
311
+ if (change.head !== run.head)
312
+ throw new BlockedError("Existing change request has a different head");
313
+ await store.patchRun(run.id, { change });
314
+ });
315
+ }
316
+ 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;
323
+ });
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
+ await this.step("review-content", async (run) => {
326
+ await this.dependencies.store.patchRun(run.id, {
327
+ review: reviewSchema.parse(JSON.parse(output)),
328
+ reviewHead: run.head,
329
+ });
330
+ });
331
+ }
332
+ async publishReview() {
333
+ await this.step("review-publication", async (run) => {
334
+ const { hosting, project } = this.dependencies;
335
+ if (!run.change || !run.review || !run.reviewHead || !run.base)
336
+ throw new Error("Missing review");
337
+ if ((await hosting.head(run.change)) !== run.reviewHead)
338
+ throw new BlockedError("Review stale: remote head changed");
339
+ const diff = (await command("git", ["diff", "--unified=0", run.base, run.reviewHead], { cwd: project.checkout })).stdout;
340
+ await hosting.publishReview({
341
+ change: run.change,
342
+ head: run.reviewHead,
343
+ review: run.review,
344
+ runId: run.id,
345
+ diff,
346
+ });
347
+ });
348
+ }
349
+ async complete(outcome = "completed") {
350
+ await this.step("completion", async (run) => {
351
+ await this.dependencies.workspace.release(this.dependencies.project);
352
+ await this.dependencies.store.patchRun(run.id, { outcome });
353
+ });
354
+ }
355
+ }
356
+ export async function defaultWorkflow(operations) {
357
+ if (!(await operations.eligible())) {
358
+ await operations.step("ineligible", async (run) => {
359
+ await operations.dependencies.store.patchRun(run.id, {
360
+ outcome: "ineligible",
361
+ });
362
+ });
363
+ return;
364
+ }
365
+ await operations.prepare();
366
+ await operations.implement();
367
+ if (!(await operations.validate())) {
368
+ await operations.complete("no-change");
369
+ return;
370
+ }
371
+ await operations.writePublication();
372
+ await operations.commit();
373
+ await operations.push();
374
+ await operations.publish();
375
+ await operations.review();
376
+ await operations.publishReview();
377
+ await operations.complete();
378
+ }
@@ -0,0 +1,7 @@
1
+ import type { RunRecord } from "./domain.js";
2
+ type QueuedRunInput = Pick<RunRecord, "id" | "projectId" | "checkout" | "taskKey" | "attempt" | "issue" | "retryOf"> & {
3
+ now: string;
4
+ branchTemplate: string;
5
+ };
6
+ export declare function createQueuedRun(input: QueuedRunInput): RunRecord;
7
+ export {};
@@ -0,0 +1,19 @@
1
+ export function createQueuedRun(input) {
2
+ return {
3
+ id: input.id,
4
+ projectId: input.projectId,
5
+ checkout: input.checkout,
6
+ taskKey: input.taskKey,
7
+ attempt: input.attempt,
8
+ ...(input.retryOf === undefined ? {} : { retryOf: input.retryOf }),
9
+ issue: input.issue,
10
+ outcome: "queued",
11
+ phase: "queued",
12
+ createdAt: input.now,
13
+ updatedAt: input.now,
14
+ branch: input.branchTemplate
15
+ .replaceAll("{issue}", String(input.issue.number))
16
+ .replaceAll("{attempt}", String(input.attempt))
17
+ .replaceAll("{run}", input.id),
18
+ };
19
+ }
@@ -0,0 +1,47 @@
1
+ import { type Configuration, type Project } from "./config.js";
2
+ import { type AgentAdapter, type HostingAdapter, type Workspace } from "./domain.js";
3
+ import { Operations } from "./operations.js";
4
+ import { Store } from "./store.js";
5
+ export interface RunnerOptions {
6
+ config: Configuration;
7
+ databaseUrl: string;
8
+ hosting: (project: Project) => HostingAdapter;
9
+ agents: Record<string, AgentAdapter>;
10
+ workflowVersion?: string;
11
+ workspace?: Workspace;
12
+ workflow?: (operations: Operations) => Promise<void>;
13
+ }
14
+ export declare class Runner {
15
+ readonly options: RunnerOptions;
16
+ readonly store: Store;
17
+ readonly config: Configuration;
18
+ private readonly controllers;
19
+ private readonly active;
20
+ private readonly hosting;
21
+ private workflow;
22
+ private readonly ownership;
23
+ private stopping;
24
+ private ownsRuntime;
25
+ private tickBusy;
26
+ private timer?;
27
+ private readonly lastPoll;
28
+ private readonly polling;
29
+ constructor(options: RunnerOptions);
30
+ private queue;
31
+ start(): Promise<void>;
32
+ private redact;
33
+ poll(projectId?: string): Promise<void>;
34
+ private pollProject;
35
+ private tick;
36
+ private processCommands;
37
+ private pollDueProjects;
38
+ private dispatchRuns;
39
+ private recordWorkflowFailure;
40
+ private execute;
41
+ private executeOwned;
42
+ pause(projectId: string): Promise<void>;
43
+ resume(projectId: string): Promise<void>;
44
+ stop(runId: string): Promise<void>;
45
+ retry(runId: string, commandId?: string): Promise<string>;
46
+ shutdown(): Promise<void>;
47
+ }