metergraph-cli 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +60 -11
  2. package/assets/skill/SKILL.md +52 -3
  3. package/assets/skill/manifest.json +3 -3
  4. package/assets/skills/manifest.json +13 -13
  5. package/assets/skills/metergraph-analyze/SKILL.md +62 -4
  6. package/assets/skills/metergraph-investigate/SKILL.md +222 -35
  7. package/assets/skills/metergraph-onboarding/SKILL.md +7 -0
  8. package/assets/skills/metergraph-report/SKILL.md +19 -2
  9. package/bin/metergraph.js +1 -1
  10. package/package.json +1 -1
  11. package/src/agent-key.js +160 -0
  12. package/src/approval-handoff.js +5 -0
  13. package/src/args.js +572 -17
  14. package/src/auth-browser.js +5 -19
  15. package/src/auth-callback.js +24 -15
  16. package/src/auth-login.js +55 -12
  17. package/src/auth-oauth.js +77 -7
  18. package/src/callback-page.js +58 -0
  19. package/src/cli.js +190 -45
  20. package/src/constants.js +73 -3
  21. package/src/environment.js +169 -0
  22. package/src/experiments-contract.js +462 -0
  23. package/src/experiments-output.js +189 -0
  24. package/src/experiments.js +397 -0
  25. package/src/origin.js +6 -0
  26. package/src/output.js +437 -131
  27. package/src/pricing-args.js +163 -0
  28. package/src/pricing-output.js +206 -0
  29. package/src/pricing-reference.js +195 -0
  30. package/src/pricing.js +307 -0
  31. package/src/read-contract.js +976 -16
  32. package/src/read-output.js +449 -30
  33. package/src/read.js +312 -60
  34. package/src/setup-deployment.js +11 -3
  35. package/src/setup-interactive.js +341 -0
  36. package/src/setup-refusal.js +172 -0
  37. package/src/setup-state.js +4 -2
  38. package/src/setup.js +227 -53
  39. package/src/skill-bundle.js +2 -2
  40. package/src/style.js +101 -0
  41. package/src/transport.js +13 -7
  42. package/src/usage-aggregates.js +197 -0
  43. package/src/verify-output.js +21 -11
