@opsee/cli 0.11.12 → 0.11.18

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.
@@ -10,9 +10,9 @@
10
10
  * drains the turn; run.ts records the outcome, since the Run Record write is the commit (ADR-0009).
11
11
  */
12
12
  import type { Account } from "../account.js";
13
+ import { driveShortTurn, type DrainedTurn } from "./short-turn.js";
13
14
  import type { TrackerTask } from "../tracker-adapter.js";
14
- import type { AdapterEvent, WorkerAdapter } from "../worker-adapter.js";
15
- import { tail } from "./install.js";
15
+ import type { WorkerAdapter } from "../worker-adapter.js";
16
16
  import type { WorkContract } from "./work-contract.js";
17
17
 
18
18
  /** Reading a few files and writing one section needs a handful of agentic turns, not a Worker's
@@ -81,58 +81,21 @@ export interface TriageTurnRequest {
81
81
  now: () => number;
82
82
  }
83
83
 
84
- export interface DrainedTurn {
85
- terminal: Extract<AdapterEvent, { type: "completed" | "failed" }>;
86
- durationMs: number;
87
- outputTail: string;
88
- /** The vendor's rate-limit report, when one came (story 30). A Triage turn is a turn on the
89
- * Account like any other, so a limit it hit Pauses the Account; the terminal event alone does not
90
- * carry the reset the vendor named, which is what decides how long. */
91
- rateLimit?: { resetAt?: string; message: string };
92
- }
84
+ export type { DrainedTurn };
93
85
 
