Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/adr/0013-short-approval-comments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Approve on the issue with a short contract code

Provider approval required an allowed human to paste
`agent-workflows approve WORK_ID GATE REVISION`, with a UUID and a 64-character
Work Item revision that appeared nowhere on the issue. The revision cannot be
printed there: it covers the issue body, so printing it would change it
(ADR 0011).

A short comment `/approve GATE CODE` also approves. `CODE` is the first twelve
hex characters of the gate's contract digest without the assessed source
revision, so the published contract can print the exact comment. The Work Item
is the one whose contract the issue carries. Synchronization accepts a short
comment only when the issue still shows the current contract, the issue
revision is the one the Work Item observed, and the comment was posted after
the Work Item last changed. The last rule keeps ADR 0009's guarantee that a
source edit invalidates earlier authorization, even when reassessment leaves
the contract text unchanged.

Approval still binds what the approver can read. The code is a name for the
contract, not a secret; authority comes from the host-authenticated allowlisted
author. Comparing host comment time with the local clock can ignore a comment
posted within the skew after publication; reposting fixes it. The full form
remains valid.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ documentation.
- [0010: Run Track A inline and delegate only gated work](0010-inline-fast-path.md)
- [0011: Publish clarified contracts to the issue](0011-publish-contracts-to-issues.md)
- [0012: Onboard through start-work and existing sign-ins](0012-onboard-through-start-work.md)
- [0013: Approve on the issue with a short contract code](0013-short-approval-comments.md)

For observable behavior, see the [public SDK API](../api.md),
[configuration reference](../configuration.md), and [operator guide](../operations.md).
21 changes: 13 additions & 8 deletions docs/engineering/sdlc.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,18 +162,23 @@ recorded safety checks permit them.
## Keep enterprise approval independent

The enterprise example requires GitLab approval by the configured allowed human.
Local `work approve` rejects that policy. After inspecting the current contract,
the human posts this exact plain comment on the source issue.
Local `work approve` rejects that policy. The published contract ends with the
exact comment for each gate, and `start-work`, `work inspect` and the monitors
show it for the gate awaiting approval. After reading the contract on the issue,
the approver posts it as a plain comment:

```text
agent-workflows approve WORK_ID scope CURRENT_REVISION
/approve scope 3f9a2c1d4e5f
```

For the design gate, the human substitutes `design` and the complete revision
they inspected. The runner reads source-host comments and accepts only configured
human approvers at the current revision. It ignores bots, outsiders, and stale
comments. The intake agent never posts approval comments. Configuration loading
rejects a `github` or `gitlab` approval source without a nonempty `approvers` list.
The code is a digest of the gate's contract content, so it names the contract
printed on the issue. A short comment counts only while the issue shows the Work
Item's current contract, and only when posted after the Work Item last changed,
so a comment from before a source edit or republication never approves the
reassessed work. That comparison uses the host's comment time and the local
clock; if a valid comment is ignored because of clock skew, post it again. The
long form `agent-workflows approve WORK_ID GATE REVISION` with the full Work
Item revision is still accepted.

The worker imports permitted approval during polling. To import existing comments
without waiting for another poll, run the sync command.
Expand Down
15 changes: 10 additions & 5 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ All commands accept `--config PATH` before the subcommand. For example,
| `retry RUN` | Request a new attempt after checkout validation |
| `retry RUN --refresh-issue` | Request a new attempt using the current hosted issue |
| `monitor [--notify]` | Attach the TUI; optionally enable outcome notifications |
| `monitor --web [WORK_ID]` | Open the running web monitor with its access link, optionally at a Work Item |

Version reporting requires no configuration, credentials, or database connection.

Expand All @@ -47,15 +48,19 @@ Use arrows to navigate and Enter or Space to expand or collapse; **Back to runs*
returns to evidence. This view refreshes after accepted reloads and labels
retained data stale when the active snapshot cannot be read.

`run` starts a separate web-server process on `127.0.0.1:8787`. Use
`run --web-port 8899` to select another port or `run --no-web` to disable it.
An occupied port or a web-server failure produces a warning while workflows
continue; the CLI does not switch ports or automatically open a browser.
`run` starts a separate web-server process on `127.0.0.1:8787`, or on a free
port when 8787 is taken, and says so. Use `run --web-port 8899` to require a
port or `run --no-web` to disable it. An occupied required port or a web-server
failure produces a warning while workflows continue. `run` does not open a
browser; `agent-workflows monitor --web` opens the current link, and
`monitor --web WORK_ID` opens a Work Item. Links ending in `#work=WORK_ID`
select that Work Item in a browser that already has access.

Open the exact printed URL. Its launch-specific token grants access without a
login or token-entry step; the browser then removes the token from the address
bar and uses an HTTP-only session cookie. Treat the printed link as local access
credentials. After restarting `run`, open its new link. The server accepts only
credentials. The runner keeps it in its mode-600 control record so
`monitor --web` can reopen it. After restarting `run`, open its new link. The server accepts only
localhost access and its own browser origin for actions; remote hosting is not
supported.

