@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.
- package/README.md +143 -9
- package/package.json +2 -2
- package/src/args.ts +167 -15
- package/src/cli.ts +62 -6
- package/src/commands/account-usage.ts +106 -0
- package/src/commands/account.ts +134 -1
- package/src/commands/claude-launcher.ts +183 -0
- package/src/commands/foreman-debug.ts +11 -4
- package/src/commands/foreman-up.ts +184 -4
- package/src/commands/foreman.ts +133 -11
- package/src/commands/init.ts +4 -8
- package/src/foreman/account.ts +106 -13
- package/src/foreman/claude-worker-adapter.ts +100 -1
- package/src/foreman/config-skeleton.ts +167 -0
- package/src/foreman/core/run.ts +157 -7
- package/src/foreman/core/scheduler.ts +139 -0
- package/src/foreman/core/short-turn.ts +80 -0
- package/src/foreman/core/start-discovery.ts +158 -0
- package/src/foreman/core/text.ts +29 -0
- package/src/foreman/core/triage.ts +10 -47
- package/src/foreman/core/verifier.ts +51 -4
- package/src/foreman/credential-store.ts +247 -0
- package/src/foreman/run-recipe.ts +37 -8
- package/src/foreman/usage-activity.ts +102 -0
- package/src/foreman/usage-format.ts +107 -0
- package/src/foreman/usage-poller.ts +489 -0
- package/src/foreman/usage-store.ts +213 -0
- package/src/foreman/usage.ts +257 -0
- package/src/foreman/vendor.ts +23 -0
- package/src/foreman/worker-adapter.ts +12 -0
- package/src/init/prompt.ts +17 -18
- package/src/init/run-recipe-config.ts +11 -12
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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(
|
|
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} (${
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 (
|
|
87
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|