94
- /** Runs one Triage turn to its terminal event, logging progress the way the dispatch loop does. */
86
+ /** Runs one Triage turn to its terminal event. */
95
87
  export async function driveTriageTurn(req: TriageTurnRequest): Promise<DrainedTurn> {
96
88
  const maxTurns = Math.min(TRIAGE_MAX_TURNS, req.maxTurns ?? TRIAGE_MAX_TURNS);
97
- const started = req.now();
98
- const handle = req.worker.launch({
89
+ req.log(`triage: ${req.account.vendor} turn on ${req.task.identifier} in ${req.workspace.path} (at most ${maxTurns} turns)`);
90
+ return driveShortTurn({
91
+ worker: req.worker,
99
92
  account: req.account,
100
93
  cwd: req.workspace.path,
101
94
  prompt: triagePrompt(req.task, req.contract, req.workspace),
95
+ label: "triage",
102
96
  maxTurns,
103
97
  stallTimeoutMs: req.stallTimeoutMs,
98
+ log: req.log,
99
+ now: req.now,
104
100
  });
105
- req.log(`triage: ${req.account.vendor} turn on ${req.task.identifier} in ${req.workspace.path} (at most ${maxTurns} turns)`);
106
- const output: string[] = [];
107
- let terminal: DrainedTurn["terminal"] | undefined;
108
- let rateLimit: DrainedTurn["rateLimit"];
109
- for await (const event of handle.events) {
110
- switch (event.type) {
111
- case "started":
112
- req.log(`triage: session ${event.sessionId}`);
113
- break;
114
- case "output":
115
- for (const line of event.text.split("\n")) {
116
- output.push(line);
117
- req.log(` | ${line}`);
118
- }
119
- break;
120
- case "tool":
121
- req.log(` tool ${event.name}`);
122
- break;
123
- case "rate_limited":
124
- rateLimit = { resetAt: event.resetAt, message: event.message };
125
- req.log(`triage: rate limited: ${event.message}${event.resetAt ? ` (resets ${event.resetAt})` : ""}`);
126
- break;
127
- case "stalled":
128
- req.log(`triage: stalled, no output for ${event.silentMs}ms`);
129
- break;
130
- case "completed":
131
- case "failed":
132
- terminal = event;
133
- break;
134
- }
135
- }
136
- if (!terminal) throw new Error("Worker Adapter contract violated: the event stream ended without a terminal event");
137
- return { terminal, durationMs: req.now() - started, outputTail: tail(output.join("\n")), rateLimit };
138
101
  }
@@ -46,7 +46,8 @@ import { RunEventInputSchema, type RunEvent, type RunEventInput, type RunVerific
46
46
  import { parseOpseeJson, readYamlBlock, type OpseeConfigFiles } from "../../opsee-config.js";
47
47
  import type { Account } from "../account.js";
48
48
  import { foremanLocalDir } from "../local-dir.js";
49
- import { RECIPE_KEY, recipeFromConfig, startApp, waitForReady, type RunRecipe, type VerifyMode } from "../run-recipe.js";
49
+ import { RECIPE_KEY, recipeFromConfig, startable, startApp, waitForReady, type RunRecipe, type StartableRecipe, type VerifyMode } from "../run-recipe.js";
50
+ import { discoverStart, type StartAnswer } from "./start-discovery.js";
50
51
  import type { TrackerTask } from "../tracker-adapter.js";
51
52
  import { parseVerdict, VERDICT_CONTRACT, type Defect, type Verdict } from "../verdict.js";
52
53
  import type { AdapterEvent, McpServerSpec, WorkerAdapter } from "../worker-adapter.js";
@@ -423,6 +424,10 @@ export interface VerifierOptions {
423
424
  * default). */
424
425
  mcpFor?: (outputDir: string, allowedOrigins: readonly string[]) => McpServerSpec;
425
426
  ports?: PortLease;
427
+ /** How the Foreman works out a start command the Run Recipe does not carry (core/
428
+ * start-discovery.ts). A Start turn by default; a test hands in an answer instead. Set it to a
429
+ * function that answers with a reason to turn discovery off. */
430
+ discoverStart?: (input: { task: Pick<TrackerTask, "identifier" | "title">; verification: string; workspace: { path: string }; portEnv: string }) => Promise<StartAnswer>;
426
431
  log?: (line: string) => void;
427
432
  now?: () => number;
428
433
  }
@@ -461,15 +466,57 @@ export function verifierWith(options: VerifierOptions): VerifierFn {
461
466
  log(`verify: ${task.identifier} skipped: ${decision.reason}; the Gates are the whole verification`);
462
467
  return { kind: "skipped", reason: decision.reason };
463
468
  }
464
- log(`verify: ${task.identifier} browser verification: ${decision.reason} (Run Recipe read from ${source})`);
469
+ // The round is wanted; whether it can happen is a second question. A block with Gates and no
470
+ // start is the ordinary shape of a repository that never asked for a browser -- `opsee init`
471
+ // stopped asking, because at setup time nobody knows which of a repository's programs a Task
472
+ // will exercise. Now there is a Task, a Verification section and a Workspace, so the Foreman
473
+ // asks a Start turn rather than giving up.
474
+ let servable = startable(recipe);
475
+ let discovered: string | undefined;
476
+ if (!servable) {
477
+ const ask =
478
+ options.discoverStart ??
479
+ ((input) =>
480
+ discoverStart({
481
+ worker: options.worker,
482
+ account: options.account,
483
+ task: input.task,
484
+ verification: input.verification,
485
+ workspace: input.workspace,
486
+ portEnv: input.portEnv,
487
+ maxTurns: options.maxTurns,
488
+ stallTimeoutMs: options.stallTimeoutMs,
489
+ log,
490
+ now,
491
+ }));
492
+ const answer = await ask({ task, verification: input.verification, workspace, portEnv: recipe.portEnv });
493
+ if (answer.recipe) {
494
+ servable = { ...recipe, ...answer.recipe } satisfies StartableRecipe;
495
+ discovered = answer.recipe.start;
496
+ } else {
497
+ log(`verify: ${task.identifier} could not work out how to serve the app: ${answer.reason}`);
498
+ }
499
+ }
500
+ if (!servable) {
501
+ const missing = `the Run Recipe has no start command, so the app cannot be served (read from ${source})`;
502
+ if (recipe.verify === "browser") {
503
+ log(`verify: ${task.identifier} the Run Recipe says verify: browser but ${missing}; the round failed`);
504
+ return { kind: "failed", reason: `the Run Recipe says verify: browser but ${missing}`, durationMs: 0 };
505
+ }
506
+ log(`verify: ${task.identifier} skipped: ${missing}; the Gates are the whole verification`);
507
+ return { kind: "skipped", reason: missing };
508
+ }
509
+ log(
510
+ `verify: ${task.identifier} browser verification: ${decision.reason} (Run Recipe read from ${source}${discovered ? ", start command from a Start turn" : ""})`,
511
+ );
465
512
 
466
513
  const started = now();
467
514
  const port = await ports.acquire(task.id);
468
515
  const output: string[] = [];
469
- const app = startApp(recipe, { port, cwd: workspace.path, env: gateEnv(options.account), onOutput: (line) => output.push(line) });
516
+ const app = startApp(servable, { port, cwd: workspace.path, env: gateEnv(options.account), onOutput: (line) => output.push(line) });
470
517
  input.onPid?.("app", app.pid);
471
518
  const appAt = now();
472
- log(`verify: ${task.identifier} starting the app on port ${port}: ${app.command} (${recipe.portEnv}=${port}${app.pid ? `, pid ${app.pid}` : ""})`);
519
+ log(`verify: ${task.identifier} starting the app on port ${port}: ${app.command} (${servable.portEnv}=${port}${app.pid ? `, pid ${app.pid}` : ""})`);
473
520
  const appRun = (ready: boolean, error?: string): AppRun => ({
474
521
  command: app.command,
475
522
  cwd: workspace.path,
@@ -0,0 +1,247 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { execFile } from "node:child_process";
4
+ import { homedir, userInfo } from "node:os";
5
+ import { join } from "node:path";
6
+ import { VENDOR_CREDENTIAL_FILE, type Vendor } from "./vendor.js";
7
+
8
+ /**
9
+ * Where a vendor keeps the credential for one config directory, per platform.
10
+ *
11
+ * This exists because of a wrong assumption that reached a release: that a subscription's
12
+ * credential is a file inside its config directory. **On macOS Claude Code writes no such file.**
13
+ * It derives a Keychain item per `CLAUDE_CONFIG_DIR` and stores the credential there, so a file
14
+ * check finds nothing however well the login went — which is exactly what refused three Accounts
15
+ * that had been logged into correctly.
16
+ *
17
+ * Two callers, wanting different things, and the difference is deliberate:
18
+ *
19
+ * - `account add --login` wants to know whether a login *happened* (`credentialPresence`). It never
20
+ * needs the secret, and never asks for one.
21
+ * - The usage poller wants the token itself (`readCredentialText`), which ADR-0013's amendment
22
+ * permits for the single purpose of asking that vendor about the user's own quota.
23
+ *
24
+ * Nothing else in the package may call `readCredentialText`.
25
+ */
26
+
27
+ /** The service name Claude Code uses for the default profile — no `CLAUDE_CONFIG_DIR` set. */
28
+ export const CLAUDE_DEFAULT_KEYCHAIN_SERVICE = "Claude Code-credentials";
29
+
30
+ /** The directory Claude Code uses when `CLAUDE_CONFIG_DIR` is not set. */
31
+ export const CLAUDE_DEFAULT_CONFIG_DIR = ".claude";
32
+
33
+ /**
34
+ * Apple's own binary, by absolute path.
35
+ *
36
+ * Never resolved through `PATH`: this reads a credential, so a `security` planted earlier on the
37
+ * path would be handed one. `/usr/bin/security` is present on every macOS.
38
+ */
39
+ const SECURITY = "/usr/bin/security";
40
+
41
+ /** A wedged Keychain — a locked login keychain on a headless or SSH host, prompting for an unlock
42
+ * nobody will give — must not hang a command. A healthy one answers in well under 100ms. */
43
+ const SECURITY_TIMEOUT_MS = 5_000;
44
+
45
+ /** `errSecItemNotFound`, as `security` surfaces it: the one non-zero status that means "no such
46
+ * item" rather than "I could not tell you". */
47
+ const NOT_FOUND_STATUS = 44;
48
+
49
+ /** What a credential lookup found. `unknown` is the important one: it means the question could not
50
+ * be answered, and **no caller may refuse anything on it**. */
51
+ export type CredentialPresence = "present" | "absent" | "unknown";
52
+
53
+ /** What `security` reported: its exit status, whatever it printed, and whether it ran at all. */
54
+ export interface SecurityResult {
55
+ /** Null when the command could not be run or did not finish. */
56
+ status: number | null;
57
+ stdout: string;
58
+ /** Set when the command was killed at `SECURITY_TIMEOUT_MS`, which a caller reports differently
59
+ * from a binary that is missing: one is a Keychain nobody can reach, the other is not macOS. */
60
+ timedOut?: boolean;
61
+ }
62
+
63
+ export interface CredentialDeps {
64
+ platform: NodeJS.Platform;
65
+ env: Readonly<Record<string, string | undefined>>;
66
+ /** The user's home, for recognising the default profile. Injected so the rule is testable. */
67
+ home: string;
68
+ /** The OS account name, used when the environment carries none (`osUsername`). */
69
+ osUsername: () => string;
70
+ fileExists: (path: string) => boolean;
71
+ readFile: (path: string) => string;
72
+ security: (args: readonly string[]) => Promise<SecurityResult>;
73
+ }
74
+
75
+ /**
76
+ * The real seams. Deliberately **not** a default parameter: `ConfigDirFs` and `HostProbeDeps` are
77
+ * both required arguments in this package, and a default is how a call site silently keeps the
78
+ * production implementation in a test — which is how the file-only assumption survived review.
79
+ */
80
+ export const realCredentialDeps: CredentialDeps = {
81
+ platform: process.platform,
82
+ env: process.env,
83
+ home: homedir(),
84
+ osUsername: () => userInfo().username,
85
+ fileExists: (path) => existsSync(path),
86
+ readFile: (path) => readFileSync(path, "utf-8"),
87
+ security: (args) =>
88
+ new Promise((resolve) => {
89
+ // `execFile`, not `spawnSync`: this runs inside the daemon, which is streaming live Workers.
90
+ // A wedged Keychain would otherwise block the event loop for the whole timeout, per Account
91
+ // — trading the thing that matters for the thing that helps, which the poll tick's own
92
+ // comment warns against.
93
+ execFile(SECURITY, [...args], { timeout: SECURITY_TIMEOUT_MS, encoding: "utf-8" }, (error, stdout) => {
94
+ const killed = (error as { killed?: boolean } | null)?.killed === true;
95
+ const status = (error as { code?: number } | null)?.code;
96
+ resolve({
97
+ status: error ? (typeof status === "number" ? status : null) : 0,
98
+ stdout: stdout ?? "",
99
+ ...(killed ? { timedOut: true } : {}),
100
+ });
101
+ });
102
+ }),
103
+ };
104
+
105
+ /**
106
+ * The Keychain service name Claude Code derives for a config directory.
107
+ *
108
+ * The vendor's own scheme: the first 8 hex characters of the SHA-256 of the **exact string exported
109
+ * as `CLAUDE_CONFIG_DIR`**, NFC-normalised, after a fixed prefix. Hash what is exported and nothing
110
+ * else — `/a/b` and `/a/b/` are different strings and so are different items, and resolving or
111
+ * tidying the path here would look up an item the vendor never wrote.
112
+ *
113
+ * The Foreman always exports the absolute path it stored at registration (`validateConfigDir`
114
+ * resolves it once, through the same `expandHome` the login path uses), so the string hashed here
115
+ * is the string the login used.
116
+ */
117
+ export function keychainServiceName(configDir: string): string {
118
+ const digest = createHash("sha256").update(configDir.normalize("NFC"), "utf8").digest("hex").slice(0, 8);
119
+ return `${CLAUDE_DEFAULT_KEYCHAIN_SERVICE}-${digest}`;
120
+ }
121
+
122
+ /**
123
+ * The Keychain account name, mirroring the vendor's `getUsername()`: `$USER`, then the OS account
124
+ * name, then a fixed string.
125
+ *
126
+ * The middle step is the one that matters, and it is not `$LOGNAME`. Under launchd — which is how
127
+ * `foreman service install` runs the daemon — neither `USER` nor `LOGNAME` is set, so an
128
+ * environment-only lookup would fall through to the fixed string, key a different item than the one
129
+ * the login wrote, and fail every poll on the machine. That is the original bug wearing a different
130
+ * hat, so the OS is asked rather than the environment.
131
+ */
132
+ export function keychainAccountName(deps: Pick<CredentialDeps, "env" | "osUsername">): string {
133
+ if (deps.env.USER) return deps.env.USER;
134
+ try {
135
+ const name = deps.osUsername();
136
+ if (name) return name;
137
+ } catch {
138
+ /* a container with no passwd entry; the fixed name below is the vendor's own last resort */
139
+ }
140
+ return "claude-code-user";
141
+ }
142
+
143
+ /**
144
+ * Whether a credential exists for this config directory.
145
+ *
146
+ * Three answers, and the third is the point: `unknown` means the question could not be answered —
147
+ * a locked Keychain, a denied prompt, a `security` that would not run. **A caller must not refuse
148
+ * anything on `unknown`.** Treating "I could not tell" as "there is nothing there" is precisely the
149
+ * bug this module was written to fix.
150
+ *
151
+ * Presence never asks for the value: on macOS the lookup omits `-w`, and off it the file is only
152
+ * stat-ed. Answering "is there one" by reading one would be a credential read with no permission
153
+ * behind it.
154
+ */
155
+ export async function credentialPresence(vendor: Vendor, configDir: string, deps: CredentialDeps): Promise<CredentialPresence> {
156
+ if (!usesKeychain(vendor, deps.platform)) {
157
+ return deps.fileExists(credentialPath(vendor, configDir)) ? "present" : "absent";
158
+ }
159
+ for (const service of keychainServices(configDir, deps)) {
160
+ const found = await lookUp(deps, service, { wantValue: false });
161
+ if (found.kind === "found") return "present";
162
+ if (found.kind === "unusable") return "unknown";
163
+ }
164
+ return "absent";
165
+ }
166
+
167
+ /**
168
+ * The credential as the vendor stored it, or a one-line reason.
169
+ *
170
+ * **The only credential read in this package**, and permitted by ADR-0013's amendment for one
171
+ * purpose: asking that same vendor about the user's own quota. Every failure reason is built here
172
+ * and quotes nothing that was read — a poller failure is written to the usage store, printed by
173
+ * `account usage` and logged, so a reason carrying the file or the item would leak into all three.
174
+ */
175
+ export async function readCredentialText(
176
+ vendor: Vendor,
177
+ configDir: string,
178
+ deps: CredentialDeps,
179
+ ): Promise<{ ok: true; text: string } | { ok: false; reason: string }> {
180
+ if (!usesKeychain(vendor, deps.platform)) {
181
+ try {
182
+ return { ok: true, text: deps.readFile(credentialPath(vendor, configDir)) };
183
+ } catch {
184
+ return { ok: false, reason: `no readable ${VENDOR_CREDENTIAL_FILE[vendor]} in ${configDir}` };
185
+ }
186
+ }
187
+ for (const service of keychainServices(configDir, deps)) {
188
+ const found = await lookUp(deps, service, { wantValue: true });
189
+ if (found.kind === "found") return { ok: true, text: found.value };
190
+ if (found.kind === "unusable") return { ok: false, reason: found.reason };
191
+ }
192
+ return { ok: false, reason: "no Keychain item holds a credential for this config directory; the vendor has not been logged in there" };
193
+ }
194
+
195
+ type LookUp = { kind: "found"; value: string } | { kind: "missing" } | { kind: "unusable"; reason: string };
196
+
197
+ /**
198
+ * One Keychain lookup. The only difference between asking whether an item exists and asking what is
199
+ * in it is `-w`, so both callers come through here rather than each keeping its own copy of the
200
+ * argument list and the three-way reading of the exit status.
201
+ */
202
+ async function lookUp(deps: CredentialDeps, service: string, options: { wantValue: boolean }): Promise<LookUp> {
203
+ const args = ["find-generic-password", "-a", keychainAccountName(deps), ...(options.wantValue ? ["-w"] : []), "-s", service];
204
+ const found = await deps.security(args);
205
+ // `-w` prints the value and one newline; strip exactly that.
206
+ if (found.status === 0) return { kind: "found", value: found.stdout.replace(/\n$/, "") };
207
+ if (found.status === NOT_FOUND_STATUS) return { kind: "missing" };
208
+ return {
209
+ kind: "unusable",
210
+ reason: found.timedOut
211
+ ? `the macOS Keychain did not answer within ${SECURITY_TIMEOUT_MS}ms for this Account`
212
+ : `the macOS Keychain could not be read for this Account (security exited ${found.status ?? "without running"})`,
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Claude Code keeps credentials in the Keychain on macOS and in a file everywhere else. Codex is
218
+ * file-based on every platform (`auth.json`, vendor.ts), so it never comes here.
219
+ */
220
+ function usesKeychain(vendor: Vendor, platform: NodeJS.Platform): boolean {
221
+ return vendor === "claude" && platform === "darwin";
222
+ }
223
+
224
+ /**
225
+ * The Keychain items to try, in order.
226
+ *
227
+ * Normally exactly one: the item derived from this config directory. The default profile's
228
+ * unhashed item is tried **only when the directory is the default one**, which is the single case
229
+ * where both names can legitimately describe the same login: exporting `CLAUDE_CONFIG_DIR` makes
230
+ * the vendor write a hashed item, but somebody who has only ever used the default profile may have
231
+ * just the unhashed one.
232
+ *
233
+ * Trying it for every directory — which this did at first — is a hole, not a courtesy: a directory
234
+ * with no item of its own would have found the *default profile's* credential, so `--login` would
235
+ * register an Account nobody signed into, and the poller would file one identity's quota under
236
+ * another Account. Two Accounts would report one quota and the scheduler would believe twice the
237
+ * capacity that exists.
238
+ */
239
+ function keychainServices(configDir: string, deps: CredentialDeps): string[] {
240
+ const services = [keychainServiceName(configDir)];
241
+ if (configDir === join(deps.home, CLAUDE_DEFAULT_CONFIG_DIR)) services.push(CLAUDE_DEFAULT_KEYCHAIN_SERVICE);
242
+ return services;
243
+ }
244
+
245
+ function credentialPath(vendor: Vendor, configDir: string): string {
246
+ return join(configDir, VENDOR_CREDENTIAL_FILE[vendor]);
247
+ }
@@ -32,10 +32,16 @@ export const VERIFY_MODES = ["auto", "browser", "none"] as const;
32
32
  export type VerifyMode = (typeof VERIFY_MODES)[number];
33
33
 
34
34
  export interface RunRecipe {
35
- /** Shell command that serves the app; `{port}` and the port variable are substituted. */
36
- start: string;
37
- /** URL that answers once the app is up; `{port}` and the port variable are substituted. */
38
- readinessUrl: string;
35
+ /** Shell command that serves the app; `{port}` and the port variable are substituted.
36
+ *
37
+ * Optional, because only the Verifier's browser round needs it and most repositories never ask
38
+ * for one. The Gates are the rest of the block and they run without it. `opsee init` writes it
39
+ * when the analyzer already knows a dev command and otherwise leaves it out rather than asking
40
+ * a question whose answer is "which of these six programs?" in a monorepo. */
41
+ start?: string;
42
+ /** URL that answers once the app is up; `{port}` and the port variable are substituted. Absent
43
+ * with `start`, present with it. */
44
+ readinessUrl?: string;
39
45
  /** Name of the environment variable set to the port when `start` runs. */
40
46
  portEnv: string;
41
47
  gates: RunRecipeGates;
@@ -81,10 +87,17 @@ function nonEmptyString(value: unknown): string | undefined {
81
87
  export function recipeFromConfig(raw: unknown, source: string): RunRecipe {
82
88
  if (!raw || typeof raw !== "object") throw new Error(`${source} has no foreman block; run opsee init to write the Run Recipe.`);
83
89
  const block = raw as Record<string, unknown>;
90
+ // Neither is required. A block with Gates and no start is a repository that never wanted a
91
+ // browser round, which is most of them; the Verifier says so and the Gates give the Verdict.
92
+ //
93
+ // A start command without a readiness URL is different: it is a half-written recipe. It would
94
+ // start the app and never know it was up, so the round would skip for a reason that looks like
95
+ // "you did not configure this" when the truth is "you configured half of it".
84
96
  const start = nonEmptyString(block.start);
85
97
  const readinessUrl = nonEmptyString(block.readiness_url);
86
- if (!start) throw new Error(`${source}: foreman.start is missing; it is the command that serves the app.`);
87
- if (!readinessUrl) throw new Error(`${source}: foreman.readiness_url is missing; it is the URL that answers once the app is up.`);
98
+ if (start && !readinessUrl) {
99
+ throw new Error(`${source}: foreman.start is set but foreman.readiness_url is missing; it is the URL that answers once the app is up.`);
100
+ }
88
101
  const gatesRaw = block.gates && typeof block.gates === "object" ? (block.gates as Record<string, unknown>) : {};
89
102
  const gates: RunRecipeGates = {};
90
103
  for (const name of ["test", "lint", "typecheck"] as const) {
@@ -97,7 +110,13 @@ export function recipeFromConfig(raw: unknown, source: string): RunRecipe {
97
110
  } catch (error) {
98
111
  throw new Error(`${source}: ${error instanceof Error ? error.message : String(error)}`);
99
112
  }
100
- return { start, readinessUrl, portEnv: nonEmptyString(block.port_env) ?? DEFAULT_PORT_ENV, gates, ...(block.verify === undefined ? {} : { verify }) };
113
+ return {
114
+ ...(start === undefined ? {} : { start }),
115
+ ...(readinessUrl === undefined ? {} : { readinessUrl }),
116
+ portEnv: nonEmptyString(block.port_env) ?? DEFAULT_PORT_ENV,
117
+ gates,
118
+ ...(block.verify === undefined ? {} : { verify }),
119
+ };
101
120
  }
102
121
 
103
122
  /** Resolves the Run Recipe of the repo at `root`. The JSON copy is authoritative when present
@@ -146,7 +165,17 @@ export interface AppHandle {
146
165
  * wrapper spawned underneath it. The child goes on this process's live registry
147
166
  * (worker-process.ts `trackLiveProcess`) beside the Workers, so a Foreman that is signalled or
148
167
  * exits stops the app with them rather than orphaning it. */
149
- export function startApp(recipe: RunRecipe, options: StartOptions): AppHandle {
168
+ /** A recipe that can actually serve the app: both halves present. `startApp` takes this rather
169
+ * than a RunRecipe so a caller cannot forget the check, and the Verifier's "cannot start the app"
170
+ * branch is the one place that decides what to do about it. */
171
+ export type StartableRecipe = RunRecipe & { start: string; readinessUrl: string };
172
+
173
+ /** The recipe when it can serve the app, undefined when it cannot. */
174
+ export function startable(recipe: RunRecipe): StartableRecipe | undefined {
175
+ return recipe.start && recipe.readinessUrl ? (recipe as StartableRecipe) : undefined;
176
+ }
177
+
178
+ export function startApp(recipe: StartableRecipe, options: StartOptions): AppHandle {
150
179
  const command = substitutePort(recipe.start, options.port, recipe.portEnv);
151
180
  const readinessUrl = substitutePort(recipe.readinessUrl, options.port, recipe.portEnv);
152
181
  const child = spawn(command, {
@@ -0,0 +1,102 @@
1
+ import { count } from "./core/text.js";
2
+ import type { AccountUsage, ActivityTurn } from "./usage.js";
3
+ import type { UsageStore } from "./usage-store.js";
4
+
5
+ /**
6
+ * What this Foreman has itself spent on an Account (the multi-account headroom design, §6).
7
+ *
8
+ * What the Foreman itself has spent on an Account, from the `costUsd` and turn count that already
9
+ * ride the completed event and were, until now, explicitly unconsumed.
10
+ *
11
+ * **It deliberately reports no percentage.** The design sketched this as a third estimator
12
+ * producing estimated window percentages for Accounts the poller cannot measure, and that
13
+ * part is not built, because it cannot be built honestly: converting "eleven turns and $4.20" into
14
+ * "38% of a five-hour window" needs the plan's limit, which nothing on this machine knows and which
15
+ * differs by subscription tier. A number like that would be invented, and — worse — it would sit in
16
+ * the same column as measurements that are real.
17
+ *
18
+ * What it is good for is the question a percentage cannot answer anyway: *how hard has the Foreman
19
+ * been leaning on this Account?* That is a fact, it is this machine's to know, and it is what a
20
+ * human looks for when an Account keeps hitting its limit. It is reported beside the windows in
21
+ * `foreman account usage`, never used to hold an Account back or to rank a Lane.
22
+ *
23
+ * Called activity on purpose: `cli/CONTEXT.md` puts "Ledger" on the avoid list
24
+ * for both the Run Record and the Process Table, and a third thing wearing the word would undo
25
+ * exactly the vocabulary that file exists to keep straight.
26
+ */
27
+
28
+ /** The windows this reports on, matching the vendor's own two. */
29
+ const FIVE_HOURS = 5 * 60 * 60_000;
30
+ const SEVEN_DAYS = 7 * 24 * 60 * 60_000;
31
+
32
+ /** The longest window reported on: a turn older than this answers no question asked here. */
33
+ const ACTIVITY_HORIZON_MS = SEVEN_DAYS;
34
+
35
+ /** The most turns kept for one Account. A busy fleet would otherwise grow the record without bound,
36
+ * and the oldest turns are the ones that have already aged out of every window that matters. */
37
+ export const ACTIVITY_TURN_CAP = 2_000;
38
+
39
+ /** Turns inside `windowMs`, and what they cost. A turn whose cost the vendor did not report still
40
+ * counts as a turn: the turn happened, and that is the part this is sure of. */
41
+ export function activityIn(usage: AccountUsage | undefined, windowMs: number, now: number): { turns: number; costUsd: number } {
42
+ let turns = 0;
43
+ let costUsd = 0;
44
+ for (const turn of usage?.activity?.turns ?? []) {
45
+ const at = Date.parse(turn.at);
46
+ if (!Number.isFinite(at) || at < now - windowMs || at > now) continue;
47
+ turns++;
48
+ if (typeof turn.costUsd === "number" && Number.isFinite(turn.costUsd)) costUsd += turn.costUsd;
49
+ }
50
+ return { turns, costUsd };
51
+ }
52
+
53
+ /** What the Foreman has spent on this Account, in one line, or undefined when it has run none. */
54
+ export function summariseActivity(usage: AccountUsage | undefined, now: number): string | undefined {
55
+ const recent = activityIn(usage, FIVE_HOURS, now);
56
+ const week = activityIn(usage, SEVEN_DAYS, now);
57
+ if (week.turns === 0) return undefined;
58
+ const spent = week.costUsd > 0 ? `, $${week.costUsd.toFixed(2)} this week` : "";
59
+ return `this Foreman ran ${count(recent.turns, "turn")} in the last 5h and ${count(week.turns, "turn")} in 7d${spent}`;
60
+ }
61
+
62
+ /**
63
+ * Records one completed turn against an Account.
64
+ *
65
+ * Goes through the store's merge like everything else, so a turn recorded by one Run and a window
66
+ * measured by the poller at the same moment cannot lose each other (`mergeActivity`).
67
+ */
68
+ export function recordTurn(store: UsageStore, account: string, at: number, costUsd: number | undefined): void {
69
+ store.observe(account, {}, at, {
70
+ turns: [costUsd === undefined ? { at: new Date(at).toISOString() } : { at: new Date(at).toISOString(), costUsd }],
71
+ });
72
+ }
73
+
74
+ /** Turns still worth keeping: inside the horizon, newest-last, and no more than the cap. */
75
+ export function pruneTurns(turns: readonly ActivityTurn[], now: number): ActivityTurn[] {
76
+ const kept = turns
77
+ .filter((turn) => {
78
+ const at = Date.parse(turn.at);
79
+ return Number.isFinite(at) && at >= now - ACTIVITY_HORIZON_MS;
80
+ })
81
+ .sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
82
+ return kept.length > ACTIVITY_TURN_CAP ? kept.slice(kept.length - ACTIVITY_TURN_CAP) : kept;
83
+ }
84
+
85
+ /**
86
+ * Both writers' turns, in order, with duplicates dropped.
87
+ *
88
+ * The usage store has two writers — a Run recording a turn, the daemon's poller recording windows —
89
+ * and per-Account files keep them from losing each other's *Accounts*. This is what keeps them from
90
+ * losing each other's *turns*: a read-modify-write that overwrote the activity list would drop
91
+ * whatever the other process appended in between.
92
+ *
93
+ * A turn is identified by its instant and its cost. Two genuinely simultaneous turns of identical
94
+ * cost on one Account collapse into one, which costs the record a turn it would have counted; the
95
+ * alternative is an id per turn on a record that is already advisory, and this record is explicitly
96
+ * not a number anything depends on.
97
+ */
98
+ export function mergeActivity(mine: readonly ActivityTurn[] | undefined, theirs: readonly ActivityTurn[] | undefined, now: number): ActivityTurn[] {
99
+ const byKey = new Map<string, ActivityTurn>();
100
+ for (const turn of [...(mine ?? []), ...(theirs ?? [])]) byKey.set(`${turn.at}|${turn.costUsd ?? ""}`, turn);
101
+ return pruneTurns([...byKey.values()], now);
102
+ }