@@ -0,0 +1,341 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { createInterface } from "node:readline/promises";
6
+
7
+ import { resolveProject } from "./auth-binding.js";
8
+ import { runDoctor } from "./doctor.js";
9
+ import { classifyEnvironment, handoffPages, shellArg } from "./environment.js";
10
+ import { prerequisitesFor } from "./setup-deployment.js";
11
+ import { readSetupState } from "./setup-state.js";
12
+
13
+ // Setup asks for what it is missing only when a person is at a terminal: no
14
+ // --json, stdin and stdout both terminals, and not CI. An agent or a script
15
+ // never gets a question, so nothing waits on input that will never come; it
16
+ // keeps the flag errors and the next_action contract. The answers become the
17
+ // same flags a script would pass, and the full command is printed so it can
18
+ // be rerun without questions.
19
+
20
+ // The setup parse failures a question can answer. Any other failure, such as
21
+ // an invalid value or two conflicting flags, is reported as it is.
22
+ export const ASKABLE = new Set([
23
+ "missing_runtime", "non_hosted_origin_required", "non_hosted_workspace_required", "client_required",
24
+ ]);
25
+
26
+ export const LOCAL_BUNDLE_ORIGIN = "http://localhost:8080";
27
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
28
+ const PROBE_TIMEOUT_MS = 1500;
29
+
30
+ const DEPLOYMENTS = [
31
+ ["managed", "Metergraph cloud (app.metergraph.dev)"],
32
+ ["customer-local", "The Metergraph bundle running on this machine"],
33
+ ["byoc", "Your company's Metergraph installation on AWS"],
34
+ ["oss", "The open source Metergraph server"],
35
+ ];
36
+ const CLIENTS = [
37
+ ["claude", "Claude Code"],
38
+ ["codex", "Codex"],
39
+ ["cursor", "Cursor"],
40
+ [null, "None (skip installing the skill)"],
41
+ ];
42
+ const URL_QUESTIONS = {
43
+ "customer-local": "Address of the bundle",
44
+ byoc: "HTTPS address of the installation",
45
+ oss: "Address of the server",
46
+ };
47
+ const WORKSPACE_QUESTIONS = {
48
+ "customer-local": "Workspace ID (./bin/status in the bundle directory prints it)",
49
+ byoc: "Workspace ID (shown on the installation's Settings page)",
50
+ oss: "Workspace ID",
51
+ };
52
+
53
+ export function canAsk(argv, { stdin, stdout, env = process.env }) {
54
+ return !argv.includes("--json") && Boolean(stdin?.isTTY) && Boolean(stdout?.isTTY) && !env.CI;
55
+ }
56
+
57
+ // Questions on the terminal. ask resolves with the trimmed answer, or null
58
+ // once the person presses Ctrl-C or Ctrl-D, after which every ask is null.
59
+ export function terminalPrompter(stdin, stdout) {
60
+ const lines = createInterface({ input: stdin, output: stdout, terminal: true });
61
+ const stop = new AbortController();
62
+ let closed = false;
63
+ lines.on("SIGINT", () => stop.abort());
64
+ lines.on("close", () => {
65
+ closed = true;
66
+ stop.abort();
67
+ });
68
+ return {
69
+ async ask(question) {
70
+ if (stop.signal.aborted) return null;
71
+ try {
72
+ return (await lines.question(question, { signal: stop.signal })).trim();
73
+ } catch {
74
+ return null;
75
+ }
76
+ },
77
+ close() {
78
+ if (!closed) lines.close();
79
+ },
80
+ };
81
+ }
82
+
83
+ // Resolves with argv plus the flags the answers stand for, or null when the
84
+ // person cancelled. Flags already given are never asked for or changed.
85
+ export async function fillSetupArgv(argv, {
86
+ ask, say, probe = probeLocalBundle, savedProfile = savedSetupProfile, remote = classifyEnvironment().remote,
87
+ }) {
88
+ const has = (flag) => argv.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
89
+ const added = [];
90
+ // A person at a terminal runs setup for a coding agent on this machine.
91
+ // Over SSH or in a cloud workspace, setup itself still hands off.
92
+ if (!has("--runtime")) added.push("--runtime", "local");
93
+
94
+ let deployment = valueOf(argv, "--deployment");
95
+ // A rerun in a project set up before resumes that route without asking.
96
+ if (deployment === undefined && savedProfile(valueOf(argv, "--project")) === null) {
97
+ // A remote shell sends no request before setup hands it off to the
98
+ // person's own machine, and its localhost is not that machine anyway.
99
+ const found = remote ? false : await probe(LOCAL_BUNDLE_ORIGIN);
100
+ if (found) say(`Found a Metergraph bundle at ${LOCAL_BUNDLE_ORIGIN}.`);
101
+ deployment = await choose(ask, say, "Where is Metergraph running?", DEPLOYMENTS,
102
+ found ? "customer-local" : "managed");
103
+ if (deployment === undefined) return null;
104
+ if (deployment !== "managed") added.push("--deployment", deployment);
105
+ }
106
+
107
+ if (deployment !== undefined && deployment !== "managed") {
108
+ if (!has("--url")) {
109
+ const url = await askFor(ask, say, URL_QUESTIONS[deployment], {
110
+ fallback: deployment === "customer-local" ? LOCAL_BUNDLE_ORIGIN : null,
111
+ valid: (value) => /^https?:\/\/\S+$/.test(value),
112
+ retry: "Enter an address that starts with http:// or https://.",
113
+ });
114
+ if (url === null) return null;
115
+ added.push("--url", url);
116
+ }
117
+ if (!has("--workspace")) {
118
+ const workspace = await askFor(ask, say, WORKSPACE_QUESTIONS[deployment], {
119
+ valid: (value) => UUID.test(value),
120
+ retry: "A workspace ID looks like 11111111-1111-4111-8111-111111111111.",
121
+ });
122
+ if (workspace === null) return null;
123
+ added.push("--workspace", workspace.toLowerCase());
124
+ }
125
+ if (deployment === "oss" && !has("--agent-token-file")) {
126
+ const file = await askFor(ask, say, "Absolute path to a private file holding one MG_AGENT_TOKENS value", {
127
+ valid: (value) => path.isAbsolute(value),
128
+ retry: "Enter an absolute path, for example /home/me/.config/metergraph/oss-agent-token.",
129
+ });
130
+ if (file === null) return null;
131
+ added.push("--agent-token-file", file);
132
+ }
133
+ if (!has("--confirm-prerequisites")) {
134
+ say("", "Setup connects to an installation that is already running. It needs these done first:");
135
+ // Except on the open source server, setup signs in for its own
136
+ // Metadata access, so that one is not the person's to confirm.
137
+ for (const { name, guide } of prerequisitesFor(deployment)) {
138
+ if (name !== "metadata_agent_credential" || deployment === "oss") say(` - ${guide}`);
139
+ }
140
+ const done = await yesNo(ask, "Are they all done? [y/N] ");
141
+ if (done === null) return null;
142
+ // Without it, setup reports the first pending prerequisite.
143
+ if (done) added.push("--confirm-prerequisites");
144
+ }
145
+ }
146
+
147
+ if (!has("--client") && !has("--skip-skill")) {
148
+ const client = await choose(ask, say, "Install the Metergraph skill for which coding agent?", CLIENTS, "claude");
149
+ if (client === undefined) return null;
150
+ if (client === null) added.push("--skip-skill");
151
+ else added.push("--client", client);
152
+ }
153
+ return [...argv, ...added];
154
+ }
155
+
156
+ export function commandLine(argv) {
157
+ return ["metergraph", ...argv.map(shellArg)].join(" ");
158
+ }
159
+
160
+ function valueOf(argv, flag) {
161
+ for (let index = 0; index < argv.length; index += 1) {
162
+ if (argv[index] === flag) return argv[index + 1];
163
+ if (argv[index].startsWith(`${flag}=`)) return argv[index].slice(flag.length + 1);
164
+ }
165
+ return undefined;
166
+ }
167
+
168
+ // A numbered menu. Resolves with the chosen value, or undefined on cancel
169
+ // (null is a valid choice: no client).
170
+ async function choose(ask, say, question, options, fallback) {
171
+ say("", question);
172
+ options.forEach(([, label], index) => say(` ${index + 1}) ${label}`));
173
+ const fallbackIndex = options.findIndex(([value]) => value === fallback) + 1;
174
+ for (;;) {
175
+ const answer = await ask(`Choose 1-${options.length} [${fallbackIndex}]: `);
176
+ if (answer === null) return undefined;
177
+ const index = answer === "" ? fallbackIndex : Number(answer);
178
+ if (Number.isInteger(index) && index >= 1 && index <= options.length) return options[index - 1][0];
179
+ say(`Enter a number from 1 to ${options.length}.`);
180
+ }
181
+ }
182
+
183
+ async function askFor(ask, say, question, { fallback = null, valid, retry }) {
184
+ for (;;) {
185
+ const answer = await ask(fallback === null ? `${question}: ` : `${question} [${fallback}]: `);
186
+ if (answer === null) return null;
187
+ const value = answer === "" ? fallback : answer;
188
+ if (value !== null && valid(value)) return value;
189
+ say(retry);
190
+ }
191
+ }
192
+
193
+ async function yesNo(ask, question) {
194
+ const answer = await ask(question);
195
+ if (answer === null) return null;
196
+ return ["y", "yes"].includes(answer.toLowerCase());
197
+ }
198
+
199
+ async function probeLocalBundle(origin) {
200
+ try {
201
+ const { report } = await runDoctor({ origin, timeoutMs: PROBE_TIMEOUT_MS });
202
+ return report?.deployment_profile === "local";
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+
208
+ function savedSetupProfile(project) {
209
+ try {
210
+ return readSetupState(resolveProject(project))?.value.deployment_profile ?? null;
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
216
+ // --- Access for the coding agent on the customer-local bundle ---------------
217
+
218
+ // Setup signs the agent in with Metadata access only. On the customer-local
219
+ // bundle, bin/agent-key issues a broader key without a sign in and writes it
220
+ // to a private file, never printing it. The command text is fixed here, not
221
+ // taken from the service, so an origin cannot choose what a person is asked
222
+ // to run.
223
+ export const AGENT_KEY_FILE = "~/.config/metergraph/agent.env";
224
+ const AGENT_KEY_GRANTS = [
225
+ "--role", "debug", "--allow-evaluation-writes", "--allow-evaluation-previews", "--allow-analysis-starts",
226
+ ];
227
+ export const AGENT_KEY_COMMAND = ["./bin/agent-key", "create", "--write-to", AGENT_KEY_FILE, ...AGENT_KEY_GRANTS].join(" ");
228
+
229
+ export function agentAccessOffer(origin) {
230
+ return {
231
+ role: "metadata",
232
+ offer: {
233
+ kind: "bundle_agent_key",
234
+ run_in: "bundle_directory",
235
+ command: AGENT_KEY_COMMAND,
236
+ requires_person_approval: true,
237
+ keys_page_url: handoffPages(origin).keys_url,
238
+ message: "The agent has Metadata access. To let it write evaluations and start analyses, " +
239
+ "ask the person which access they want, then they run this command in the bundle directory, " +
240
+ "or the agent runs it with --yes after they agree. The Debug role reads captured prompts and " +
241
+ "responses, and analysis starts send captured content to the person's model provider and spend " +
242
+ "money. The command writes the key to the named private file and never prints it. A bundle " +
243
+ "without bin/agent-key uses the Keys page instead.",
244
+ },
245
+ };
246
+ }
247
+
248
+ // After a customer-local setup at a terminal: offer to run bin/agent-key
249
+ // now. It shows what it grants and asks for its own confirmation. Setup runs
250
+ // in the person's project, which may be a repository they did not write, so
251
+ // nothing there is ever a default: the person types the bundle directory, it
252
+ // must look like a started bundle, and setup names the exact program and asks
253
+ // before running it.
254
+ const BUNDLE_MARKERS = [["bin", "start"], ["docker-compose.yml"], ["metergraph-byoc-release.json"]];
255
+
256
+ export async function offerAgentAccess({ client, origin, prompter, say, run = runInherited, platform = process.platform }) {
257
+ say("", "Your coding agent has Metadata access. It can also write evaluations and start analyses.",
258
+ "That reads captured prompts and responses, and an analysis sends captured content to your model",
259
+ "provider and spends money on your account.");
260
+ if (platform === "win32") {
261
+ // The bundle's commands are shell scripts that run inside WSL, and
262
+ // Windows has no owner-only file mode to check, so say what to run there.
263
+ say(`To allow it, run this in the bundle directory inside WSL: ${AGENT_KEY_COMMAND}`);
264
+ return;
265
+ }
266
+ const wanted = await yesNo(prompter.ask, "Give it that access now? [y/N] ");
267
+ if (!wanted) {
268
+ say(`Later, run this in the bundle directory: ${AGENT_KEY_COMMAND}`);
269
+ return;
270
+ }
271
+ const answer = await askFor(prompter.ask, say, "Bundle directory (the one you ran bin/start in)", {
272
+ valid: (value) => BUNDLE_MARKERS.every((parts) => fs.existsSync(path.join(expandHome(value), ...parts))),
273
+ retry: "That is not a started bundle (it needs bin/start, docker-compose.yml and the release manifest).",
274
+ });
275
+ if (answer === null) return;
276
+ const bundle = fs.realpathSync(expandHome(answer));
277
+ const command = path.join(bundle, "bin", "agent-key");
278
+ if (!fs.existsSync(command)) {
279
+ say("This bundle has no bin/agent-key. Create the key on the Keys page instead:",
280
+ ` ${handoffPages(origin).keys_url}`);
281
+ return;
282
+ }
283
+ const problem = untrustedProgram(command);
284
+ if (problem !== null) {
285
+ say(`Not running ${command}: ${problem}.`);
286
+ return;
287
+ }
288
+ const confirmed = await yesNo(prompter.ask, `Run ${command}? [y/N] `);
289
+ if (!confirmed) {
290
+ say(`Later, run this in the bundle directory: ${AGENT_KEY_COMMAND}`);
291
+ return;
292
+ }
293
+ prompter.close();
294
+ const file = path.join(os.homedir(), ".config", "metergraph", "agent.env");
295
+ const code = await run(command, ["create", "--write-to", file, ...AGENT_KEY_GRANTS], bundle);
296
+ if (code !== 0) {
297
+ say(`bin/agent-key did not finish. Run it again from ${bundle}: ${AGENT_KEY_COMMAND}`);
298
+ return;
299
+ }
300
+ say("", ...connectSteps(client, origin, file));
301
+ }
302
+
303
+ // Only a regular file the person owns and nobody else can change is run.
304
+ function untrustedProgram(command) {
305
+ const info = fs.lstatSync(command);
306
+ if (!info.isFile()) return "it is not a regular file";
307
+ if (typeof process.getuid === "function" && info.uid !== process.getuid()) return "it is owned by another user";
308
+ if (info.mode & 0o022) return "other users can change it";
309
+ return null;
310
+ }
311
+
312
+ // How the person connects their agent to the key: load the file where the
313
+ // agent starts, and name the variable (never the token) in its MCP settings.
314
+ export function connectSteps(client, origin, file) {
315
+ const mcp = `${origin}/v1/agent/mcp`;
316
+ const steps = [`Load the key in the shell that starts your agent: . ${shellArg(file)}`];
317
+ if (client === "claude") {
318
+ steps.push("Add the MCP server to Claude Code (it saves the variable name, not the token):",
319
+ ` claude mcp add-json --scope user metergraph '${JSON.stringify({
320
+ type: "http", url: mcp, headers: { Authorization: "Bearer ${METERGRAPH_AGENT_TOKEN}" } })}'`);
321
+ } else if (client === "codex") {
322
+ steps.push("Add the MCP server to Codex (it saves the variable name, not the token):",
323
+ ` codex mcp add metergraph --url ${mcp} --bearer-token-env-var METERGRAPH_AGENT_TOKEN`);
324
+ } else {
325
+ steps.push("Add the MCP server as the bundle README's 'Let your coding agent run analyses' section shows.");
326
+ }
327
+ steps.push("Then restart the agent.");
328
+ return steps;
329
+ }
330
+
331
+ function expandHome(value) {
332
+ return value === "~" || value.startsWith("~/") ? path.join(os.homedir(), value.slice(1)) : value;
333
+ }
334
+
335
+ function runInherited(command, args, cwd) {
336
+ return new Promise((resolve) => {
337
+ const child = spawn(command, args, { cwd, stdio: "inherit" });
338
+ child.on("error", () => resolve(1));
339
+ child.on("close", (code) => resolve(code ?? 1));
340
+ });
341
+ }
@@ -0,0 +1,172 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ // What the --json --no-browser background waiter is doing with an approval
6
+ // it received, so the agent's rerun can report it instead of asking the
7
+ // person for another approval. The waiter is the only writer: a foreground
8
+ // run reports a refusal directly and never records one.
9
+ //
10
+ // redeeming the waiter holds an approval and is redeeming it (possibly
11
+ // waiting out a short Retry-After). A rerun waits for it.
12
+ // refused the service refused the redemption: the rate budget until
13
+ // retry_at, or the workspace's key cap. A rerun reports it.
14
+ //
15
+ // The record names the setup family, the waiter's process and the approval
16
+ // it came from. It holds no credential. It is a separate file, not a field of
17
+ // setup.json, so an older CLI that validates setup.json strictly keeps
18
+ // reading the project. Any read problem is treated as no record.
19
+ //
20
+ // The file lives in the project, so its content is untrusted: a record can
21
+ // only delay setup for as long as this CLI would ever write it for. A
22
+ // redeeming record expiring, or a refusal retrying, further out than that is
23
+ // ignored and removed, so a crafted or committed record cannot block setup.
24
+ const FILE = "setup-refusal.json";
25
+ const MAX_BYTES = 1024;
26
+ const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
27
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
28
+ const KEYS = "approval_id,expires_at,family_id,pid,reason,retry_at,schema_version,state";
29
+ export const REFUSAL_REASONS = Object.freeze(["setup_rate_limited", "setup_key_limit"]);
30
+ // A redeeming record outlives no waiter: redemption plus two Retry-After
31
+ // waits takes well under this. A rate refusal is reported for at most this.
32
+ const MAX_RECORD_MS = 10 * 60 * 1000;
33
+ const MAX_RATE_WAIT_MS = 5 * 60 * 1000;
34
+
35
+ const fileFor = (root) => path.join(root, ".metergraph", FILE);
36
+ const isTime = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
37
+
38
+ function valid(value) {
39
+ if (value === null || typeof value !== "object" || Array.isArray(value) ||
40
+ Object.keys(value).sort().join() !== KEYS || value.schema_version !== 2 ||
41
+ !UUID.test(value.family_id) || !UUID.test(value.approval_id) ||
42
+ !Number.isInteger(value.pid) || value.pid < 1 || !isTime(value.expires_at)) return false;
43
+ if (value.state === "redeeming") return value.reason === null && value.retry_at === null;
44
+ if (value.state === "refused") {
45
+ return REFUSAL_REASONS.includes(value.reason) &&
46
+ (value.reason === "setup_key_limit" ? value.retry_at === null : isTime(value.retry_at));
47
+ }
48
+ return false;
49
+ }
50
+
51
+ function read(root) {
52
+ const file = fileFor(root);
53
+ let fd;
54
+ try {
55
+ const stat = fs.lstatSync(file);
56
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > MAX_BYTES) return null;
57
+ fd = fs.openSync(file, fs.constants.O_RDONLY | NOFOLLOW);
58
+ const opened = fs.fstatSync(fd);
59
+ if (opened.dev !== stat.dev || opened.ino !== stat.ino || opened.size > MAX_BYTES) return null;
60
+ const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(fs.readFileSync(fd)));
61
+ return valid(value) ? value : null;
62
+ } catch {
63
+ return null;
64
+ } finally {
65
+ if (fd !== undefined) fs.closeSync(fd);
66
+ }
67
+ }
68
+
69
+ // Best effort: a record that cannot be written only means a rerun asks for a
70
+ // new approval, as it did before records existed.
71
+ function write(root, value) {
72
+ if (!valid(value)) return false;
73
+ const dir = path.join(root, ".metergraph");
74
+ const temp = path.join(dir, `.refusal.${randomBytes(8).toString("hex")}.tmp`);
75
+ try {
76
+ if (!fs.lstatSync(dir).isDirectory()) return false;
77
+ const fd = fs.openSync(temp, "wx", 0o600);
78
+ try { fs.writeFileSync(fd, `${JSON.stringify(value)}\n`); }
79
+ finally { fs.closeSync(fd); }
80
+ fs.renameSync(temp, fileFor(root));
81
+ return true;
82
+ } catch {
83
+ try { fs.unlinkSync(temp); } catch { /* no temp */ }
84
+ return false;
85
+ }
86
+ }
87
+
88
+ export function clearSetupRefusal(root) {
89
+ try {
90
+ const stat = fs.lstatSync(fileFor(root));
91
+ if (stat.isFile() || stat.isSymbolicLink()) fs.unlinkSync(fileFor(root));
92
+ } catch { /* nothing to clear */ }
93
+ }
94
+
95
+ function alive(pid) {
96
+ try {
97
+ process.kill(pid, 0);
98
+ return true;
99
+ } catch (error) {
100
+ return error.code === "EPERM";
101
+ }
102
+ }
103
+
104
+ // The waiter's handle on one approval it received. Every method is best
105
+ // effort and only ever touches this approval's own record.
106
+ export function waiterRecord(root, familyId, now = Date.now) {
107
+ const approvalId = randomUUID();
108
+ const base = () => ({ schema_version: 2, family_id: familyId, approval_id: approvalId, pid: process.pid,
109
+ expires_at: new Date(now() + MAX_RECORD_MS).toISOString() });
110
+ return {
111
+ // Early, as soon as an approval arrives: never replaces another waiter
112
+ // that is still alive. With force, once this waiter has won the setup
113
+ // state for its redemption, it takes the record over.
114
+ redeeming: ({ force = false } = {}) => {
115
+ const current = read(root);
116
+ if (!force && current?.state === "redeeming" && current.approval_id !== approvalId &&
117
+ current.pid !== process.pid && alive(current.pid) && Date.parse(current.expires_at) > now()) return false;
118
+ return write(root, { ...base(), state: "redeeming", reason: null, retry_at: null });
119
+ },
120
+ refused: (reason, retryAfterSeconds = null) => write(root, { ...base(), state: "refused", reason,
121
+ retry_at: reason === "setup_rate_limited"
122
+ ? new Date(now() + Math.min((retryAfterSeconds ?? 60) * 1000, MAX_RATE_WAIT_MS)).toISOString()
123
+ : null }),
124
+ // Any other outcome: drop a redeeming record this waiter left behind.
125
+ settle: () => {
126
+ const record = read(root);
127
+ if (record?.approval_id === approvalId && record.state === "redeeming") clearSetupRefusal(root);
128
+ },
129
+ };
130
+ }
131
+
132
+ // What a run should report before asking for an approval, or null to
133
+ // proceed. familyId and delivered describe the saved setup state; a refusal
134
+ // for another family, or for one already delivered, no longer applies.
135
+ export function pendingRefusal(root, { familyId, delivered }, now = Date.now()) {
136
+ const record = read(root);
137
+ if (record === null) return null;
138
+ const expires = Date.parse(record.expires_at);
139
+ const retry = record.retry_at === null ? null : Date.parse(record.retry_at);
140
+ if (expires <= now || expires > now + MAX_RECORD_MS || (retry !== null && retry > now + MAX_RATE_WAIT_MS)) {
141
+ clearSetupRefusal(root);
142
+ return null;
143
+ }
144
+ // A delivered family has nothing pending, whatever the record says.
145
+ if (delivered) {
146
+ clearSetupRefusal(root);
147
+ return null;
148
+ }
149
+ if (record.state === "redeeming") {
150
+ if (record.pid === process.pid || !alive(record.pid)) {
151
+ clearSetupRefusal(root);
152
+ return null;
153
+ }
154
+ // A live waiter matters only for the saved family, or before any state
155
+ // is saved (a combined sign in writes it after the approval). A record
156
+ // for another family does not block this setup.
157
+ if (familyId !== null && record.family_id !== familyId) return null;
158
+ return { reason: "setup_in_progress", retryAfter: 5 };
159
+ }
160
+ if (record.family_id !== familyId) {
161
+ clearSetupRefusal(root);
162
+ return null;
163
+ }
164
+ if (record.reason === "setup_rate_limited") {
165
+ const remaining = Math.ceil((Date.parse(record.retry_at) - now) / 1000);
166
+ if (remaining > 0) return { reason: record.reason, retryAfter: remaining };
167
+ clearSetupRefusal(root);
168
+ return null;
169
+ }
170
+ clearSetupRefusal(root);
171
+ return { reason: record.reason, retryAfter: null };
172
+ }
@@ -101,10 +101,12 @@ export function readSetupState(root) {
101
101
  }
102
102
  }
103
103
 
104
- export function newSetupState(origin, workspaceId, profile, client) {
104
+ // familyId is given when the family was already named in an approval, as a
105
+ // combined sign-in and ingest approval does; otherwise a new one is chosen.
106
+ export function newSetupState(origin, workspaceId, profile, client, familyId = randomUUID()) {
105
107
  return withSetupState({ schema_version: 1, origin, workspace_id: workspaceId,
106
108
  deployment_profile: profile, selected_client: client, skill_status: client === null ? "skipped" : "pending",
107
- family_id: randomUUID(), key_id: null, phase: "unsubmitted" });
109
+ family_id: familyId, key_id: null, phase: "unsubmitted" });
108
110
  }
109
111
 
110
112
  // Compare-and-swap under a project-local exclusive lock. A concurrent setup