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
22 changes: 19 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,10 @@ codeoid mcp signin <server> [--callback-url <u>] # sign in to an OAuth re
codeoid evidence <id> [--full] # one recorded check: what ran, on which tree, what it found
codeoid loop start "<objective>" --workdir <dir> --check <cmd> [--test] [--wait] # repair until the daemon's checks pass (docs/loops.md)
codeoid loop ls | show <id> [--events] | steer <id> <text> | pause | resume | cancel <id>
codeoid loop watch add --workdir <dir> (--branch <b>... | --my-prs) --ci "<check>[@app]: <cmd>" [--review] [--trust <login>] # CI failures / review comments start runs
codeoid loop watch ls | show | poll | pause | resume | rm <id>
codeoid routine add <name> --prompt … [--cron|--every|--at] [--check <cmd>] # scheduled agent runs
codeoid routine add <name> --loop --prompt … --verify "id: cmd" [--test] [--attempts N] [--attempt-minutes N] [--run-minutes N] [--budget N] # start a loop (--check = gate, --verify = its acceptance checks)
codeoid routine ls | show | runs | run [--wait] | edit | pause | resume | rm | cancel <runId> | token | preview
codeoid worktree trust [dir] [--off] [--yes] # trust the setup command of a repo's .codeoid/setup.yaml, pinned to its blob (docs/CONFIGURATION.md)
```
Expand All @@ -208,9 +211,22 @@ or an API call. `RoutineService` owns the scheduler and runs; it reaches
sessions only through the `RoutineHost` seam that `SessionManager` implements,
and every routine.* message is authorized in `SessionManager.#routineHandle`
(owner-only, no scope of its own: session:create + session:send for
everything but preview, plus session:approve for an autonomous routine or one
with a check; changes are refused to delegated tokens). The fire claim is a compare-and-set on
`next_fire_at`, which is the only scheduler lock.
everything but preview, plus session:approve for an autonomous routine, one
with a check, or one that starts a loop; changes are refused to delegated tokens). The fire claim is a compare-and-set on
`next_fire_at`, which is the only scheduler lock. A loop routine (`spec.loop`)
starts a loop run through `RoutineHost.loops` after its gate; its routine run
follows the loop (`loopRunId`) without holding a run slot and ends with the
loop's outcome.
A loop that ends blocked after trying is `needs_attention` there, and for a loop routine that counts toward the failure pause.
`#followLoops` runs at the start of every tick and on every loop end or cancel (`notifyOwner` kind `cancelled`); without a loop port it fails the following runs.
A loop routine's run is set `running` before `port.start`, and `RoutineService.start` relinks a run whose loop started before the run recorded it (`RoutineLoopPort.inProgress`, owner-scoped, and only when the loop's trigger names that run's routine); the routine's `notify` policy decides a routine-started loop's pushes (`loopMayPush`).
`delete` marks the routine in `#deleting` before its first await, and `fire`, `#fireScheduled`, `#admit`, `#drainQueue` and `#startLoopRun` refuse a routine being deleted.
`RoutineWire.lastOutcome` (`RoutineStore.lastOutcome`) is the newest run with an outcome by `ended_at`; anything meaning "how is it doing" reads it, not `lastRun`.
The API fire reply (`routines/http.ts`) gives a generic reason, never the run's own (it can name a loop run).
A loop cancel re-reads the worker's spend after the worker stopped (the host's `interruptWorker` waits, bounded, for the interrupted turn to drain: `Session.turnDrained`) and only then notifies.
A pinned vouch (`trustHead` record) takes full commit ids only, and its moved-head refusal carries `response.error` `detail` (`head_moved`), which `RequestError` in packages/core keeps.
The web never vouches unpinned (not on create either) and never pre-fills a vouch from an untrusted head.
The web's automation stores reset on an identity change (`web/src/state/automation-identity.ts`, keyed by `identityKey`), not on a new connection, so a routine token shown once survives a reconnect and is shown only to the identity that asked.

## Config

Expand Down
232 changes: 231 additions & 1 deletion docs/loops.md

Large diffs are not rendered by default.

111 changes: 104 additions & 7 deletions docs/routines.md

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion packages/core/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

import { afterEach, beforeEach, describe, expect, it } from "bun:test";

import { CodeoidClient } from "./client.js";
import { CodeoidClient, RequestError } from "./client.js";
import type { ClientStatus, ConnectOptions } from "./client.js";

