mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 16:23:00 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6096aac518 | ||
|
|
a279981029 | ||
|
|
d6d8fce70f | ||
|
|
40da7331ad |
No files matched your search
@@ -423,7 +423,7 @@ Each startup file and action specifies when it applies:
|
||||
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
|
||||
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
|
||||
|
||||
**A file keeps one copy of each distinct block text, and tidying the copies never removes a text you wrote.**
|
||||
**A file keeps one copy of each distinct block text, and nothing you wrote is removed.**
|
||||
- **Touched markers:** a marker an editor or formatter rewrapped, with spaces or line breaks inside the comment, still reads as its block, and the next write puts it back in OpenRig's form.
|
||||
- **Collected copies:** a copy that repeats an earlier one exactly is removed. Beside a copy you edited, OpenRig's own unedited copies are removed too, and every different edited copy stays (`guidance_block_copies_removed`).
|
||||
- **A marker that lost its pair:** OpenRig can't tell where that block starts or ends, so it adds no copy beside it and names it (`guidance_block_markers_damaged`). Put back the missing marker or remove the partial block, and OpenRig's next write treats that block like any other. `rig package install` leaves such a file as it is, names it in its result (`notes`), and installs the rest. A marker without its pair beside a complete block is named too (`guidance_block_marker_unpaired`), and the block is written.
|
||||
|
||||
@@ -180,7 +180,18 @@ and nothing else is offered:
|
||||
- **Two assistants together:** a grant with `team`/`list` sees its rigs' seats with `rig_team`, and may address and hear from
|
||||
another connected assistant's seat. A grant without it never reaches a seat in the service's caller form
|
||||
(`cus-<id>-<connection>@<rig>`), so an existing connection keeps the reach it had.
|
||||
- **Messages are items:** a message is an item the receiver reads and closes, and a reply is an item back to its sender.
|
||||
- **Messages are items:** a message is an item the receiver reads and closes, with any reply in its `resolution`. The
|
||||
sender reads that reply on the same item. Nothing wakes the sender or posts a new item for it. A separate new message is
|
||||
new work.
|
||||
- **A fair-use limit on new work:** the service can add `throttle: { new_work: true, retry_after_seconds: <n> }` to an
|
||||
active answer.
|
||||
- While it does, a `rig_queue_create` that would open a new item writes nothing. It returns `fair_use_retry_later` with
|
||||
`retryAfterSeconds` (1 to 600) and the `qitemId` to retry with.
|
||||
- A retry of a create that already landed is answered from the stored item, as before.
|
||||
- Everything else is served: reading, claiming and closing existing items (a reply in `resolution` included), and
|
||||
answering the team's questions. A separate new message or revision waits like any other new item.
|
||||
- It's never a credential refusal, and a `throttle` that can't be read is ignored.
|
||||
- The service is asked once per HTTP request (initialize and tool listing included), not once per tool call.
|
||||
- **What the sender can tell, from the item it reads:**
|
||||
- **Queued:** the item exists and is `pending` with no claim.
|
||||
- **Picked up:** the receiver's claim (`in-progress`).
|
||||
|
||||
@@ -143,8 +143,7 @@ export function downCommand(depsOverride?: DownDeps): Command {
|
||||
const cmd = new Command("down").description("Tear down a rig")
|
||||
.addHelpText("after", `
|
||||
rig down stops every agent OpenRig started in the rig: their sessions end, and any work in progress stops
|
||||
with them. For a community harness only its pane stops: its own processes may keep running, and rig down
|
||||
names it. A session OpenRig didn't start (adopted, bound or discovered) is left running, and rig down names it.
|
||||
with them. A session OpenRig didn't start (adopted, bound or discovered) is left running, and rig down names it.
|
||||
Before running it, check who is working (rig ps --nodes --rig <rig>) and that the person asked for it;
|
||||
don't stop a team you didn't start unless asked. OpenRig snapshots the rig first (best-effort), and
|
||||
rig up <rig> --existing brings it back, resuming each seat's conversation where its harness allows. With
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
//
|
||||
// With --grant-service, grants come from an external grant service instead and the token file is never read: each
|
||||
// request's key is checked with that service (openrig.grant/v1) for this daemon's instance, every answer but active is
|
||||
// refused, and an external caller reaches only what its grant names (externalScope in mcp-server.ts).
|
||||
// refused, and an external caller reaches only what its grant names (externalScope in mcp-server.ts). An active answer's
|
||||
// fair-use throttle holds back only that caller's new work (rig_queue_create), never the request.
|
||||
//
|
||||
// The endpoint serves both protocol revisions: 2026-07-28 per request, and 2025's initialize and exchanges statelessly.
|
||||
import http from "node:http";
|
||||
@@ -231,6 +232,18 @@ const unavailable = (log: string): Refusal => ({
|
||||
|
||||
const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "");
|
||||
|
||||
/** An active answer's optional fair-use limit on new work (`throttle`): the seconds until new work is accepted again,
|
||||
* rounded up and kept within 1..600, undefined when there is none, or "unusable" when it can't be read. */
|
||||
function fairUseThrottle(value: unknown): number | undefined | "unusable" {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value !== "object" || Array.isArray(value)) return "unusable";
|
||||
const { new_work: newWork, retry_after_seconds: seconds } = value as Record<string, unknown>;
|
||||
if (typeof newWork !== "boolean") return "unusable";
|
||||
if (!newWork) return undefined;
|
||||
if (typeof seconds !== "number" || !Number.isFinite(seconds)) return "unusable";
|
||||
return Math.min(600, Math.max(1, Math.ceil(seconds)));
|
||||
}
|
||||
|
||||
/** One request's caller from the grant service's answer for its key. A refusal never falls back to the token file or
|
||||
* the env's seat. */
|
||||
async function grantServiceCaller(service: GrantServiceOptions, url: URL, key: string, instance: string): Promise<{ seat: string; access: McpAccess; principal: McpPrincipal } | Refusal> {
|
||||
@@ -274,11 +287,16 @@ async function grantServiceCaller(service: GrantServiceOptions, url: URL, key: s
|
||||
if (person !== undefined && person !== null && !personId) {
|
||||
console.error(`rig mcp serve --http: grant ${grant.id} names an unusable person.entity_id, so this connection isn't offered the person tools (rig_question_*)`);
|
||||
}
|
||||
// The service's fair-use limit, when it sets one, holds back only new work (rig_queue_create). One that can't be read is
|
||||
// ignored: it never refuses the request or reads as a credential problem.
|
||||
const throttle = fairUseThrottle(body.throttle);
|
||||
if (throttle === "unusable") console.error(`rig mcp serve --http: grant ${grant.id} answered an unusable throttle, so it's ignored`);
|
||||
// The principal the daemon records beside this caller's rows. A version that isn't a finite number is left out (a v1
|
||||
// answer sends a number).
|
||||
const version = Number.isFinite(grant.version) ? { version: grant.version as number } : {};
|
||||
return { seat, access: { principal: principal.id, owner: false, rigs, roots, workspace: false,
|
||||
external: { objects, grant: { id: grant.id, ...version }, ...(personId ? { person: personId } : {}) } },
|
||||
external: { objects, grant: { id: grant.id, ...version }, ...(personId ? { person: personId } : {}),
|
||||
...(typeof throttle === "number" ? { throttle: { retryAfterSeconds: throttle } } : {}) } },
|
||||
principal: { source: "grant-service", id: principal.id, grant: grant.id, ...version } };
|
||||
}
|
||||
|
||||
|
||||
@@ -432,6 +432,17 @@ function outOfScope(what: string, hint?: string): TextResult {
|
||||
return errorResult(JSON.stringify({ ok: false, error: "out_of_scope", message }), "out_of_scope", message);
|
||||
}
|
||||
|
||||
/** A new create the grant service's fair-use limit holds back: nothing was written, and it says when to retry with the same
|
||||
* id. `unread`: whether an earlier create with this id was saved couldn't be read. */
|
||||
function fairUseRetryLater(qitemId: string, retryAfterSeconds: number, unread: boolean): TextResult {
|
||||
const earlier = unread ? ` Whether an earlier attempt with this id was saved is unknown: check rig_queue_show with qitemId ${qitemId} before retrying.` : "";
|
||||
const message = `OpenRig: this account has reached its fair-use limit for new work just now. Nothing was created by this call.${earlier}`
|
||||
+ ` Retry after ${retryAfterSeconds} seconds with the same qitemId ${qitemId}. Work you already have continues as usual: read, claim, answer and`
|
||||
+ " close it, and to reply to an item addressed to you, put the reply in its resolution when you close it.";
|
||||
return errorResult(message, "fair_use_retry_later", message,
|
||||
{ retryable: true, retryAfterSeconds, qitemId, outcome: "not_written", ...(unread ? { earlierAttempt: "unknown" } : {}) });
|
||||
}
|
||||
|
||||
/** A file the daemon read through a shared root, as text from offset: at most CONTEXT_TEXT_LIMIT characters, with
|
||||
* nextOffset when more of what the daemon read remains, and its byte cut when the file was longer than the daemon reads. */
|
||||
function artifactView(source: string, read: FileRead, offset: number): Record<string, unknown> {
|
||||
@@ -507,8 +518,10 @@ export type McpAccessClass = "work" | "workspace" | "owner" | "person";
|
||||
export type McpAccess = {
|
||||
principal: string; owner: boolean; rigs: readonly string[]; roots: readonly string[]; workspace: boolean;
|
||||
/** An external caller's grant: the object/action pairs it names (openrig.grant/v1 `objects`), the grant it answered
|
||||
* with, and `person`, the gateway entity id of the person the grant service vouches owns this connection. */
|
||||
external?: { objects: Readonly<Record<string, readonly string[]>>; grant?: { id: string; version?: number | string }; person?: string };
|
||||
* with, `person`, the gateway entity id of the person the grant service vouches owns this connection, and `throttle`,
|
||||
* the service's fair-use limit on new work for this request: the seconds before a new create is accepted again. */
|
||||
external?: { objects: Readonly<Record<string, readonly string[]>>; grant?: { id: string; version?: number | string }; person?: string;
|
||||
throttle?: { retryAfterSeconds: number } };
|
||||
};
|
||||
|
||||
/** The tool each external object/action pair offers. A pair not listed here offers nothing. */
|
||||
@@ -638,7 +651,7 @@ const EXTERNAL_PERSON_INSTRUCTIONS = [
|
||||
|
||||
/** For an external grant that names claim and update: how it works the items addressed to it, messages included. */
|
||||
const EXTERNAL_INBOX_INSTRUCTIONS = [...SHARED_INSTRUCTIONS,
|
||||
"Items addressed to your seat are yours: rig_queue_list with owned set lists them. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it, close it done, and reply, when it needs one, with a new item to its sender. Nothing promises to wake you, so check your items again.",
|
||||
"Items addressed to your seat are yours: rig_queue_list with owned set lists them. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it and close it done, with your reply in resolution when it needs one; its sender reads that reply on the same item. Nothing promises to wake you, so check your items again.",
|
||||
].join("\n");
|
||||
|
||||
function instructionsFor(access: McpAccess | undefined, frontDoor = false): string {
|
||||
@@ -1402,6 +1415,19 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
|
||||
}
|
||||
}
|
||||
const id = qitemId ?? `qitem-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(8).toString("hex")}`;
|
||||
// The grant service's fair-use limit holds back new work only. A retry of a create that already landed isn't new
|
||||
// work: the daemon answers it from the stored row, so it goes through as before.
|
||||
const throttle = access?.external?.throttle;
|
||||
if (throttle) {
|
||||
let landed: boolean | undefined = false;
|
||||
if (qitemId) {
|
||||
try {
|
||||
const existing = await client.get(`/api/queue/${encodeURIComponent(qitemId)}`);
|
||||
landed = existing.status < 400 ? true : existing.status === 404 ? false : undefined;
|
||||
} catch { landed = undefined; }
|
||||
}
|
||||
if (landed !== true) return fairUseRetryLater(id, throttle.retryAfterSeconds, landed === undefined);
|
||||
}
|
||||
// With no seat, the task goes to its destination as from the unknown sender, with the note at the top of its body.
|
||||
const note = unknownSenderNote("task");
|
||||
try {
|
||||
|
||||
@@ -1045,6 +1045,94 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
|
||||
expect((await asExternal("rig_queue_create", { destination: "lead@team-a", body: "far", qitemId: far })).text).not.toContain("other-host");
|
||||
});
|
||||
|
||||
describe("a fair-use throttle on an active answer", () => {
|
||||
const ALL = { queue: ["create", "show", "list", "claim", "update"] };
|
||||
const throttled = (throttle: unknown) => (): [number, unknown] => [200, active({ objects: ALL, throttle })];
|
||||
|
||||
it("holds back only new creates, which write nothing and say when to retry with the same id; a replay, claim, update, show and list are served", async () => {
|
||||
answer = () => [200, active({ objects: ALL })];
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
const landed = { destination: "lead@team-a", body: "First task", qitemId: "qitem-landed" };
|
||||
expect((await ask("rig_queue_create", landed)).isError).toBe(false);
|
||||
const theirs = (await create("lead@team-a", CALLER, "A task for the assistant")).qitemId;
|
||||
answer = throttled({ new_work: true, retry_after_seconds: 30 });
|
||||
const before = rows();
|
||||
for (const args of [
|
||||
{ destination: "lead@team-a", body: "New work" },
|
||||
{ destination: "lead@team-a", body: "New work", qitemId: "qitem-new" },
|
||||
{ destination: "lead@team-a", body: "Improve it", revises: landed.qitemId },
|
||||
] as Array<Record<string, string>>) {
|
||||
const held = await ask("rig_queue_create", args);
|
||||
expect(held.isError).toBe(true);
|
||||
expect(held.structured).toMatchObject({ ok: false, code: "fair_use_retry_later", retryable: true, retryAfterSeconds: 30, outcome: "not_written" });
|
||||
expect(held.structured.earlierAttempt).toBeUndefined();
|
||||
expect(held.text).toContain("Nothing was created by this call.");
|
||||
expect(held.text).toContain(`Retry after 30 seconds with the same qitemId ${held.structured.qitemId}.`);
|
||||
if (args.qitemId) expect(held.structured.qitemId).toBe(args.qitemId);
|
||||
}
|
||||
expect(rows()).toBe(before);
|
||||
expect(repo.getById("qitem-new")).toBeFalsy();
|
||||
const replay = await ask("rig_queue_create", landed);
|
||||
expect(replay.isError).toBe(false);
|
||||
expect(replay.structured.qitemId).toBe(landed.qitemId);
|
||||
expect((await ask("rig_queue_claim", { qitemId: theirs })).isError).toBe(false);
|
||||
expect((await ask("rig_queue_update", { qitemId: theirs, state: "done", closureReason: "no-follow-on", resolution: "Done" })).isError).toBe(false);
|
||||
expect(repo.getById(theirs)).toMatchObject({ state: "done", resolution: "Done" });
|
||||
expect((await ask("rig_queue_show", { qitemId: landed.qitemId })).isError).toBe(false);
|
||||
expect((await ask("rig_queue_list", {})).isError).toBe(false);
|
||||
expect(rows()).toBe(before);
|
||||
// Once the service lifts it, the same retry lands.
|
||||
answer = () => [200, active({ objects: ALL })];
|
||||
expect((await ask("rig_queue_create", { destination: "lead@team-a", body: "New work", qitemId: "qitem-new" })).isError).toBe(false);
|
||||
expect(rows()).toBe(before + 1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a string", "yes"],
|
||||
["a list", [true]],
|
||||
["new_work as text", { new_work: "true", retry_after_seconds: 30 }],
|
||||
["a delay as text", { new_work: true, retry_after_seconds: "30" }],
|
||||
["no delay", { new_work: true }],
|
||||
])("ignores a throttle it can't read (%s): nothing is refused, and the log says so", async (_label, throttle) => {
|
||||
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
answer = throttled(throttle);
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
expect((await ask("rig_queue_create", { destination: "lead@team-a", body: "Served" })).isError).toBe(false);
|
||||
expect(rows()).toBe(1);
|
||||
expect(logged.mock.calls.some(([line]) => String(line).includes("answered an unusable throttle, so it's ignored"))).toBe(true);
|
||||
logged.mockRestore();
|
||||
});
|
||||
|
||||
it("serves everything when new_work is false, and keeps a delay within 1 to 600 seconds, rounded up", async () => {
|
||||
answer = throttled({ new_work: false, retry_after_seconds: 30 });
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
expect((await ask("rig_queue_create", { destination: "lead@team-a", body: "Served" })).isError).toBe(false);
|
||||
for (const [given, kept] of [[0, 1], [-5, 1], [12.2, 13], [9000, 600]]) {
|
||||
answer = throttled({ new_work: true, retry_after_seconds: given });
|
||||
expect((await ask("rig_queue_create", { destination: "lead@team-a", body: "Held" })).structured.retryAfterSeconds).toBe(kept);
|
||||
}
|
||||
expect(rows()).toBe(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["answers an error", () => new Response(JSON.stringify({ error: "boom" }), { status: 500, headers: { "Content-Type": "application/json" } })],
|
||||
["answers a body it can't parse", () => new Response("{", { status: 200, headers: { "Content-Type": "application/json" } })],
|
||||
])("writes nothing, and says an earlier attempt is unknown, when the check of whether the id landed %s", async (_label, failure) => {
|
||||
answer = throttled({ new_work: true, retry_after_seconds: 5 });
|
||||
const { url } = await listen();
|
||||
const ask = await connect(url);
|
||||
broken = (req) => req.method === "GET" && new URL(req.url).pathname === "/api/queue/qitem-unread" ? failure() : undefined;
|
||||
const held = await ask("rig_queue_create", { destination: "lead@team-a", body: "Maybe", qitemId: "qitem-unread" });
|
||||
broken = undefined;
|
||||
expect(held.structured).toMatchObject({ code: "fair_use_retry_later", outcome: "not_written", earlierAttempt: "unknown", qitemId: "qitem-unread" });
|
||||
expect(held.text).toContain("Whether an earlier attempt with this id was saved is unknown");
|
||||
expect(rows()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("a task parked on an internal child reads blocked, with no child id, owner or state anywhere", async () => {
|
||||
const { asExternal, asLead, mine } = await externalAndLead();
|
||||
const child = await create("lead@team-a", "qa@team-b", "SECRET child");
|
||||
|
||||
@@ -50,6 +50,8 @@ describe("agent network increment A: OAuth-connected assistants work with each o
|
||||
let serviceUrl: string;
|
||||
/** What each connection's grant names, by key; a key set to "revoked" is answered revoked. */
|
||||
let grants: Map<string, Record<string, string[]> | "revoked">;
|
||||
/** The keys whose active answer carries the grant service's fair-use throttle on new work. */
|
||||
let throttled: Set<string>;
|
||||
const closers: Array<() => Promise<void>> = [];
|
||||
const saved = { home: process.env.OPENRIG_HOME, session: process.env.OPENRIG_SESSION_NAME };
|
||||
|
||||
@@ -65,6 +67,7 @@ describe("agent network increment A: OAuth-connected assistants work with each o
|
||||
daemonUrl = `http://127.0.0.1:${(daemon.address() as AddressInfo).port}`;
|
||||
setSelfHostId(INSTANCE);
|
||||
grants = new Map([[A.key, NETWORK], [B.key, NETWORK]]);
|
||||
throttled = new Set();
|
||||
service = http.createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
@@ -72,7 +75,8 @@ describe("agent network increment A: OAuth-connected assistants work with each o
|
||||
const who = key === A.key ? A : key === B.key ? B : null;
|
||||
const objects = who ? grants.get(who.key) : undefined;
|
||||
const body = !who || !objects ? { schema: SCHEMA, status: "unknown" }
|
||||
: objects === "revoked" ? { schema: SCHEMA, status: "revoked" } : active(who, objects);
|
||||
: objects === "revoked" ? { schema: SCHEMA, status: "revoked" }
|
||||
: { ...active(who, objects), ...(throttled.has(who.key) ? { throttle: { new_work: true, retry_after_seconds: 20 } } : {}) };
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(body));
|
||||
});
|
||||
});
|
||||
@@ -113,7 +117,7 @@ describe("agent network increment A: OAuth-connected assistants work with each o
|
||||
const result = await client.callTool({ name, arguments: args });
|
||||
return { isError: result.isError === true, structured: result.structuredContent as Record<string, any>, text: (result.content as Array<{ text: string }>)[0]?.text ?? "" };
|
||||
};
|
||||
return Object.assign(call, { tools: async () => (await client.listTools()).tools.map((tool) => tool.name).sort() });
|
||||
return Object.assign(call, { tools: async () => (await client.listTools()).tools.map((tool) => tool.name).sort(), instructions: () => client.getInstructions() });
|
||||
}
|
||||
|
||||
const rows = () => (db.prepare("SELECT COUNT(*) AS n FROM queue_items").get() as { n: number }).n;
|
||||
@@ -174,6 +178,42 @@ describe("agent network increment A: OAuth-connected assistants work with each o
|
||||
expect((await a("rig_queue_show", { qitemId: reply })).text).toContain("Agreed; I'll review the fix.");
|
||||
});
|
||||
|
||||
it("under a fair-use throttle, B answers A's message on the same item and A reads that answer through its own grant; B's separate new message waits", async () => {
|
||||
const served = await listen();
|
||||
const a = await connect(served.url, A);
|
||||
const b = await connect(served.url, B);
|
||||
expect(b.instructions()).toContain("with your reply in resolution when it needs one; its sender reads that reply on the same item");
|
||||
expect(b.instructions()).not.toContain("with a new item to its sender");
|
||||
const message = createdId(await a("rig_queue_create", { destination: B.seat, summary: "Which fix first?", body: "Smaller fix or the full one?", tags: ["message"] }));
|
||||
throttled.add(B.key);
|
||||
const before = rows();
|
||||
expect((await b("rig_queue_list", { owned: true })).text).toContain(message);
|
||||
expect((await b("rig_queue_claim", { qitemId: message })).isError).toBe(false);
|
||||
expect((await b("rig_queue_update", { qitemId: message, state: "done", closureReason: "no-follow-on", resolution: "The smaller fix first." })).isError).toBe(false);
|
||||
const separate = await b("rig_queue_create", { destination: A.seat, summary: "Also", body: "One more thing.", tags: ["message"] });
|
||||
expect(separate).toMatchObject({ isError: true, structured: { code: "fair_use_retry_later", retryAfterSeconds: 20, outcome: "not_written" } });
|
||||
expect(rows()).toBe(before);
|
||||
// A, the sender, isn't throttled, and reads B's answer on its own item through its own grant's ordinary show and list.
|
||||
const answered = await a("rig_queue_show", { qitemId: message });
|
||||
expect(answered.structured.item).toMatchObject({ state: "done", resolution: "The smaller fix first.", destinationSession: B.seat });
|
||||
expect((await a("rig_queue_list", { mine: true, all: true })).text).toContain("The smaller fix first.");
|
||||
});
|
||||
|
||||
it("under a fair-use throttle, A answers the lead's message on the same item and the lead reads it on its ordinary read; A's new item to the lead waits", async () => {
|
||||
const served = await listen();
|
||||
const a = await connect(served.url, A);
|
||||
const message = (await repo.create({ sourceSession: LEAD, destinationSession: A.seat, body: "Can you take the review?", nudge: false } as never)).qitemId;
|
||||
throttled.add(A.key);
|
||||
const before = rows();
|
||||
expect((await a("rig_queue_claim", { qitemId: message })).isError).toBe(false);
|
||||
expect((await a("rig_queue_update", { qitemId: message, state: "done", closureReason: "no-follow-on", resolution: "Yes, after lunch." })).isError).toBe(false);
|
||||
expect((await a("rig_queue_create", { destination: LEAD, body: "Starting the review." })).structured).toMatchObject({ code: "fair_use_retry_later", outcome: "not_written" });
|
||||
expect(rows()).toBe(before);
|
||||
const read = await fetch(`${daemonUrl}/api/queue/${encodeURIComponent(message)}`, { headers: { "X-OpenRig-Session": LEAD } });
|
||||
expect(read.status).toBe(200);
|
||||
expect(await read.json()).toMatchObject({ sourceSession: LEAD, state: "done", resolution: "Yes, after lunch." });
|
||||
});
|
||||
|
||||
it("B hands work on to the team and reads the team's result; A's task and B's task are separate items, each with its own result", async () => {
|
||||
const served = await listen();
|
||||
const b = await connect(served.url, B);
|
||||
|
||||
+4
-4
@@ -163,8 +163,8 @@ is not a restore request.
|
||||
1. **Check for a hold first.** Read the restore request, the per-seat instruction file
|
||||
(`<OPENRIG_HOME>/compaction/post-compact-extra/<session>.md`, named after your full session, such as
|
||||
`dev-impl@my-rig` with `.md` added) when it exists, the newest row or message from whoever routes your work, and any hold
|
||||
from the authority above them. A hold, a release order or an operator's own restore map overrides the default
|
||||
order below. Before any write, also run `rig whoami --json` and `rig queue whoami`.
|
||||
from the authority above them. A hold you verified as live, a release order or an operator's own restore map overrides
|
||||
the default order below for the work it covers; do the unaffected work. Before any write, also run `rig whoami --json` and `rig queue whoami`.
|
||||
2. **Name your class and state its read budget** before reading (see "Two restore classes"), with a
|
||||
checkpoint at each step below. The budget exists so that the restore leaves room for the work it was
|
||||
restored to do. Step 5 takes a high-context seat to its Tier 2 line; the extra sources under "Two restore classes" apply only
|
||||
@@ -218,7 +218,7 @@ The audit message asks for a read-depth table and tells you not to conserve toke
|
||||
- where you are in time: what earlier windows established, what the last window did, what is true now;
|
||||
- the connections you have rebuilt, in a few lines;
|
||||
- the connections you could not rebuild, and where you would look;
|
||||
- the next authorized step and who authorizes it. If a hold stands, the next step is waiting.
|
||||
- the next authorized step and who authorizes it. If a hold you verified as live covers it, wait on that; do the unaffected work.
|
||||
|
||||
The next restore map links this note, which keeps the chain unbroken.
|
||||
|
||||
@@ -238,7 +238,7 @@ unaffected work.
|
||||
- Do not launch a fresh session in place of restoring.
|
||||
- An honest `PARTIAL` with its reason is a correct outcome. Claiming coverage you did not reach is the
|
||||
failure.
|
||||
- Do not resume task work until the read-depth table and the reconnect note exist.
|
||||
- Write the read-depth table and the reconnect note as you resume; don't hold unaffected work for them.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -1278,7 +1278,8 @@ reaching them is ordinary work rather than an escalation.
|
||||
- **The `relay` gateway connector** carries a registered person's questions, updates and notices to a
|
||||
bridge process you run, over loopback HTTP, and takes their replies back with the sender resolved
|
||||
against their bindings. It's off until a person has a relay binding (`rig gateway human add <id> …
|
||||
--binding "relay:<connectorRef>:file:<absolute env file>:primary"`), and the bridge, with its reach to
|
||||
--binding "relay:<connectorRef>:file:<absolute env file>:primary:handle=<alias>"`). The handle is how
|
||||
their replies are matched to them: a binding without one is outbound-only. The bridge, with its reach to
|
||||
any messaging platform, is yours (`docs/reference/gateway-relay.md`).
|
||||
- **`rig gateway channel add <id> --display-name <name> --binding
|
||||
slack:<connectorRef>:<secretsRef>:<channelId>`** — a Slack shared channel as one participant,
|
||||
@@ -1597,8 +1598,11 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
without that storage), so don't copy them by hand; read the `plugin_skill_*` warnings if one
|
||||
is missing. TDD is no longer a default skill.
|
||||
- **`on_restore: follow_source`** on a spec's `resources.skills[]` entry refreshes that skill from its source when
|
||||
a Claude or Codex seat is restored, keeping its conversation; `keep_saved`, the default, keeps the saved copy, but a
|
||||
skill whose source changed after `rig down` comes back missing, named in the restore's warnings.
|
||||
a Claude or Codex seat is restored, keeping its conversation, while the seat's copy is still what OpenRig wrote: an
|
||||
edited, unrecorded or linked copy is kept and named, and a source that can't be read leaves the saved copy.
|
||||
`keep_saved`, the default, keeps the saved copy, but a missing file comes back only from a source that still has the
|
||||
bytes OpenRig wrote: a file whose source changed after `rig down` stays missing, named in the restore's warnings,
|
||||
even when the rest of the skill (its `SKILL.md`, say) is unchanged.
|
||||
- **`rig startup-proof submit`** — answer an explicitly selected authenticated startup challenge.
|
||||
Startup adds no proof exercise by default. Declare `startup_proof` with `authenticated` or
|
||||
`none` in the applicable startup layers; see `docs/reference/rig-spec.md#startup-block/startup-proof-selection`.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join, isAbsolute } from "node:path";
|
||||
import { getOpenRigHome } from "../../openrig-compat.js";
|
||||
import { SETTINGS_VALID_KEYS, type SettingsStore, type ResolvedSetting } from "./settings-store.js";
|
||||
import { SETTINGS_VALID_KEYS, describeSettingKey, type SettingKeyDescription, type SettingsStore, type ResolvedSetting } from "./settings-store.js";
|
||||
import { connectionsProjection, readConnectionConfiguration } from "../gateway/connections-projection.js";
|
||||
import { DEFAULT_CONFIG, INHERIT_INBOUND_DESTINATION, LEGACY_INBOUND_DESTINATION } from "../gateway/slack/config.js";
|
||||
import { projectionPath } from "../gateway/human-registry.js";
|
||||
@@ -13,7 +13,13 @@ import { describeDefaultReceiver, type DefaultReceiver } from "../default-receiv
|
||||
type Value = string | number | boolean | null;
|
||||
type State = "available" | "missing" | "malformed" | "unavailable";
|
||||
type Kind = "scalar" | "text" | "path" | "identity" | "url" | "names" | "paths" | "withheld";
|
||||
export interface BrowserEntry {
|
||||
/** Each is optional and separate: the rules a value must meet, whether a write route exists for the key, when a change is
|
||||
* known to take effect, and (above, on the entry) whether this caller is shown the value. None implies another. */
|
||||
export interface BrowserEntryMetadata extends SettingKeyDescription {
|
||||
/** "daemon_restart" when the application guidance says so; otherwise "unknown": OpenRig does not guess. */
|
||||
appliesAt: "daemon_restart" | "unknown";
|
||||
}
|
||||
export interface BrowserEntry extends BrowserEntryMetadata {
|
||||
key: string;
|
||||
group: "general" | "slack" | "people" | "hosts" | "health";
|
||||
value: Value;
|
||||
@@ -90,6 +96,8 @@ function sourceFile(file: string): { state: State; bytes: string | null } {
|
||||
try { return { state: "available", bytes: readFileSync(file, "utf8") }; }
|
||||
catch (error) { return { state: (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unavailable", bytes: null }; }
|
||||
}
|
||||
/** The keys whose guidance below says a daemon restart or startup applies them. The browser test ties this to that text. */
|
||||
const DAEMON_RESTART_KEYS = new Set(["terminal.tmux_server", "retention.enabled"]);
|
||||
function application(key: string): string {
|
||||
if (key === "terminal.tmux_server") return "Selected at daemon restart; surviving sessions keep their previous server until deliberately stopped. See terminal status for the effective choice.";
|
||||
if (key === "ui.terminal.max_live_terminals") return "Legacy web client; unset resolver value, consumer fallback 2. No TUI limit implied.";
|
||||
@@ -119,7 +127,8 @@ export function settingsBrowser(store: SettingsStore, gateway: Record<string, un
|
||||
const d = safeValue(defaultValue, kind, redact);
|
||||
entries.push({ key, group, value: v.value, defaultValue: d.value, defaultKnown: defaultValue !== undefined && !["people", "hosts"].includes(group), source,
|
||||
visibility: source === "unavailable" ? "unavailable" : v.value === null && v.reason ? "withheld" : "shown",
|
||||
reason: v.reason ?? d.reason, scope, application: applied });
|
||||
reason: v.reason ?? d.reason, scope, application: applied,
|
||||
...describeSettingKey(key), appliesAt: DAEMON_RESTART_KEYS.has(key) ? "daemon_restart" : "unknown" });
|
||||
}
|
||||
const file = sourceFile(store.configPath);
|
||||
let generalState = file.state;
|
||||
|
||||
@@ -842,6 +842,20 @@ function coerceValue(key: SettingsValidKey, raw: string, workspaceRoot: string):
|
||||
// in `set()`. Lockstep with cli/src/config-store.ts KEY_CONSTRAINTS so the
|
||||
// daemon's HTTP write surface (/api/config POST) rejects the same input
|
||||
// the CLI rejects.
|
||||
/** What a validator can say about the values it accepts, kept on the validator itself so it cannot drift from it. */
|
||||
interface ConstraintMeta { choices?: readonly string[]; format?: string }
|
||||
type KeyConstraint = ((raw: string, coerced: string | number | boolean) => void) & { meta?: ConstraintMeta };
|
||||
function described(meta: ConstraintMeta, check: (raw: string, coerced: string | number | boolean) => void): KeyConstraint {
|
||||
return Object.assign(check, { meta });
|
||||
}
|
||||
/** A closed list of accepted strings: the list is both what is enforced and what the browser view reports. */
|
||||
function choiceConstraint(choices: readonly string[], message: (raw: string) => string, trim = false): KeyConstraint {
|
||||
return described({ choices }, (raw) => {
|
||||
if (!choices.includes(trim ? (raw ?? "").trim() : raw)) throw new Error(message(raw));
|
||||
});
|
||||
}
|
||||
const PROVIDER_SELECTED_CHOICES: readonly string[] = ["", "claude", "codex", "claude,codex", "codex,claude"];
|
||||
|
||||
function positiveIntegerConstraint(key: string) {
|
||||
return (raw: string, coerced: string | number | boolean): void => {
|
||||
if (!/^\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced <= 0) {
|
||||
@@ -850,12 +864,6 @@ function positiveIntegerConstraint(key: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function wakeHumanRungConstraint(raw: string): void {
|
||||
if (raw !== "always" && raw !== "explicit-only") {
|
||||
throw new Error(`Invalid value for queue.wake_human_rung: must be "always" or "explicit-only", got "${raw}"`);
|
||||
}
|
||||
}
|
||||
|
||||
function percentageConstraint(key: string) {
|
||||
return (raw: string, coerced: string | number | boolean): void => {
|
||||
if (!/^\d+$/.test((raw ?? "").trim())
|
||||
@@ -868,15 +876,10 @@ function percentageConstraint(key: string) {
|
||||
};
|
||||
}
|
||||
|
||||
const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: string | number | boolean) => void>> = {
|
||||
"terminal.tmux_server": (raw) => {
|
||||
if (raw !== "inherit" && raw !== "isolated") {
|
||||
throw new Error(`Invalid value for terminal.tmux_server: must be "inherit" or "isolated", got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"daemon.db_conflict": (raw) => {
|
||||
if (raw !== "warn" && raw !== "stop") throw new Error("daemon.db_conflict must be warn or stop");
|
||||
},
|
||||
const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, KeyConstraint>> = {
|
||||
"terminal.tmux_server": choiceConstraint(["inherit", "isolated"],
|
||||
(raw) => `Invalid value for terminal.tmux_server: must be "inherit" or "isolated", got "${raw}"`),
|
||||
"daemon.db_conflict": choiceConstraint(["warn", "stop"], () => "daemon.db_conflict must be warn or stop"),
|
||||
"daemon.seat_poll_interval_ms": (raw, value) => {
|
||||
// Startup-only: the seat pollers read it once when the daemon starts. Only slower than the 1 s default, up to the
|
||||
// ceiling the structural observation's expiry allows (seat-activity-service.ts MAX_SEAT_POLL_INTERVAL_MS).
|
||||
@@ -884,9 +887,8 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
|
||||
throw new Error("Invalid daemon.seat_poll_interval_ms: must be a whole number of milliseconds in [1000, 2000]");
|
||||
}
|
||||
},
|
||||
"transport.draft_delivery": (raw) => {
|
||||
if (raw !== "refuse" && raw !== "deliver") throw new Error(`Invalid value for transport.draft_delivery: must be "refuse" or "deliver", got "${raw}"`);
|
||||
},
|
||||
"transport.draft_delivery": choiceConstraint(["refuse", "deliver"],
|
||||
(raw) => `Invalid value for transport.draft_delivery: must be "refuse" or "deliver", got "${raw}"`),
|
||||
"transcripts.lines": (raw, value) => {
|
||||
if (!/^\d+$/.test(raw.trim()) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 1_000_000) {
|
||||
throw new Error("Invalid transcripts.lines: must be an integer in [1, 1000000]");
|
||||
@@ -928,37 +930,34 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
|
||||
throw new Error(`Invalid value for daemon.port: must be an integer in [0, 65535], got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"ui.timezone": (_raw, value) => {
|
||||
"ui.timezone": described({ format: "iana-timezone" }, (_raw, value) => {
|
||||
try {
|
||||
if (typeof value !== "string" || !value || /^[+-]/.test(value)) throw new Error();
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: value });
|
||||
} catch { throw new Error("Invalid ui.timezone: use an IANA timezone such as America/Los_Angeles or Europe/London"); }
|
||||
},
|
||||
}),
|
||||
"health.context_pressure.warning_percent": percentageConstraint("health.context_pressure.warning_percent"),
|
||||
"health.context_pressure.critical_percent": percentageConstraint("health.context_pressure.critical_percent"),
|
||||
"policies.idle_gate_qitem.scan_interval_seconds": positiveIntegerConstraint("policies.idle_gate_qitem.scan_interval_seconds"),
|
||||
"policies.idle_gate_qitem.active_wake_interval_seconds": positiveIntegerConstraint("policies.idle_gate_qitem.active_wake_interval_seconds"),
|
||||
"policies.idle_gate_qitem.auto_register": (raw) => {
|
||||
const v = (raw ?? "").trim();
|
||||
if (v !== "off" && v !== "all") {
|
||||
throw new Error(`Invalid value for policies.idle_gate_qitem.auto_register: must be "off" or "all", got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"policies.idle_gate_qitem.auto_register": choiceConstraint(["off", "all"],
|
||||
(raw) => `Invalid value for policies.idle_gate_qitem.auto_register: must be "off" or "all", got "${raw}"`, true),
|
||||
// Policy threshold: integer in [1, 100]. Documented contract from
|
||||
// slice 27 README. parseInt's permissive coercion ("80abc" → 80;
|
||||
// "80.5" → 80) is not safe for a key the daemon's compaction trigger
|
||||
// reads at every poll tick; runtime validator rejects what the
|
||||
// contract forbids per banked feedback_static_gates_mirror_runtime_validators.
|
||||
"provider.selected": (_raw, value) => {
|
||||
if (typeof value !== "string" || !/^(|claude|codex|claude,codex|codex,claude)$/.test(value)) {
|
||||
// Checks the typed value too: a config.json array would otherwise pass as its String() form.
|
||||
"provider.selected": described({ choices: PROVIDER_SELECTED_CHOICES }, (_raw, value) => {
|
||||
if (typeof value !== "string" || !PROVIDER_SELECTED_CHOICES.includes(value)) {
|
||||
throw new Error("Invalid provider.selected: use claude, codex, claude,codex, or an empty string for no recorded choice");
|
||||
}
|
||||
},
|
||||
"policies.claude_compaction.preparation_root": (_raw, value) => {
|
||||
}),
|
||||
"policies.claude_compaction.preparation_root": described({ format: "absolute-path-or-empty" }, (_raw, value) => {
|
||||
if (typeof value !== "string" || (value !== "" && (!path.isAbsolute(value) || value.includes("\0")))) {
|
||||
throw new Error("Invalid policies.claude_compaction.preparation_root: use an absolute path or an empty string for the default");
|
||||
}
|
||||
},
|
||||
}),
|
||||
"policies.claude_compaction.threshold_percent": (raw, coerced) => {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!/^-?\d+$/.test(trimmed)) {
|
||||
@@ -1049,12 +1048,12 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
|
||||
}
|
||||
},
|
||||
// Empty (the default) keeps no erasure ledger; otherwise an absolute file path, kept outside the backed-up data.
|
||||
"retention.erasure_ledger": (raw) => {
|
||||
"retention.erasure_ledger": described({ format: "absolute-path-or-empty" }, (raw) => {
|
||||
const value = (raw ?? "").trim();
|
||||
if (value !== "" && !value.startsWith("/")) {
|
||||
throw new Error(`Invalid value for retention.erasure_ledger: must be empty or an absolute file path, got "${raw}"`);
|
||||
}
|
||||
},
|
||||
}),
|
||||
"retention.watchdog_keep_per_job": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 0) {
|
||||
throw new Error(`Invalid value for retention.watchdog_keep_per_job: must be an integer >= 0, got "${raw}"`);
|
||||
@@ -1070,12 +1069,12 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
|
||||
throw new Error(`Invalid value for retention.stream_max_items: must be an integer >= 1, got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"stream.intake": (raw) => {
|
||||
"stream.intake": described({ format: "queue.worker, off, or member@rig" }, (raw) => {
|
||||
const val = (raw ?? "").trim();
|
||||
if (val !== "off" && val !== "queue.worker" && !/^[a-zA-Z0-9\-_.]+@[a-zA-Z0-9\-_.]+$/.test(val)) {
|
||||
throw new Error(`Invalid value for stream.intake: must be "queue.worker", a canonical session (<seat>@<rig>), or "off", got "${raw}"`);
|
||||
}
|
||||
},
|
||||
}),
|
||||
"retention.batch_size": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
|
||||
throw new Error(`Invalid value for retention.batch_size: must be an integer >= 1, got "${raw}"`);
|
||||
@@ -1089,18 +1088,19 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
|
||||
"queue.wake_retry_cap": positiveIntegerConstraint("queue.wake_retry_cap"),
|
||||
"queue.wake_unconfirmed_window_minutes": positiveIntegerConstraint("queue.wake_unconfirmed_window_minutes"),
|
||||
"queue.wake_swap_grace_seconds": positiveIntegerConstraint("queue.wake_swap_grace_seconds"),
|
||||
"queue.wake_human_rung": wakeHumanRungConstraint,
|
||||
"decisions.template_dir": (_raw, value) => {
|
||||
"queue.wake_human_rung": choiceConstraint(["always", "explicit-only"],
|
||||
(raw) => `Invalid value for queue.wake_human_rung: must be "always" or "explicit-only", got "${raw}"`),
|
||||
"decisions.template_dir": described({ format: "absolute-path-or-empty" }, (_raw, value) => {
|
||||
if (typeof value !== "string" || (value !== "" && (!path.isAbsolute(value) || value.includes("\0")))) {
|
||||
throw new Error("Invalid decisions.template_dir: use an absolute path, or an empty string for the shipped templates only");
|
||||
}
|
||||
},
|
||||
}),
|
||||
// rig acp serve's receiver when --to isn't given: a team seat, member@rig, or empty for none.
|
||||
"acp.to": (_raw, value) => {
|
||||
"acp.to": described({ format: "member@rig-or-empty" }, (_raw, value) => {
|
||||
if (typeof value !== "string" || (value !== "" && !/^[^@\s]+@[^@\s]+$/.test(value))) {
|
||||
throw new Error("Invalid acp.to: use the seat that receives your ACP tasks as member@rig, or an empty string for none");
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
function validateKeyConstraints(key: SettingsValidKey, raw: string, coerced: string | number | boolean): void {
|
||||
@@ -1126,6 +1126,58 @@ function validateTypedFileValue(key: SettingsValidKey, value: string | number |
|
||||
check(raw, value);
|
||||
}
|
||||
|
||||
/** What the browser view reports about a key's values, read from the rules that govern it. */
|
||||
export interface SettingKeyDescription {
|
||||
/** Whether POST and DELETE /api/config/:key accept this key. */
|
||||
writeRoute: boolean;
|
||||
type?: "boolean" | "integer" | "string" | "enum";
|
||||
choices?: string[];
|
||||
range?: { min?: number; max?: number };
|
||||
format?: string;
|
||||
}
|
||||
|
||||
const ACCEPTED_INTEGER_LIMIT = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
/** The interval of whole numbers a key's validator accepts, found by asking the validator itself; a side it does not bound is left out. */
|
||||
function acceptedIntegerRange(key: SettingsValidKey, start: number): { min?: number; max?: number } | undefined {
|
||||
const check = KEY_CONSTRAINTS[key];
|
||||
if (!check) return undefined;
|
||||
const accepts = (n: number): boolean => {
|
||||
try { check(String(n), n); return true; } catch { return false; }
|
||||
};
|
||||
if (!accepts(start)) return undefined;
|
||||
const range: { min?: number; max?: number } = {};
|
||||
if (!accepts(ACCEPTED_INTEGER_LIMIT)) {
|
||||
let lo = start, hi = ACCEPTED_INTEGER_LIMIT;
|
||||
while (hi - lo > 1) { const mid = Math.floor(lo + (hi - lo) / 2); if (accepts(mid)) lo = mid; else hi = mid; }
|
||||
range.max = lo;
|
||||
}
|
||||
if (!accepts(-ACCEPTED_INTEGER_LIMIT)) {
|
||||
let lo = -ACCEPTED_INTEGER_LIMIT, hi = start;
|
||||
while (hi - lo > 1) { const mid = Math.floor(lo + (hi - lo) / 2); if (accepts(mid)) hi = mid; else lo = mid; }
|
||||
range.min = hi;
|
||||
}
|
||||
return range.min === undefined && range.max === undefined ? undefined : range;
|
||||
}
|
||||
|
||||
/** Derived from coerceValue (the type), KEY_CONSTRAINTS (choices, format, range) and the key grammar (write route); no second list. */
|
||||
export function describeSettingKey(key: string): SettingKeyDescription {
|
||||
if (parseFeedHostSubscriptionKey(key)) return { writeRoute: true, type: "boolean" };
|
||||
if (!isSettingsValidKey(key)) return { writeRoute: false };
|
||||
const fallback = getDefaultValue(key, "");
|
||||
const meta = KEY_CONSTRAINTS[key]?.meta;
|
||||
const out: SettingKeyDescription = { writeRoute: true };
|
||||
if (typeof fallback === "boolean") out.type = "boolean";
|
||||
else if (typeof fallback === "number") {
|
||||
out.type = "integer";
|
||||
const range = acceptedIntegerRange(key, fallback);
|
||||
if (range) out.range = range;
|
||||
} else out.type = meta?.choices ? "enum" : "string";
|
||||
if (meta?.choices) out.choices = [...meta.choices];
|
||||
if (meta?.format) out.format = meta.format;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice 27 — projected Claude auto-compaction policy. ContextMonitor
|
||||
* consumes this snapshot per-poll; the PreCompact hook reads the same
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { SettingsStore, SETTINGS_VALID_KEYS, isSettingsValidKey, parseFeedHostSubscriptionKey } from "../src/domain/user-settings/settings-store.js";
|
||||
import { settingsBrowser } from "../src/domain/user-settings/settings-browser.js";
|
||||
import { configRoutes } from "../src/routes/config.js";
|
||||
|
||||
let home: string;
|
||||
let n = 0;
|
||||
const freshStore = () => new SettingsStore(join(home, `config-${n++}.json`));
|
||||
const browse = () => settingsBrowser(freshStore(), null, home);
|
||||
/** The write path is the oracle: does the daemon's own set() accept this raw value for this key? */
|
||||
const accepts = (key: string, raw: string): boolean => {
|
||||
try { freshStore().set(key, raw); return true; } catch { return false; }
|
||||
};
|
||||
/** Each accepts 1 to 100 on its own, but set() also requires warning < critical: a cross-key rule these per-key fields do not describe. */
|
||||
const PAIRED = new Set(["health.context_pressure.warning_percent", "health.context_pressure.critical_percent"]);
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "s06-browser-meta-"));
|
||||
for (const key of Object.keys(process.env).filter((k) => /^(OPENRIG_|RIGGED_|SLACK_)/.test(k))) vi.stubEnv(key, "");
|
||||
vi.stubEnv("OPENRIG_HOME", home);
|
||||
vi.stubEnv("OPENRIG_WORKSPACE_ROOT", join(home, "workspace"));
|
||||
});
|
||||
afterEach(() => { vi.unstubAllEnvs(); rmSync(home, { recursive: true, force: true }); });
|
||||
|
||||
describe("browser entry metadata", () => {
|
||||
it("adds type, choices, range, format, write route and timing without changing the existing fields", () => {
|
||||
const entries = browse().entries;
|
||||
expect(entries.find((e) => e.key === "terminal.history_limit")).toMatchObject({
|
||||
type: "integer", range: { min: 2000, max: 1_000_000 }, writeRoute: true, appliesAt: "unknown",
|
||||
value: expect.anything(), defaultKnown: true, application: expect.stringContaining("tmux"),
|
||||
});
|
||||
expect(entries.find((e) => e.key === "terminal.tmux_server")).toMatchObject({
|
||||
type: "enum", choices: ["inherit", "isolated"], writeRoute: true, appliesAt: "daemon_restart",
|
||||
});
|
||||
expect(entries.find((e) => e.key === "daemon.port")).toMatchObject({ type: "integer", range: { min: 0, max: 65535 } });
|
||||
expect(entries.find((e) => e.key === "retention.stream_days")).toMatchObject({ type: "integer", range: { min: 1 } });
|
||||
expect(entries.find((e) => e.key === "retention.stream_days")!.range).not.toHaveProperty("max");
|
||||
expect(entries.find((e) => e.key === "ui.timezone")).toMatchObject({ type: "string", format: "iana-timezone" });
|
||||
expect(entries.find((e) => e.key === "retention.enabled")).toMatchObject({ type: "boolean", appliesAt: "daemon_restart" });
|
||||
// A key with no validator reports a type and nothing it cannot back up.
|
||||
const free = entries.find((e) => e.key === "host.name")!;
|
||||
expect(free).toMatchObject({ type: "string", writeRoute: true });
|
||||
expect(free).not.toHaveProperty("choices");
|
||||
expect(free).not.toHaveProperty("range");
|
||||
expect(free).not.toHaveProperty("format");
|
||||
});
|
||||
|
||||
it("matches the daemon's own validator for every key it can be written under", () => {
|
||||
const entries = browse().entries.filter((e) => e.group === "general" && isSettingsValidKey(e.key));
|
||||
expect(entries.map((e) => e.key)).toEqual([...SETTINGS_VALID_KEYS]);
|
||||
for (const e of entries) {
|
||||
expect(e.writeRoute, e.key).toBe(true);
|
||||
expect(e.type, e.key).toBeDefined();
|
||||
if (e.type === "boolean") {
|
||||
expect(accepts(e.key, "true"), e.key).toBe(true);
|
||||
expect(accepts(e.key, "false"), e.key).toBe(true);
|
||||
expect(accepts(e.key, "not-a-boolean"), e.key).toBe(false);
|
||||
}
|
||||
if (e.type === "integer" && !PAIRED.has(e.key)) {
|
||||
expect(accepts(e.key, "not-a-number"), e.key).toBe(false);
|
||||
if (e.range?.min !== undefined) {
|
||||
expect(accepts(e.key, String(e.range.min)), `${e.key} min`).toBe(true);
|
||||
expect(accepts(e.key, String(e.range.min - 1)), `${e.key} below min`).toBe(false);
|
||||
}
|
||||
if (e.range?.max !== undefined) {
|
||||
expect(accepts(e.key, String(e.range.max)), `${e.key} max`).toBe(true);
|
||||
expect(accepts(e.key, String(e.range.max + 1)), `${e.key} above max`).toBe(false);
|
||||
}
|
||||
// Where a side is left out, the validator must accept a value far on that side.
|
||||
if (e.range?.max === undefined) expect(accepts(e.key, "1000000000"), `${e.key} no max`).toBe(true);
|
||||
if (e.range?.min === undefined) expect(accepts(e.key, "0") || accepts(e.key, "1"), `${e.key} no min`).toBe(true);
|
||||
}
|
||||
if (e.choices) {
|
||||
expect(e.type, e.key).toBe("enum");
|
||||
for (const choice of e.choices) expect(accepts(e.key, choice), `${e.key}=${choice}`).toBe(true);
|
||||
expect(accepts(e.key, "not-one-of-the-choices"), e.key).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("states a range only where the validator is an interval, at sample points across the whole line", () => {
|
||||
const samples = [-1_000_000_000, -100_001, -1000, -61, -60, -2, -1, 0, 1, 2, 59, 60, 100, 101, 599, 600, 601,
|
||||
999, 1000, 1999, 2000, 2001, 3600, 3601, 65535, 65536, 999_999, 1_000_000, 1_000_001, 1_000_000_000];
|
||||
const ranged = browse().entries.filter((x) => x.type === "integer" && x.range && !PAIRED.has(x.key));
|
||||
expect(ranged.length).toBeGreaterThan(25);
|
||||
for (const e of ranged) {
|
||||
for (const sample of samples) {
|
||||
const inside = (e.range!.min === undefined || sample >= e.range!.min) && (e.range!.max === undefined || sample <= e.range!.max);
|
||||
expect(accepts(e.key, String(sample)), `${e.key} at ${sample}`).toBe(inside);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("reports the per-key range of the paired percentages and leaves their cross-key rule out", () => {
|
||||
const entries = browse().entries;
|
||||
for (const key of PAIRED) expect(entries.find((e) => e.key === key)).toMatchObject({ type: "integer", range: { min: 1, max: 100 } });
|
||||
// Per key the bounds are the validator's: critical takes 100, nothing takes 0 or 101 ...
|
||||
expect(accepts("health.context_pressure.critical_percent", "100")).toBe(true);
|
||||
for (const key of PAIRED) for (const bad of ["0", "101"]) expect(accepts(key, bad), `${key}=${bad}`).toBe(false);
|
||||
expect(accepts("health.context_pressure.warning_percent", "1")).toBe(true);
|
||||
// ... while the pair rule (warning below critical) still refuses a value inside that range, and is not a field here.
|
||||
expect(accepts("health.context_pressure.warning_percent", "100")).toBe(false);
|
||||
expect(entries.find((e) => e.key === "health.context_pressure.warning_percent")).not.toHaveProperty("pair");
|
||||
});
|
||||
|
||||
it("names a format that agrees with what the validator accepts and refuses", () => {
|
||||
const cases: Record<string, { format: string; ok: string[]; bad: string[] }> = {
|
||||
"ui.timezone": { format: "iana-timezone", ok: ["Europe/London", "UTC"], bad: ["Not/AZone", "+01:00"] },
|
||||
"policies.claude_compaction.preparation_root": { format: "absolute-path-or-empty", ok: ["/var/lib/example", ""], bad: ["relative/path"] },
|
||||
"decisions.template_dir": { format: "absolute-path-or-empty", ok: ["/var/lib/example", ""], bad: ["relative/path"] },
|
||||
"retention.erasure_ledger": { format: "absolute-path-or-empty", ok: ["/var/lib/example/ledger", ""], bad: ["relative/ledger"] },
|
||||
"stream.intake": { format: "queue.worker, off, or member@rig", ok: ["off", "queue.worker", "seat@rig"], bad: ["no at sign"] },
|
||||
"acp.to": { format: "member@rig-or-empty", ok: ["seat@rig", ""], bad: ["no-at-sign", "a@b@c"] },
|
||||
};
|
||||
const entries = browse().entries;
|
||||
for (const [key, c] of Object.entries(cases)) {
|
||||
expect(entries.find((e) => e.key === key)!.format, key).toBe(c.format);
|
||||
for (const ok of c.ok) expect(accepts(key, ok), `${key}=${ok}`).toBe(true);
|
||||
for (const bad of c.bad) expect(accepts(key, bad), `${key}=${bad}`).toBe(false);
|
||||
}
|
||||
// Every key that reports a format is in this table, so a new one cannot ship unchecked.
|
||||
expect(entries.filter((e) => e.format).map((e) => e.key).sort()).toEqual(Object.keys(cases).sort());
|
||||
});
|
||||
|
||||
it("reports the write route as the daemon's own key rules decide, for every entry", () => {
|
||||
const store = freshStore();
|
||||
store.set("feed.subscriptions.fixture-host.enabled", "true");
|
||||
const entries = settingsBrowser(store, null, home).entries;
|
||||
for (const e of entries) {
|
||||
expect(e.writeRoute, e.key).toBe(isSettingsValidKey(e.key) || parseFeedHostSubscriptionKey(e.key) !== null);
|
||||
}
|
||||
expect(entries.find((e) => e.key === "feed.subscriptions.fixture-host.enabled")).toMatchObject({ type: "boolean", writeRoute: true });
|
||||
expect(entries.find((e) => e.key === "slack.enabled")).toMatchObject({ writeRoute: false });
|
||||
expect(entries.find((e) => e.key === "slack.enabled")).not.toHaveProperty("type");
|
||||
expect(entries.filter((e) => e.group !== "general" && e.writeRoute)).toEqual([]);
|
||||
});
|
||||
|
||||
it("agrees with the write route itself: the keys it reports writable are accepted, the others are unknown keys", async () => {
|
||||
const store = freshStore();
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => { c.set("settingsStore" as never, store as never); await next(); });
|
||||
app.route("/api/config", configRoutes({ home }));
|
||||
const post = (key: string, value: string) => app.request(`/api/config/${key}`, {
|
||||
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ value }) });
|
||||
expect((await post("terminal.history_limit", "5000")).status).toBe(200);
|
||||
expect((await post("terminal.history_limit", "1999")).status).toBe(400);
|
||||
const refused = await post("slack.enabled", "true");
|
||||
expect(refused.status).toBe(400);
|
||||
expect((await refused.json()).error).toContain("Unknown config key");
|
||||
const browser = await (await app.request("/api/config?view=browser")).json();
|
||||
expect(browser.entries.find((e: { key: string }) => e.key === "terminal.history_limit")).toMatchObject({ writeRoute: true, range: { min: 2000, max: 1_000_000 } });
|
||||
expect(browser.entries.find((e: { key: string }) => e.key === "slack.enabled")).toMatchObject({ writeRoute: false });
|
||||
expect(browser.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("marks timing only where the application guidance says a restart applies it, and says unknown otherwise", () => {
|
||||
for (const e of browse().entries) {
|
||||
const guidanceSaysRestart = /\bdaemon (?:restart|startup)\b/i.test(e.application);
|
||||
expect(e.appliesAt, e.key).toBe(guidanceSaysRestart ? "daemon_restart" : "unknown");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -625,6 +625,19 @@ describe("SettingsStore (User Settings v0)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("a non-string provider.selected in config.json falls back to the default with a warning", () => {
|
||||
writeFileSync(configPath, JSON.stringify({ provider: { selected: ["claude"] } }));
|
||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
try {
|
||||
const store = new SettingsStore(configPath);
|
||||
expect(store.resolveOne("provider.selected")).toMatchObject({ value: "", source: "default" });
|
||||
const warns = stderrSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(warns.some((w) => w.includes("file value for provider.selected rejected"))).toBe(true);
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("a quoted in-range port in config.json keeps its port, and the CLI target and daemon bind agree", () => {
|
||||
writeFileSync(configPath, JSON.stringify({ daemon: { port: "7434" } }));
|
||||
const store = new SettingsStore(configPath);
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"agent-operated-software/SKILL.md": "ec910bf5f631d7f20872786a565a0437a446fa2d5fd8b52b685979978243a6a8",
|
||||
"agent-operated-workflows/SKILL.md": "24e54cbfa979999f523516112670618b2926ae5d42c7aa0846c94db214857ea5",
|
||||
"applying-a-permission-policy/SKILL.md": "f7bba8d50b10fc8715f40a57bf82677ca9388c6718c8a162833d9c6daf2afdca",
|
||||
"claude-compaction-restore/SKILL.md": "79a707974824e91d5888c7bc10b152989ff891491f1adecf3e93991e2af55f81",
|
||||
"claude-compaction-restore/SKILL.md": "cd8369a62647ad389a9b0cc91c6f784a0d93e2d2b874bf9570d47c2134647d1c",
|
||||
"claude-compaction-restore/reference/SKILL-v1.md": "6af2a889c9c38227fb186897c6e29185f81b215822070b469a6ceeb47734e133",
|
||||
"claude-compaction-restore/scripts/precompact-hook.mjs": "712e97c8beb02eb6c2815884a960778a225d8f6238dfe04a9b0547c34eaa131d",
|
||||
"claude-compaction-restore/scripts/restore-from-jsonl.mjs": "5584cff4ab36cd843e14c81bd201e4a2f8e498e995abacc4f81ec6c1fc556b10",
|
||||
|
||||
Reference in new issue
Block a user