@mingchuno/agent-workflows 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,7 +38,12 @@ agent-workflows init
38
38
 
39
39
  For SDK use, install locally with `npm install @mingchuno/agent-workflows`. Both interfaces ship in the same package.
40
40
 
41
- Edit `agent-workflows.json`: set the checkout, hosting origin/repository, Git identity, agent stages and validation commands. Set the named hosting-token environment variable through your usual secret manager; for GitHub, follow the [PAT creation and required permissions](docs/providers.md#create-a-fine-grained-personal-access-token) guide before running. Commit or ignore the configuration before running. Keep the state directory outside managed checkouts, or explicitly Git-ignore it.
41
+ Edit `agent-workflows.json`: set the checkout, hosting origin/repository, agent
42
+ stages and validation commands. Relative checkout, state and prompt-file paths
43
+ resolve from the configuration file's directory. Commits use native Git identity
44
+ and disclose retained writable agent contributions with default-enabled co-author
45
+ trailers. Set the named hosting-token environment variable through your usual
46
+ secret manager; for GitHub, follow the [PAT creation and required permissions](docs/providers.md#create-a-fine-grained-personal-access-token) guide before running. Commit or ignore the configuration before running. Keep the state directory outside managed checkouts, or explicitly Git-ignore it.
42
47
 
43
48
  ```sh
44
49
  agent-workflows run
@@ -47,9 +52,10 @@ agent-workflows monitor
47
52
  agent-workflows status --json
48
53
  ```
49
54
 
50
- Alternatively, load database, hosting and application variables from one explicit
51
- file: `agent-workflows --env-file ./runner.env run`. Existing shell values win,
52
- including empty strings. See [environment file examples and boundaries](docs/configuration.md#cli-environment-files).
55
+ Alternatively, set `"envFile": "./runner.env"` at the top level of
56
+ `agent-workflows.json` to load database, hosting and application variables from
57
+ one explicit file. Existing process values win, including empty strings. See
58
+ [environment file examples and boundaries](docs/configuration.md#cli-environment-files).
53
59
 
54
60
  The runner fetches the configured base, creates a branch, implements an eligible issue, validates it, generates publication text, commits and pushes, creates a draft PR/MR, and publishes an independent review of its exact head. It never merges. Initial use should target a repository and issue you explicitly intend to automate; running the CLI authorizes these effects and agent usage.
55
61
 
@@ -71,6 +77,24 @@ await runner.shutdown();
71
77
 
72
78
  [Custom workflow](examples/custom-workflow.ts), [complete runner](examples/run.ts), [configuration](examples/config.ts), and [inspection](examples/observe.ts) examples are type-checked with the library. The custom workflow is also exercised using controlled providers.
73
79
 
80
+ ## Documentation
81
+
82
+ ### Using the package
83
+
84
+ - [Configuration and profiles](docs/configuration.md)
85
+ - [Default stage prompts](docs/configuration.md#default-stage-prompts)
86
+ - [Public SDK API and composition](docs/api.md)
87
+ - [DBOS SDK direct usage](docs/api.md#dbos-sdk-direct-usage)
88
+ - [Authentication and provider capabilities](docs/providers.md)
89
+ - [CLI, TUI, observability, and recovery](docs/operations.md)
90
+ - [Observability landscape](docs/operations.md#observability-landscape)
91
+
92
+ ### Maintaining the project
93
+
94
+ - [Architecture decisions](docs/adr/README.md)
95
+ - [Database maintenance](docs/database.md)
96
+ - [Release process](docs/releases.md)
97
+
74
98
  ## Development and review
75
99
 
76
100
  [mise](https://mise.jdx.dev/getting-started.html) pins the development Node and pnpm versions. Activate it in your shell or prefix commands with `mise exec --`.
@@ -87,14 +111,6 @@ Use `pnpm start --help` to run the CLI from source. `pnpm build` emits the runti
87
111
 
88
112
  `pnpm test` builds the application, then starts and removes a disposable real PostgreSQL database using `initdb`, `pg_ctl`, and `createdb` on PATH. Alternatively, set `TEST_DATABASE_URL` to a disposable database whose role can create test databases. Tests use real temporary Git repositories and controlled adapters/HTTP servers; they make no paid agent calls or writes to real hosting providers.
89
113
 
90
- Schema changes and database upgrades: [database maintenance](docs/database.md). Biome formats and lints supported source/configuration files; Markdown and YAML are maintained manually.
91
-
92
- - [Configuration and profiles](docs/configuration.md)
93
- - [Default stage prompts](docs/configuration.md#default-stage-prompts)
94
- - [Public SDK API and composition](docs/api.md)
95
- - [DBOS SDK direct usage](docs/api.md#dbos-sdk-direct-usage)
96
- - [Authentication and provider capabilities](docs/providers.md)
97
- - [CLI, TUI, observability and recovery](docs/operations.md)
98
- - [Observability Landscape](docs/operations.md#observability-landscape)
99
- - [Architecture](docs/architecture.md)
100
- - [Releases](docs/releases.md)
114
+ Schema changes and database upgrades: [database maintenance](docs/database.md).
115
+ Biome formats and lints supported source/configuration files; Markdown and YAML
116
+ are maintained manually.
@@ -0,0 +1,9 @@
1
+ import type { Project } from "./config.js";
2
+ import type { ContributionCandidate, Publication, Snapshot } from "./domain.js";
3
+ export declare const agentCoAuthors: {
4
+ readonly codex: "Codex <noreply@openai.com>";
5
+ readonly copilot: "Copilot <223556219+Copilot@users.noreply.github.com>";
6
+ };
7
+ export type AttributedProvider = keyof typeof agentCoAuthors;
8
+ export declare function contributingProviders(candidates: ContributionCandidate[], final: Snapshot): AttributedProvider[];
9
+ export declare function finalizeCommitMessage(publication: Publication, project: Project, providers: AttributedProvider[], runId: string): Publication;
@@ -0,0 +1,57 @@
1
+ export const agentCoAuthors = {
2
+ codex: "Codex <noreply@openai.com>",
3
+ copilot: "Copilot <223556219+Copilot@users.noreply.github.com>",
4
+ };
5
+ const trailerLine = /^([A-Za-z0-9][A-Za-z0-9-]*):[ \t]*(\S.*)$/;
6
+ function normalizedTrailer(line) {
7
+ const match = trailerLine.exec(line);
8
+ return match ? `${match[1]}:${match[2]}`.toLowerCase() : undefined;
9
+ }
10
+ function retained(candidate, final) {
11
+ const paths = new Set([
12
+ ...Object.keys(candidate.beforeFiles),
13
+ ...Object.keys(candidate.afterFiles),
14
+ ]);
15
+ return [...paths].some((path) => candidate.beforeFiles[path] !== candidate.afterFiles[path] &&
16
+ candidate.beforeFiles[path] !== final.files[path]);
17
+ }
18
+ export function contributingProviders(candidates, final) {
19
+ const providers = new Set();
20
+ for (const candidate of candidates) {
21
+ if (!(candidate.provider in agentCoAuthors) || !retained(candidate, final))
22
+ continue;
23
+ providers.add(candidate.provider);
24
+ }
25
+ return [...providers];
26
+ }
27
+ export function finalizeCommitMessage(publication, project, providers, runId) {
28
+ const identities = project.includeAgentCoAuthors
29
+ ? providers.map((provider) => agentCoAuthors[provider])
30
+ : [];
31
+ const matching = new Set(identities.map((identity) => `co-authored-by:${identity}`.toLowerCase()));
32
+ const lines = publication.commitMessage
33
+ .split(/\r?\n/)
34
+ .filter((line) => !line.trim().toLowerCase().startsWith("agent-workflows-run:"));
35
+ while (lines.at(-1)?.trim() === "")
36
+ lines.pop();
37
+ const separator = lines.findLastIndex((line) => line.trim() === "");
38
+ const possibleTrailers = lines.slice(separator + 1);
39
+ const hasTrailerBlock = separator >= 0 &&
40
+ possibleTrailers.length > 0 &&
41
+ possibleTrailers.every((line) => trailerLine.test(line) || /^\s+\S/.test(line));
42
+ const body = hasTrailerBlock ? lines.slice(0, separator) : lines;
43
+ while (body.at(-1)?.trim() === "")
44
+ body.pop();
45
+ const existingTrailers = hasTrailerBlock
46
+ ? possibleTrailers.filter((line) => !matching.has(normalizedTrailer(line.trim()) ?? ""))
47
+ : [];
48
+ const finalizedTrailers = [
49
+ ...existingTrailers,
50
+ ...identities.map((identity) => `Co-authored-by: ${identity}`),
51
+ `Agent-Workflows-Run: ${runId}`,
52
+ ];
53
+ return {
54
+ ...publication,
55
+ commitMessage: `${body.join("\n")}\n\n${finalizedTrailers.join("\n")}`,
56
+ };
57
+ }
@@ -0,0 +1,2 @@
1
+ import { type Configuration } from "./config.js";
2
+ export declare function readCliConfiguration(path: string, resolveEnvironmentFile: (path: string) => string): Promise<Configuration>;
@@ -0,0 +1,35 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { parseEnv } from "node:util";
3
+ import { z } from "zod";
4
+ import { configSchema } from "./config.js";
5
+ const cliConfigurationSchema = configSchema.extend({
6
+ envFile: z
7
+ .string()
8
+ .refine((path) => path.trim().length > 0, "Environment file path must be nonblank")
9
+ .optional(),
10
+ });
11
+ export async function readCliConfiguration(path, resolveEnvironmentFile) {
12
+ const { envFile, ...configuration } = cliConfigurationSchema.parse(JSON.parse(await readFile(path, "utf8")));
13
+ if (envFile !== undefined)
14
+ await loadEnvironmentFile(resolveEnvironmentFile(envFile));
15
+ return configuration;
16
+ }
17
+ async function loadEnvironmentFile(path) {
18
+ let contents;
19
+ try {
20
+ contents = await readFile(path, "utf8");
21
+ }
22
+ catch (error) {
23
+ throw new Error(`Cannot read environment file ${path} (${error.code ?? "read failed"})`);
24
+ }
25
+ let values;
26
+ try {
27
+ values = parseEnv(contents);
28
+ }
29
+ catch (error) {
30
+ throw new Error(`Cannot parse environment file ${path}`, { cause: error });
31
+ }
32
+ for (const [name, value] of Object.entries(values))
33
+ if (value !== undefined && process.env[name] === undefined)
34
+ process.env[name] = value;
35
+ }
package/dist/src/cli.js CHANGED
@@ -1,42 +1,44 @@
1
1
  #!/usr/bin/env -S node --
2
+ import { realpathSync, statSync } from "node:fs";
2
3
  import { readFile, writeFile } from "node:fs/promises";
3
- import { basename, dirname, resolve } from "node:path";
4
- import { parseEnv } from "node:util";
4
+ import { basename, dirname, relative, resolve } from "node:path";
5
5
  import { Command } from "commander";
6
6
  import { render } from "ink";
7
7
  import React from "react";
8
8
  import { createAgents } from "./adapters/agents.js";
9
9
  import { createHosting } from "./adapters/hosting.js";
10
- import { configSchema } from "./config.js";
10
+ import { readCliConfiguration } from "./cli-config.js";
11
11
  import { defaultValidationTimeoutMs } from "./defaults.js";
12
12
  import { Runner } from "./runner.js";
13
13
  import { Store } from "./store.js";
14
- import { Monitor } from "./tui/index.js";
14
+ import { createTerminalNotificationWriter, Monitor } from "./tui/index.js";
15
15
  const launchDirectory = process.cwd();
16
16
  const program = new Command()
17
17
  .name("agent-workflows")
18
18
  .description("Local durable issue-to-review workflows")
19
19
  .option("-c, --config <file>", "configuration path", "agent-workflows.json")
20
- .option("--env-file <path>", "load literal dotenv values; existing environment wins")
21
- .hook("preAction", async () => {
22
- const path = program.opts().envFile;
23
- if (path === undefined)
24
- return;
25
- const file = resolve(launchDirectory, path);
26
- let contents;
20
+ .option("--config-base-directory <directory>", "base directory for paths contained in configuration");
21
+ async function configuration() {
22
+ return readCliConfiguration(configPath(), (path) => resolve(configBaseDirectory(), path));
23
+ }
24
+ function configPath() {
25
+ return resolve(launchDirectory, program.opts().config);
26
+ }
27
+ function configBaseDirectory() {
28
+ const selected = program.opts().configBaseDirectory;
29
+ const path = selected
30
+ ? resolve(launchDirectory, selected)
31
+ : dirname(configPath());
32
+ let canonical;
27
33
  try {
28
- contents = await readFile(file, "utf8");
34
+ canonical = realpathSync(path);
29
35
  }
30
36
  catch (error) {
31
- throw new Error(`Cannot read environment file ${file} (${error.code ?? "read failed"})`);
37
+ throw new Error(`Configuration path base is not an existing directory: ${path}`, { cause: error });
32
38
  }
33
- for (const [name, value] of Object.entries(parseEnv(contents))) {
34
- if (process.env[name] === undefined)
35
- process.env[name] = value;
36
- }
37
- });
38
- async function configuration() {
39
- return configSchema.parse(JSON.parse(await readFile(resolve(program.opts().config), "utf8")));
39
+ if (!statSync(canonical).isDirectory())
40
+ throw new Error(`Configuration path base is not an existing directory: ${path}`);
41
+ return canonical;
40
42
  }
41
43
  function databaseUrl(config) {
42
44
  const value = process.env[config.databaseUrlEnv];
@@ -46,6 +48,9 @@ function databaseUrl(config) {
46
48
  }
47
49
  async function withStore(action) {
48
50
  const config = await configuration();
51
+ await withConfiguredStore(config, action);
52
+ }
53
+ async function withConfiguredStore(config, action) {
49
54
  const store = new Store(databaseUrl(config), config.id);
50
55
  try {
51
56
  await store.initialize();
@@ -59,14 +64,18 @@ program
59
64
  .command("init")
60
65
  .description("Write a configuration scaffold without overwriting files")
61
66
  .action(async () => {
67
+ const base = configBaseDirectory();
68
+ const checkout = process.cwd();
69
+ const externalState = resolve(checkout, "..", `${basename(checkout)}.agent-workflows`);
70
+ const portable = (path) => relative(base, path) || ".";
62
71
  const config = {
63
72
  id: "local",
64
73
  databaseUrlEnv: "AGENT_WORKFLOWS_DATABASE_URL",
65
- stateDirectory: resolve("..", `${basename(process.cwd())}.agent-workflows`),
74
+ stateDirectory: portable(externalState),
66
75
  projects: [
67
76
  {
68
77
  id: "example",
69
- checkout: process.cwd(),
78
+ checkout: portable(checkout),
70
79
  hosting: {
71
80
  provider: "github",
72
81
  origin: "https://github.com",
@@ -76,7 +85,7 @@ program
76
85
  labels: ["ready-for-agent"],
77
86
  baseBranch: "main",
78
87
  branchTemplate: "agent/{issue}-{attempt}",
79
- gitIdentity: { name: "YOUR NAME", email: "you@example.com" },
88
+ includeAgentCoAuthors: true,
80
89
  agent: { provider: "codex" },
81
90
  validation: [
82
91
  {
@@ -89,8 +98,11 @@ program
89
98
  },
90
99
  ],
91
100
  };
92
- await writeFile(resolve(program.opts().config), JSON.stringify(config, null, 2) + "\n", { flag: "wx", mode: 0o600 });
93
- console.log("Created configuration. Set repository, checkout, identity and credentials before running.");
101
+ await writeFile(configPath(), JSON.stringify(config, null, 2) + "\n", {
102
+ flag: "wx",
103
+ mode: 0o600,
104
+ });
105
+ console.log("Created configuration. Set repository and credentials before running.");
94
106
  });
95
107
  program
96
108
  .command("run")
@@ -105,7 +117,7 @@ program
105
117
  config.projects = config.projects.filter((project) => wanted.has(project.id));
106
118
  }
107
119
  const runner = new Runner({
108
- promptBaseDirectory: dirname(resolve(program.opts().config)),
120
+ pathBaseDirectory: configBaseDirectory(),
109
121
  config,
110
122
  databaseUrl: databaseUrl(config),
111
123
  hosting: createHosting,
@@ -178,13 +190,20 @@ for (const kind of ["pause", "resume", "stop", "retry", "recover"])
178
190
  status: "pending",
179
191
  }));
180
192
  }));
181
- program.command("monitor").action(async () => {
193
+ program
194
+ .command("monitor")
195
+ .option("--notify", "notify when an observed execution reaches an outcome")
196
+ .action(async (options) => {
197
+ const config = await configuration();
182
198
  if (!process.stdin.isTTY || !process.stdout.isTTY)
183
199
  throw new Error("Monitor requires an interactive terminal; use status --json instead");
184
- await withStore(async (store) => {
185
- await render(React.createElement(Monitor, { source: store }), {
186
- alternateScreen: true,
187
- }).waitUntilExit();
200
+ await withConfiguredStore(config, async (store) => {
201
+ await render(React.createElement(Monitor, {
202
+ source: store,
203
+ notificationWriter: options.notify
204
+ ? createTerminalNotificationWriter()
205
+ : undefined,
206
+ }), { alternateScreen: true }).waitUntilExit();
188
207
  });
189
208
  });
190
209
  try {
@@ -51,10 +51,7 @@ export declare const projectSchema: z.ZodObject<{
51
51
  args: z.ZodDefault<z.ZodArray<z.ZodString>>;
52
52
  timeoutMs: z.ZodDefault<z.ZodNumber>;
53
53
  }, z.core.$strict>>>;
54
- gitIdentity: z.ZodObject<{
55
- name: z.ZodString;
56
- email: z.ZodEmail;
57
- }, z.core.$strict>;
54
+ includeAgentCoAuthors: z.ZodDefault<z.ZodBoolean>;
58
55
  agent: z.ZodObject<{
59
56
  provider: z.ZodEnum<{
60
57
  codex: "codex";
@@ -147,10 +144,7 @@ export declare const configSchema: z.ZodObject<{
147
144
  args: z.ZodDefault<z.ZodArray<z.ZodString>>;
148
145
  timeoutMs: z.ZodDefault<z.ZodNumber>;
149
146
  }, z.core.$strict>>>;
150
- gitIdentity: z.ZodObject<{
151
- name: z.ZodString;
152
- email: z.ZodEmail;
153
- }, z.core.$strict>;
147
+ includeAgentCoAuthors: z.ZodDefault<z.ZodBoolean>;
154
148
  agent: z.ZodObject<{
155
149
  provider: z.ZodEnum<{
156
150
  codex: "codex";
@@ -60,7 +60,7 @@ export const projectSchema = z.strictObject({
60
60
  .default(defaultValidationTimeoutMs),
61
61
  }))
62
62
  .default([]),
63
- gitIdentity: z.strictObject({ name: z.string().min(1), email: z.email() }),
63
+ includeAgentCoAuthors: z.boolean().default(true),
64
64
  agent: profileSchema,
65
65
  stages: z
66
66
  .strictObject({
@@ -47,6 +47,11 @@ export interface Snapshot {
47
47
  paths: string[];
48
48
  files: Record<string, string | null>;
49
49
  }
50
+ export interface ContributionCandidate {
51
+ provider: string;
52
+ beforeFiles: Record<string, string | null>;
53
+ afterFiles: Record<string, string | null>;
54
+ }
50
55
  export interface Workspace {
51
56
  check(project: Project): Promise<void>;
52
57
  prepare(project: Project, branch: string, signal?: AbortSignal): Promise<Snapshot>;
@@ -140,6 +145,8 @@ export interface RunRecord {
140
145
  snapshot?: Snapshot;
141
146
  validation?: ValidationResult[];
142
147
  publication?: Publication;
148
+ contributionCandidates?: ContributionCandidate[];
149
+ contributingProviders?: string[];
143
150
  change?: ChangeRequest;
144
151
  review?: Review;
145
152
  reviewHead?: string;
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { type Stage } from "./config.js";
3
- import { type RunRecord, type Snapshot } from "./domain.js";
3
+ import { type ContributionCandidate, type RunRecord, type Snapshot } from "./domain.js";
4
4
  import { type ChangeEvidence } from "./evidence.js";
5
5
  import type { OperationDependencies } from "./operations.js";
6
6
  export interface InvocationTask {
@@ -17,8 +17,9 @@ interface StageExecution {
17
17
  task: InvocationTask;
18
18
  stepId: number;
19
19
  dependencies: OperationDependencies;
20
- saveImplementationSnapshot: (runId: string, snapshot: Snapshot) => Promise<void>;
20
+ saveImplementationSnapshot: (runId: string, snapshot: Snapshot, provider: string) => Promise<ContributionCandidate | undefined>;
21
+ acceptContribution: (runId: string, candidate: ContributionCandidate) => Promise<void>;
21
22
  }
22
23
  /** One logical stage; only returned format errors admit a second response attempt. */
23
- export declare function invokeStage({ run, name, stage, task, stepId, dependencies, saveImplementationSnapshot, }: StageExecution): Promise<string>;
24
+ export declare function invokeStage({ run, name, stage, task, stepId, dependencies, saveImplementationSnapshot, acceptContribution, }: StageExecution): Promise<string>;
24
25
  export {};
@@ -3,12 +3,12 @@ import { appendFile, mkdir } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { resolveProfile } from "./config.js";
6
- import { BlockedError } from "./domain.js";
6
+ import { BlockedError, } from "./domain.js";
7
7
  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, }) {
11
+ export async function invokeStage({ run, name, stage, task, stepId, dependencies, saveImplementationSnapshot, acceptContribution, }) {
12
12
  const { store, project, agents, signal, workspace, redact } = dependencies;
13
13
  const previous = (await store.invocations(run.id)).filter((item) => item.stepId === stepId);
14
14
  if (previous.length) {
@@ -56,6 +56,7 @@ export async function invokeStage({ run, name, stage, task, stepId, dependencies
56
56
  const directory = join(dependencies.artifacts, run.id);
57
57
  await mkdir(directory, { recursive: true, mode: 0o700 });
58
58
  let correction = "";
59
+ let pendingContribution;
59
60
  for (let attempt = 1; attempt <= maxInvocationAttempts; attempt++) {
60
61
  invocationSignal.throwIfAborted();
61
62
  const expected = (await store.run(run.id)).snapshot;
@@ -118,7 +119,7 @@ export async function invokeStage({ run, name, stage, task, stepId, dependencies
118
119
  if (readOnly)
119
120
  await workspace.verify(project, expected);
120
121
  else
121
- await saveImplementationSnapshot(run.id, expected);
122
+ pendingContribution = await saveImplementationSnapshot(run.id, expected, profile.provider);
122
123
  if (task.evidence)
123
124
  await verifyEvidence(task.evidence);
124
125
  invocationSignal.throwIfAborted();
@@ -144,6 +145,8 @@ export async function invokeStage({ run, name, stage, task, stepId, dependencies
144
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)}`;
145
146
  continue;
146
147
  }
148
+ if (pendingContribution)
149
+ await acceptContribution(run.id, pendingContribution);
147
150
  record.outcome = "completed";
148
151
  return redact(task.outputContract ? JSON.stringify(parsed) : output);
149
152
  }
@@ -26,6 +26,7 @@ export declare class Operations {
26
26
  prepare(): Promise<void>;
27
27
  invoke(name: string, stage: Stage, task: InvocationTask): Promise<string>;
28
28
  private saveImplementationSnapshot;
29
+ private acceptContribution;
29
30
  implement(): Promise<void>;
30
31
  validate(): Promise<boolean>;
31
32
  private prepareEvidence;
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { appendFile, mkdir } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { DBOS } from "@dbos-inc/dbos-sdk";
5
+ import { contributingProviders, finalizeCommitMessage } from "./attribution.js";
5
6
  import { BlockedError, isBlockedError, publicationSchema, reviewSchema, } from "./domain.js";
6
7
  import { captureEvidence, evidenceContext, } from "./evidence.js";
7
8
  import { invokeStage } from "./invocation.js";
@@ -103,15 +104,32 @@ export class Operations {
103
104
  task,
104
105
  stepId: DBOS.stepID,
105
106
  dependencies: this.dependencies,
106
- saveImplementationSnapshot: (runId, expected) => this.saveImplementationSnapshot(runId, expected),
107
+ saveImplementationSnapshot: (runId, expected, provider) => this.saveImplementationSnapshot(runId, expected, provider),
108
+ acceptContribution: (runId, candidate) => this.acceptContribution(runId, candidate),
107
109
  }));
108
110
  }
109
- async saveImplementationSnapshot(runId, expected) {
111
+ async saveImplementationSnapshot(runId, expected, provider) {
110
112
  const { workspace, project, store } = this.dependencies;
111
113
  const snapshot = await workspace.inspect(project);
112
114
  if (snapshot.head !== expected.head || snapshot.branch !== expected.branch)
113
115
  throw new BlockedError("Agent changed branch or committed unexpectedly");
114
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
+ });
115
133
  }
116
134
  async implement() {
117
135
  await this.invoke("implementation", this.dependencies.project.stages.implementation, {
@@ -206,8 +224,12 @@ export class Operations {
206
224
  context: (run) => `${evidenceContext(evidence)}\nIssue: ${JSON.stringify(run.issue)}\nValidation: ${JSON.stringify(run.validation ?? [])}`,
207
225
  });
208
226
  await this.step("publication-content", async (run) => {
227
+ if (!run.snapshot)
228
+ throw new Error("Missing publication snapshot");
229
+ const providers = contributingProviders(run.contributionCandidates ?? [], run.snapshot);
209
230
  await this.dependencies.store.patchRun(run.id, {
210
- publication: publicationSchema.parse(JSON.parse(output)),
231
+ publication: finalizeCommitMessage(publicationSchema.parse(JSON.parse(output)), this.dependencies.project, providers, run.id),
232
+ contributingProviders: providers,
211
233
  });
212
234
  });
213
235
  }
@@ -3,6 +3,7 @@ import { type AgentAdapter, type HostingAdapter, type Workspace } from "./domain
3
3
  import { Operations } from "./operations.js";
4
4
  import { Store } from "./store.js";
5
5
  export interface RunnerOptions {
6
+ pathBaseDirectory?: string;
6
7
  promptBaseDirectory?: string;
7
8
  config: Configuration;
8
9
  databaseUrl: string;
@@ -19,6 +20,7 @@ export declare class Runner {
19
20
  private readonly controllers;
20
21
  private readonly active;
21
22
  private readonly hosting;
23
+ private readonly promptBaseDirectory;
22
24
  private workflow;
23
25
  private readonly ownership;
24
26
  private stopping;
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { realpathSync, statSync } from "node:fs";
2
3
  import { mkdir, realpath } from "node:fs/promises";
3
4
  import { resolve } from "node:path";
4
5
  import { DBOS } from "@dbos-inc/dbos-sdk";
@@ -22,6 +23,7 @@ export class Runner {
22
23
  controllers = new Map();
23
24
  active = new Map();
24
25
  hosting = new Map();
26
+ promptBaseDirectory;
25
27
  workflow;
26
28
  ownership = new CheckoutOwnership();
27
29
  stopping = false;
@@ -33,8 +35,16 @@ export class Runner {
33
35
  constructor(options) {
34
36
  this.options = options;
35
37
  this.config = configSchema.parse(options.config);
38
+ const pathBaseDirectory = canonicalDirectory(options.pathBaseDirectory ?? process.cwd(), "Configuration path base");
39
+ this.config.stateDirectory = resolve(pathBaseDirectory, this.config.stateDirectory);
36
40
  for (const project of this.config.projects)
37
- projectPrompts(project, options.promptBaseDirectory);
41
+ project.checkout = resolve(pathBaseDirectory, project.checkout);
42
+ const promptBaseDirectory = options.promptBaseDirectory
43
+ ? resolve(options.promptBaseDirectory)
44
+ : pathBaseDirectory;
45
+ this.promptBaseDirectory = promptBaseDirectory;
46
+ for (const project of this.config.projects)
47
+ projectPrompts(project, promptBaseDirectory);
38
48
  this.store = new Store(options.databaseUrl, this.config.id, this.redact);
39
49
  }
40
50
  queue(id) {
@@ -49,7 +59,7 @@ export class Runner {
49
59
  const ids = new Set();
50
60
  for (const project of this.config.projects) {
51
61
  project.checkout = await realpath(project.checkout);
52
- await assertEvidenceDirectory(project.checkout, this.config.stateDirectory);
62
+ this.config.stateDirectory = await assertEvidenceDirectory(project.checkout, this.config.stateDirectory);
53
63
  if (canonical.has(project.checkout) || ids.has(project.id))
54
64
  throw new Error("Duplicate project identity or canonical checkout");
55
65
  canonical.add(project.checkout);
@@ -277,7 +287,7 @@ export class Runner {
277
287
  const workspace = this.options.workspace ?? new ExistingCheckout();
278
288
  let recoveryChecked = false;
279
289
  const operations = new Operations(runId, {
280
- promptBaseDirectory: this.options.promptBaseDirectory,
290
+ promptBaseDirectory: this.promptBaseDirectory,
281
291
  store: this.store,
282
292
  project,
283
293
  workspace,
@@ -495,3 +505,18 @@ export class Runner {
495
505
  await this.store.close();
496
506
  }
497
507
  }
508
+ function canonicalDirectory(path, label) {
509
+ const resolved = resolve(path);
510
+ let canonical;
511
+ try {
512
+ canonical = realpathSync(resolved);
513
+ }
514
+ catch (error) {
515
+ throw new Error(`${label} is not an existing directory: ${resolved}`, {
516
+ cause: error,
517
+ });
518
+ }
519
+ if (!statSync(canonical).isDirectory())
520
+ throw new Error(`${label} is not an existing directory: ${resolved}`);
521
+ return canonical;
522
+ }
@@ -1,5 +1,6 @@
1
1
  import type { RunRecord } from "../domain.js";
2
2
  import type { EventRecord, InvocationRecord, ProjectState, Store } from "../store.js";
3
+ import { type ExecutionNotificationWriter } from "./notifications.js";
3
4
  export interface MonitorSource {
4
5
  projects: Store["projects"];
5
6
  runs: Store["runs"];
@@ -12,7 +13,7 @@ export type MonitorAction = "pause" | "resume" | "stop" | "retry" | "recover";
12
13
  export declare function useMonitorData(source: MonitorSource, selection: {
13
14
  projectId?: string;
14
15
  runId?: string;
15
- }): {
16
+ }, notificationWriter?: ExecutionNotificationWriter): {
16
17
  projects: ProjectState[];
17
18
  project: ProjectState | undefined;
18
19
  projectRuns: RunRecord[];
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import { tuiRefreshIntervalMs } from "./constants.js";
3
- export function useMonitorData(source, selection) {
3
+ import { ExecutionNotificationObserver, } from "./notifications.js";
4
+ export function useMonitorData(source, selection, notificationWriter) {
4
5
  const [projects, setProjects] = useState([]);
5
6
  const [runs, setRuns] = useState([]);
6
7
  const [detail, setDetail] = useState({ sessions: [], events: [] });
@@ -10,6 +11,9 @@ export function useMonitorData(source, selection) {
10
11
  const [pending, setPending] = useState();
11
12
  const pendingRef = useRef(undefined);
12
13
  const mounted = useRef(true);
14
+ const notificationObserver = useRef(notificationWriter
15
+ ? new ExecutionNotificationObserver(notificationWriter)
16
+ : undefined).current;
13
17
  const project = projects.find((item) => item.id === selection.projectId) ?? projects[0];
14
18
  const projectRuns = runs.filter((item) => item.projectId === project?.id);
15
19
  const run = projectRuns.find((item) => item.id === selection.runId) ?? projectRuns[0];
@@ -38,6 +42,7 @@ export function useMonitorData(source, selection) {
38
42
  return;
39
43
  setProjects(nextProjects);
40
44
  setRuns(nextRuns);
45
+ notificationObserver?.observe(nextRuns);
41
46
  if (selectedRunId) {
42
47
  const [sessions, nextEvents] = await Promise.all([
43
48
  source.invocations(selectedRunId),
@@ -71,7 +76,7 @@ export function useMonitorData(source, selection) {
71
76
  closed = true;
72
77
  clearInterval(timer);
73
78
  };
74
- }, [source, selectedRunId]);
79
+ }, [source, selectedRunId, notificationObserver]);
75
80
  useEffect(() => {
76
81
  if (!pending || pending === "submitting")
77
82
  return;