// ── Controllable mock WebSocket ───────────────────────────────────────────
Expand Down Expand Up @@ -184,6 +184,17 @@ describe("CodeoidClient", () => {
await expect(bad).rejects.toThrow("nope");
});

it("a refusal keeps its code and structured detail, not only its words", async () => {
const c = track(makeClient());
const ws = await connectClient(c);
const bad = c.request({ type: "loop.watch.update", id: "req-9", watchId: "lw_1", trustHead: { main: "a".repeat(40) } });
const detail = { kind: "head_moved", branch: "main", head: "b".repeat(40), pinned: "a".repeat(40) } as const;
ws.recv({ type: "response.error", requestId: "req-9", error: "main's head is … now", code: "invalid_request", detail });
const err = await bad.catch((e: unknown) => e);
expect(err).toBeInstanceOf(RequestError);
expect(err).toMatchObject({ message: "main's head is … now", code: "invalid_request", detail });
});

it("resolves waitForResult requests on their typed result frame", async () => {
const c = track(makeClient());
const ws = await connectClient(c);
Expand Down
18 changes: 17 additions & 1 deletion packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ export class AuthRejectedError extends Error {
}
}

/**
* A request the daemon refused (`response.error`): its words as the message,
* with its `code`, and its `detail` when it gave one — so a caller can act on
* the refusal (a moved head to re-pin) without parsing the words.
*/
export class RequestError extends Error {
readonly code: ResponseErrorMsg["code"];
readonly detail?: ResponseErrorMsg["detail"];
constructor(msg: ResponseErrorMsg) {
super(msg.error);
this.name = "RequestError";
this.code = msg.code;
if (msg.detail) this.detail = msg.detail;
}
}

export interface ConnectOptions {
url: string; // ws://host:port — daemon's WS endpoint
/** Initial access token used for the first handshake (and the fallback
Expand Down Expand Up @@ -266,7 +282,7 @@ export class CodeoidClient {

this.#pending.set(id, {
resolve: (ok) => resolve(ok as unknown as T),
reject: (e) => reject(e instanceof Error ? e : new Error(e.error)),
reject: (e) => reject(e instanceof Error ? e : new RequestError(e)),
waitForResult: options.waitForResult,
timer,
});
Expand Down
189 changes: 189 additions & 0 deletions packages/protocol/src/loops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,29 @@ export interface LoopRunWire {
createdAt: string;
updatedAt: string;
endedAt: string | null;
/** The commit a triggered run works from, instead of the owner's checkout. */
baseRef?: { sha: string; branch?: string };
/** What started the run, when a watch or a routine did ("started by"). */
trigger?: LoopTriggerWire;
}

/**
* What started a run: a repository event a watch saw, or a routine's fire.
* `summary` is event text (a check's output, a comment, a routine's gate
* output) and `payload` a routine fire's payload: data.
*/
export interface LoopTriggerWire {
kind: "ci_failure" | "review_comment" | "routine";
key: string;
watchId?: string;
/** kind `routine`: the routine, and the routine run that started this one. */
routineId?: string;
routineName?: string;
routineRunId?: string;
url?: string;
summary: string;
/** kind `routine`: what was sent with its fire, kept apart from its gate's output in `summary`. Data. */
payload?: string;
}

export interface LoopAttemptWire {
Expand Down Expand Up @@ -182,3 +205,169 @@ export interface LoopListResultMsg {
requestId: string;
runs: LoopRunWire[];
}

// ── Watches ──────────────────────────────────────────────────────────────────
//
// A watch is an owner-defined, opt-in rule that starts ordinary repair runs
// from repository events: a failing CI check on a watched branch, or a review
// comment addressed to codeoid on the owner's pull request. Authorized like
// loop.start (session:create + session:send, plus session:approve for
// anything that can start runs); creating, changing, enabling, and polling
// are refused to a delegated token, while disabling and deleting are safety
// stops any credential that can see the watch may use. A watch is its
// owner's alone.

/** Which CI check names start a run, and the local command that reproduces each. */
export interface LoopWatchCiWire {
/** The check run's name on GitHub, exact or a glob (`*`, `?`). */
ciName: string;
/** Slug of the GitHub App that must have reported it. Default `github-actions`. */
app?: string;
/** The local command the run must make pass. */
command: string;
timeoutSec?: number;
expectTests?: boolean;
}

export interface LoopWatchSpecWire {
/** The repository; its `origin` remote must be on github.com. */
workdir: string;
/** Watch these branches… */
branches?: string[];
/** …or the heads of your open pull requests. Exactly one of the two. */
myPrs?: boolean;
ci: LoopWatchCiWire[];
review?: {
enabled: boolean;
/** GitHub logins whose comments count. Default: the gh-authenticated user. */
allow?: string[];
/** The token a comment must contain. Default `@codeoid`. */
mention?: string;
};
/**
* More GitHub logins whose commits a head may carry. A watch runs a head's
* code only when every commit past the default branch was authored and
* committed by you (gh's login) or one of these.
*/
trust?: string[];
provider?: string;
model?: string;
toolBudget?: number;
limits?: Partial<LoopLimitsWire>;
/** Runs this watch may start per hour. Default 6. */
maxRunsPerHour?: number;
}

export interface LoopWatchWire {
id: string;
workdir: string;
/** `owner/name`, from the workdir's origin when the watch was made. */
repo: string;
branches: string[];
myPrs: boolean;
ci: Array<{ ciName: string; app: string; command: string; timeoutSec: number; expectTests?: boolean }>;
review: { enabled: boolean; allow: string[]; mention: string };
/** The GitHub login the watch reads as (gh's, when it was made): always trusted. */
ghLogin: string | null;
/** More logins trusted to have written a head's commits. */
trust: string[];
provider?: string;
model?: string;
toolBudget?: number;
limits?: Partial<LoopLimitsWire>;
maxRunsPerHour: number;
enabled: boolean;
lastPolledAt: string | null;
/** Why the last poll could not finish, or what it had to skip. */
lastError: string | null;
createdAt: string;
updatedAt: string;
}

/** An event a watch acted on: a run it started, or one it ignored for good. */
export interface LoopWatchTriggerWire {
key: string;
/**
* An event; `trust_anchor`: you vouched for a branch's head (`trustHead`);
* `head`: a watched head the watch judged untrusted with no event on it.
*/
kind: "ci_failure" | "review_comment" | "trust_anchor" | "head";
branch: string;
runId: string | null;
/** `retry`: its run's workspace couldn't be prepared; a later poll tries again. `vouched`: a trust anchor. */
outcome: "run" | "ignored" | "retry" | "vouched";
note: string | null;
createdAt: string;
}

/**
* `trustHead` (branch watches only): you vouch for each watched branch's head
* as it is now. It is remembered as trusted under the watch's trust list, so
* a later merge's chain of trusted merges can end at it; audited, and kept in
* the watch's history with who put the head there. Never implied by anything
* else. `true` vouches for every watched branch's head as the daemon reads
* it; a record of branch to full commit id (40 hex characters, or 64 for a
* SHA-256 repository; a prefix is refused) vouches for only those branches,
* and only while each head is still that commit — when any moved, nothing is
* vouched for and the request fails with `detail` `head_moved`, which names
* the head's full id now.
*/
export type LoopWatchTrustHead = boolean | Record<string, string>;

export interface LoopWatchCreateMsg {
type: "loop.watch.create";
id: string;
spec: LoopWatchSpecWire;
trustHead?: LoopWatchTrustHead;
}

export interface LoopWatchListMsg {
type: "loop.watch.list";
id: string;
}

export interface LoopWatchGetMsg {
type: "loop.watch.get";
id: string;
watchId: string;
}

/** Enable or disable, and/or change fields (the repository can't change). */
export interface LoopWatchUpdateMsg {
type: "loop.watch.update";
id: string;
watchId: string;
enabled?: boolean;
spec?: Partial<Omit<LoopWatchSpecWire, "workdir">>;
/** As on create: vouch for each watched branch's head as it is now (after `spec` applies). */
trustHead?: LoopWatchTrustHead;
}

export interface LoopWatchDeleteMsg {
type: "loop.watch.delete";
id: string;
watchId: string;
}

/** Poll now instead of at the next tick. */
export interface LoopWatchPollMsg {
type: "loop.watch.poll";
id: string;
watchId: string;
}

export interface LoopWatchSnapshotMsg {
type: "loop.watch.snapshot";
requestId: string;
watch: LoopWatchWire;
/** Recent events it acted on, newest first. */
triggers?: LoopWatchTriggerWire[];
/** After loop.watch.poll: the runs it started, and what it skipped. */
poll?: { started: string[]; skipped: string[] };
}

export interface LoopWatchListResultMsg {
type: "loop.watch.list.result";
requestId: string;
watches: LoopWatchWire[];
}
Loading
Loading