Expand Down
29 changes: 27 additions & 2 deletions packages/cli/src/cli-runner-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const controlSchema = z.strictObject({
pid: z.number().int().positive(),
nonce: z.string(),
configuration: configSchema.optional(),
/** Web monitor access URL, including its launch token. */
webUrl: z.url().optional(),
});
type RunnerControl = z.infer<typeof controlSchema>;

Expand Down Expand Up @@ -115,13 +117,31 @@ export async function publishActiveConfiguration(
configPath: string,
configuration: Configuration,
): Promise<void> {
const path = controlPath(configPath);
const control = await readRunnerControl(configPath);
if (control.pid !== process.pid || control.id !== configuration.id)
throw new Error("Cannot publish another runner's configuration");
await replaceControl(configPath, { ...control, configuration });
}

/** Record the web monitor link so `monitor --web` can reopen it later. */
export async function publishWebUrl(
configPath: string,
webUrl: string,
): Promise<void> {
const control = await readRunnerControl(configPath);
if (control.pid !== process.pid)
throw new Error("Cannot publish another runner's web monitor");
await replaceControl(configPath, { ...control, webUrl });
}

async function replaceControl(
configPath: string,
record: RunnerControl,
): Promise<void> {
const path = controlPath(configPath);
const temporary = `${path}.${randomUUID()}.tmp`;
try {
await writeFile(temporary, JSON.stringify({ ...control, configuration }), {
await writeFile(temporary, JSON.stringify(record), {
flag: "wx",
mode: 0o600,
});
Expand All @@ -131,6 +151,11 @@ export async function publishActiveConfiguration(
}
}

/** The web monitor's address without its access token. */
export function monitorAddress(webUrl: string): string {
return new URL(webUrl).origin + "/";
}

export async function findRunnerControl(
configPath: string,
): Promise<RunnerControl | undefined> {
Expand Down
45 changes: 41 additions & 4 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env -S node --

import { spawn } from "node:child_process";
import { realpathSync, statSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { basename, dirname, relative, resolve } from "node:path";
Expand All @@ -25,8 +26,10 @@ import {
} from "./cli-config.js";
import {
findRunnerControl,
monitorAddress,
publishActiveConfiguration,
publishRunnerControl,
publishWebUrl,
readActiveConfiguration,
readRunnerControl,
} from "./cli-runner-control.js";
Expand Down Expand Up @@ -108,6 +111,13 @@ function databaseUrl(config: Pick<Configuration, "databaseUrlEnv">): string {
throw new Error(`Set ${config.databaseUrlEnv} to a PostgreSQL URL`);
return value;
}
function openBrowser(url: string): void {
const opener = process.platform === "darwin" ? "open" : "xdg-open";
// Opening is a convenience; the printed link still works.
spawn(opener, [url], { stdio: "ignore", detached: true })
.on("error", () => undefined)
.unref();
}
async function withStore(action: (store: Store) => Promise<void>) {
const config = await configuration();
await withConfiguredStore(config, action);
Expand Down Expand Up @@ -208,10 +218,13 @@ program
.description("Start the worker and the web monitor; keep it running")
.option("-p, --project <ids...>", "run selected project IDs")
.option("--no-web", "disable the local web monitor")
.option("--web-port <port>", "local web monitor port", String(defaultWebPort))
.option(
"--web-port <port>",
`local web monitor port (default: ${defaultWebPort}, or a free port when it is taken)`,
)
.action(
async (options: { project?: string[]; web: boolean; webPort: string }) => {
const webPort = Number(options.webPort);
async (options: { project?: string[]; web: boolean; webPort?: string }) => {
const webPort = Number(options.webPort ?? defaultWebPort);
if (!Number.isInteger(webPort) || webPort < 1 || webPort > 65535)
throw new Error("--web-port must be an integer between 1 and 65535");
const path = configPath();
Expand Down Expand Up @@ -266,6 +279,9 @@ program
configPath: path,
databaseUrl: databaseUrl(config),
port: webPort,
fallbackPort: options.webPort === undefined,
onReady: (url) =>
void publishWebUrl(path, url).catch(() => undefined),
});
} catch {
console.error(
Expand Down Expand Up @@ -437,8 +453,25 @@ program
.command("monitor")
.description("Open the terminal monitor: runs, Work Items and approvals")
.option("--notify", "notify when an observed execution reaches an outcome")
.action(async (options: { notify?: boolean }) => {
.option(
"--web [workItem]",
"open the running web monitor instead, optionally at a Work Item",
)
.action(async (options: { notify?: boolean; web?: boolean | string }) => {
const control = await findRunnerControl(configPath());
if (options.web) {
if (!control?.webUrl)
throw new Error(
"No web monitor is running for this configuration; start agent-workflows run",
);
const url =
typeof options.web === "string"
? `${control.webUrl}#work=${encodeURIComponent(options.web)}`
: control.webUrl;
console.log(`Web monitor: ${url}`);
if (process.stdout.isTTY) openBrowser(url);
return;
}
if (control?.envFile) await loadEnvironmentFile(control.envFile);
const config = control?.configuration ?? (await configuration());
if (control && sha256(databaseUrl(config)) !== control.databaseUrlHash)
Expand Down Expand Up @@ -491,6 +524,10 @@ program
registerWorkCommands(program, {
pathBaseDirectory: configBaseDirectory,
configuration,
monitorUrl: async () => {
const webUrl = (await findRunnerControl(configPath()))?.webUrl;
return webUrl && monitorAddress(webUrl);
},
withStore: withConfiguredStore,
});
// Help lists groups in command order, so daily commands come first.
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
Store,
} from "@mingchuno/agent-workflows-core";
import type { Command } from "commander";
import { findRunnerControl } from "./cli-runner-control.js";
import { findRunnerControl, monitorAddress } from "./cli-runner-control.js";
import { withinCheckout } from "./config-discovery.js";
import { applyCliTokens, cliTokenAllowed } from "./hosting-token.js";
import { onPath, remoteRepository } from "./repository-inference.js";
Expand All @@ -22,6 +22,8 @@ export interface DoctorReport {
version: string;
configPath?: string;
project?: string;
/** The running web monitor, without its access token. */
monitorUrl?: string;
protocols: { inline: true; delegated: boolean };
checks: Check[];
}
Expand Down Expand Up @@ -156,6 +158,7 @@ export async function doctor(
const checks: Check[] = [];
const check = (name: string, status: Status, detail: string) =>
checks.push({ name, status, detail });
let monitorUrl: string | undefined;
const report = (project?: string): DoctorReport => {
let configPath: string | undefined;
try {
Expand All @@ -169,6 +172,7 @@ export async function doctor(
version: dependencies.version,
configPath,
project,
...(monitorUrl ? { monitorUrl } : {}),
protocols: { inline: true, delegated: ok },
checks,
};
Expand Down Expand Up @@ -274,11 +278,12 @@ export async function doctor(
check("database", "fail", message(error));
}
const worker = await findRunnerControl(dependencies.configPath());
if (worker?.webUrl) monitorUrl = monitorAddress(worker.webUrl);
check(
"worker",
worker ? "pass" : "warn",
worker
? `Running as process ${worker.pid}`
? `Running as process ${worker.pid}${monitorUrl ? `; web monitor ${monitorUrl}` : ""}`
: "Not running. Start `agent-workflows run` in its own terminal; approved work waits until then.",
);
return report(project.id);
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/tui/work-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ export function WorkItemsView({
...(selected.approvalSource !== "local-operator"
? [
`Approval requires an authenticated allowed human through ${selected.approvalSource}.`,
...(selected.approvalComment
? [`Comment on the issue: ${selected.approvalComment}`]
: []),
]
: []),
"",
Expand Down
21 changes: 17 additions & 4 deletions packages/cli/src/web/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface WebStartup {
stateDirectory: string;
projectIds: string[];
port: number;
/** Use any free port when `port` is the taken default. */
fallbackPort?: boolean;
token: string;
}

Expand All @@ -19,6 +21,9 @@ export function startWebMonitor(options: {
configPath: string;
databaseUrl: string;
port: number;
fallbackPort?: boolean;
/** Receives the access URL once the server listens. */
onReady?: (url: string) => void;
}) {
const token = randomBytes(32).toString("hex");
const redact = createRedactor([token, options.databaseUrl]);
Expand Down Expand Up @@ -46,10 +51,17 @@ export function startWebMonitor(options: {
if (!message || typeof message !== "object") return;
if ("ready" in message && message.ready === true) {
clearTimeout(timer);
if (!stopping)
console.log(
`Web monitor: http://127.0.0.1:${options.port}/?token=${token}`,
);
const port =
"port" in message && typeof message.port === "number"
? message.port
: options.port;
const url = `http://127.0.0.1:${port}/?token=${token}`;
if (!stopping) {
if (port !== options.port)
console.log(`Port ${options.port} is in use; using ${port}.`);
console.log(`Web monitor: ${url}`);
options.onReady?.(url);
}
} else if ("error" in message && typeof message.error === "string") {
clearTimeout(timer);
warn(message.error);
Expand All @@ -76,6 +88,7 @@ export function startWebMonitor(options: {
stateDirectory: options.config.stateDirectory,
projectIds: options.config.projects.map((project) => project.id),
port: options.port,
fallbackPort: options.fallbackPort ?? false,
token,
};
child.send(startup, (error) => {
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export function monitorServer(store: Store, startup: WebStartup) {
if (token !== undefined) {
if (!sameSecret(token, startup.token))
return context.text(
"Access link is invalid. Open the URL printed by run.",
"Access link is invalid. Run agent-workflows monitor --web for the current link.",
401,
);
setCookie(context, cookieName, session, {
Expand All @@ -98,7 +98,10 @@ export function monitorServer(store: Store, startup: WebStartup) {
app.use("/api/*", async (context, next) => {
if (!sameSecret(getCookie(context, cookieName), session))
return context.json(
{ error: "Open the access URL printed by run to connect." },
{
error:
"Run agent-workflows monitor --web to open the monitor with access.",
},
401,
);
await next();
Expand Down
Loading
Loading