diff --git a/README.md b/README.md index 3520c580..bc180ba1 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,9 @@ Requirements: - Node.js `24.18.1` through `fnm`; - pnpm `11.18.0`; -- a locally authenticated Codex CLI or Claude CLI product subscription. +- at least one locally authenticated agent CLI harness — `codex`, `claude`, `glm`, `deepseek` or + `muse`. Three run on a subscription and two on a wallet billed by the token; the harness setup page + says which before you hand any of them a credential. ```bash fnm use @@ -187,9 +189,10 @@ asks the daemon for anything: - **Harnesses** — the roster a new investigation starts on, and the model and reasoning effort behind each of the director, researcher and verifier roles; -- **Subscriptions** — what each authenticated subscription has left and when the reading was taken, +- **Allowances** — what each authenticated harness has left and when the reading was taken, refreshed on its own and on demand, with a limiter on every window that says how far into it the - lab may spend; + lab may spend. A harness billed by the token carries no window, so it states a balance and takes + no limiter; - **Notifications** — the channels the lab reaches the operator through when nobody is watching this page, which moments each one reports and what language it writes in; - **Storage** — what each run directory takes up under `OPENLAB_HOME`, including directories left behind diff --git a/apps/cli/src/lab-installation/render-installation.ts b/apps/cli/src/lab-installation/render-installation.ts index f517f597..a1d2620f 100644 --- a/apps/cli/src/lab-installation/render-installation.ts +++ b/apps/cli/src/lab-installation/render-installation.ts @@ -64,7 +64,8 @@ export function renderInstallationReport(report: InstallationReport): string { lines.push( "", "The lab dispatches every agent to one of these CLIs and cannot research without one.", - "Install one and authenticate it with your own subscription." + "Install one and give it a credential of your own — a subscription for codex, claude or", + "glm, a key or a Meta login for deepseek and muse." ); } diff --git a/apps/daemon/src/agent-harness/agent-harness.fixture.ts b/apps/daemon/src/agent-harness/agent-harness.fixture.ts index 5bebf56a..2c800a1e 100644 --- a/apps/daemon/src/agent-harness/agent-harness.fixture.ts +++ b/apps/daemon/src/agent-harness/agent-harness.fixture.ts @@ -64,29 +64,29 @@ export function harnessNotInstalled(kind: AgentHarnessKind): StubHarness { new HarnessCapabilityError( kind, HarnessCapabilityGaps.INSTALLATION, - `${kind} CLI is unavailable`, + `${kind} CLI is unavailable or cannot report its version`, { - need: `${kind} CLI with an active product subscription login`, - reason: `The ${kind} agent cannot run without proven subscription authentication`, - provisioningHint: `Install ${kind}, log in interactively, then retry` + need: `The ${kind} CLI on this machine`, + reason: `The ${kind} agent cannot run until its own CLI answers`, + provisioningHint: `Install the ${kind} CLI, make sure it is on PATH, then retry` } ) ) ); } -/** An installed CLI whose login is not one the lab may bill: an API key, a console account. */ +/** An installed CLI holding no credential the lab can spend: signed out, or never given a key. */ export function harnessNotSignedIn(kind: AgentHarnessKind): StubHarness { return new StubHarness(kind, () => Promise.reject( new HarnessCapabilityError( kind, - HarnessCapabilityGaps.SUBSCRIPTION, - `${kind} CLI is not authenticated through a product subscription`, + HarnessCapabilityGaps.CREDENTIAL, + `${kind} holds no credential the lab can spend`, { - need: `${kind} CLI logged in through an active product subscription`, - reason: "Usage-billed authentication is forbidden", - provisioningHint: `Log in to ${kind} interactively and retry` + need: `A ${kind} credential the lab can spend`, + reason: `${kind} authenticates every run against an account the operator provides`, + provisioningHint: `Give ${kind} its credential on the harness setup page and retry` } ) ) diff --git a/apps/daemon/src/daemon-runtime/daemon-startup.ts b/apps/daemon/src/daemon-runtime/daemon-startup.ts index b10a3197..4751da14 100644 --- a/apps/daemon/src/daemon-runtime/daemon-startup.ts +++ b/apps/daemon/src/daemon-runtime/daemon-startup.ts @@ -10,6 +10,7 @@ import { PromiseSettlementStatus } from "#src/daemon-runtime/daemon-startup.cons import type { DaemonDependencies, RunningDaemon } from "#src/daemon-runtime/daemon-startup.types"; import { DaemonStartupStep } from "#src/daemon-runtime/daemon-startup-progress.const"; import type { DaemonStartupProgress } from "#src/daemon-runtime/daemon-startup-progress.types"; +import { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; import { HarnessReadinessChecks } from "#src/harness-readiness/harness-readiness-checks"; import { InvestigationRegistry } from "#src/investigation-registry/investigation-registry"; import { createStatusServer } from "#src/investigation-status/status-server"; @@ -18,7 +19,6 @@ import { investigationsAnswering } from "#src/operator-answers/answering-investi import { OperatorAnswers } from "#src/operator-answers/operator-answers"; import { NotificationDispatch } from "#src/operator-notifications/notification-dispatch"; import { NotificationSettingsStore } from "#src/operator-notifications/notification-settings-store"; -import { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; /** * Brings up the lab: the database it keeps its investigations in, the registry that holds them, @@ -46,7 +46,7 @@ export async function startDaemon( ? at(DaemonStartupStep.DASHBOARD_MISSING, config.dashboardRoot) : at(DaemonStartupStep.DASHBOARD, dashboardRoot) ); - const subscriptions = new SubscriptionAllowanceReadings(); + const allowances = new HarnessAllowanceReadings(); const harnesses = new HarnessReadinessChecks(); const settings = new LabSettingsStore(database.settings); const notificationSettings = new NotificationSettingsStore(database.notifications); @@ -76,14 +76,14 @@ export async function startDaemon( workspaceRoot: config.workspaceRoot, persistence: database.persistence, investigations: database.investigations, - subscriptions, + allowances, settings, ...(dependencies.researchLoop === undefined ? {} : { researchLoop: dependencies.researchLoop }) }); app = createStatusServer(registry, { - subscriptions, + allowances, harnesses, settings, notifications: { settings: notificationSettings, dispatch }, diff --git a/apps/daemon/src/daemon-runtime/research-loop-controller.const.ts b/apps/daemon/src/daemon-runtime/research-loop-controller.const.ts index 04ed04b5..7bd97547 100644 --- a/apps/daemon/src/daemon-runtime/research-loop-controller.const.ts +++ b/apps/daemon/src/daemon-runtime/research-loop-controller.const.ts @@ -6,4 +6,4 @@ export const RESUME_STEP_MS = 3_600_000; /** Why the investigation is awake, written where an operator reads its history rather than its logs. */ -export const RESUME_REASON = "The subscriptions this investigation was waiting on are back"; +export const RESUME_REASON = "The allowances this investigation was waiting on are back"; diff --git a/apps/daemon/src/daemon-runtime/research-loop-controller.test.ts b/apps/daemon/src/daemon-runtime/research-loop-controller.test.ts index 2032bdc0..23c0e615 100644 --- a/apps/daemon/src/daemon-runtime/research-loop-controller.test.ts +++ b/apps/daemon/src/daemon-runtime/research-loop-controller.test.ts @@ -28,7 +28,7 @@ function countingLoop(started: string[]) { } describe("ResearchLoopController", () => { - it("takes the investigation back up once the subscriptions it slept on are due", async () => { + it("takes the investigation back up once the allowances it slept on are due", async () => { const workspace = await createWorkspace(); const started: string[] = []; const controller = new ResearchLoopController( diff --git a/apps/daemon/src/daemon-runtime/research-loop-controller.ts b/apps/daemon/src/daemon-runtime/research-loop-controller.ts index cf39da77..fdf0a3b8 100644 --- a/apps/daemon/src/daemon-runtime/research-loop-controller.ts +++ b/apps/daemon/src/daemon-runtime/research-loop-controller.ts @@ -5,13 +5,13 @@ import { InvestigationState } from "@openlab/protocol/investigation-lifecycle/in import type { StatusSnapshot } from "@openlab/protocol/investigation-status/status-snapshot.types"; import type { AgentActivityHub } from "#src/agent-activity/agent-activity-hub"; import { RESUME_REASON, RESUME_STEP_MS } from "#src/daemon-runtime/research-loop-controller.const"; +import type { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; import type { InvestigationWorkspace } from "#src/investigation-workspace/investigation-workspace"; import type { LabSettingsReader } from "#src/lab-settings/lab-settings.types"; import type { ResearchLoopOptions, ResearchLoopOutcome } from "#src/research-cycle/research-loop.types"; -import type { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; export class ResearchLoopController { readonly #workspace: InvestigationWorkspace; @@ -23,7 +23,7 @@ export class ResearchLoopController { /** Built afresh for every loop, because the roster an investigation runs on can be moved. */ readonly #harnesses: (() => readonly AgentHarness[]) | undefined; readonly #settings: LabSettingsReader | undefined; - readonly #subscriptions: SubscriptionAllowanceReadings | undefined; + readonly #allowances: HarnessAllowanceReadings | undefined; #abortController: AbortController | undefined; #running: Promise | undefined; #restartRequested = false; @@ -39,14 +39,14 @@ export class ResearchLoopController { ) => Promise, harnesses?: () => readonly AgentHarness[], settings?: LabSettingsReader, - subscriptions?: SubscriptionAllowanceReadings + allowances?: HarnessAllowanceReadings ) { this.#workspace = workspace; this.#activity = activity; this.#run = run; this.#harnesses = harnesses; this.#settings = settings; - this.#subscriptions = subscriptions; + this.#allowances = allowances; this.#unsubscribe = workspace.subscribe((event, snapshot) => { if (event.type !== EventType.INVESTIGATION_STATE_CHANGED) { return; @@ -78,7 +78,7 @@ export class ResearchLoopController { signal: abortController.signal, ...(this.#harnesses === undefined ? {} : { harnesses: this.#harnesses() }), ...(this.#settings === undefined ? {} : { settings: this.#settings }), - ...(this.#subscriptions === undefined ? {} : { subscriptions: this.#subscriptions }) + ...(this.#allowances === undefined ? {} : { allowances: this.#allowances }) }); this.#running = running; const clear = () => { @@ -113,14 +113,14 @@ export class ResearchLoopController { * once, when it starts, so the one in flight is given up rather than finished — the same cost as * a pause, and the only way a new harness reaches the next agent. * - * An investigation the lab put to sleep with a date on it is sleeping on its subscriptions, and + * An investigation the lab put to sleep with a date on it is sleeping on its allowances, and * pointing it somewhere else is the operator answering exactly that wait, so it goes back to * work now instead of at the reset it was holding out for. One that was paused, or that ran out * of directions, stays where the operator left it. */ async redispatch(reason: Error): Promise { if (this.#workspace.getSnapshot().investigation.state === InvestigationState.HIBERNATING) { - await this.wakeIfWaitingOnSubscriptions(reason); + await this.wakeIfWaitingOnAllowances(reason); return; } await this.cancel(reason); @@ -128,11 +128,11 @@ export class ResearchLoopController { } /** - * Gives an investigation the lab parked on its subscriptions another go at them, now rather than + * Gives an investigation the lab parked on its allowances another go at them, now rather than * at the reset it was holding out for. Only a sleep the lab dated is one it took on itself: an * investigation the operator paused, and one that ran out of directions, are left where they are. */ - async wakeIfWaitingOnSubscriptions(reason: Error): Promise { + async wakeIfWaitingOnAllowances(reason: Error): Promise { const investigation = this.#workspace.getSnapshot().investigation; if ( investigation.state !== InvestigationState.HIBERNATING || diff --git a/apps/daemon/src/subscription-allowance/subscription-allowance-readings.test.ts b/apps/daemon/src/harness-allowance/harness-allowance-readings.test.ts similarity index 68% rename from apps/daemon/src/subscription-allowance/subscription-allowance-readings.test.ts rename to apps/daemon/src/harness-allowance/harness-allowance-readings.test.ts index f5780531..63bd4ae3 100644 --- a/apps/daemon/src/subscription-allowance/subscription-allowance-readings.test.ts +++ b/apps/daemon/src/harness-allowance/harness-allowance-readings.test.ts @@ -1,19 +1,19 @@ import { HarnessKinds } from "@openlab/harness/agent-harness.const"; -import type { SubscriptionAllowance as HarnessAllowance } from "@openlab/harness/subscription-allowance.types"; -import { SubscriptionAllowanceState } from "@openlab/protocol/subscription-allowance/subscription-allowance.const"; +import type { HarnessAllowanceReading } from "@openlab/harness/harness-allowance.types"; +import { HarnessAllowanceState } from "@openlab/protocol/harness-allowance/harness-allowance.const"; import { describe, expect, it } from "vitest"; import { EVERY_HARNESS_KIND } from "#src/agent-harness/harness-factory"; -import { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; +import { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; const TTL_MILLISECONDS = 60_000; -function reading(windows: HarnessAllowance["windows"]): HarnessAllowance { - return { kind: HarnessKinds.CLAUDE, plan: "max", windows }; +function reading(windows: HarnessAllowanceReading["windows"]): HarnessAllowanceReading { + return { kind: HarnessKinds.CLAUDE, plan: "max", balance: null, spent: false, windows }; } -describe("SubscriptionAllowanceReadings", () => { +describe("HarnessAllowanceReadings", () => { it("calls a plan spent on the window that ran out, however much the others have left", async () => { - const readings = new SubscriptionAllowanceReadings({ + const readings = new HarnessAllowanceReadings({ read: async () => reading([ { durationMinutes: 300, usedPercent: 4, resetsAt: null }, @@ -23,12 +23,33 @@ describe("SubscriptionAllowanceReadings", () => { const allowance = await readings.read(HarnessKinds.CLAUDE); - expect(allowance.state).toBe(SubscriptionAllowanceState.EXHAUSTED); + expect(allowance.state).toBe(HarnessAllowanceState.EXHAUSTED); expect(allowance.plan).toBe("max"); }); + /** + * A wallet meters no window, so there is no ceiling for it to have reached and the vendor says + * it outright instead. Read off the windows alone, an empty wallet came back as an account with + * everything still to spend — on the same page that names the balance as nought. + */ + it("calls an empty wallet spent, having no window to have read it off", async () => { + const readings = new HarnessAllowanceReadings({ + read: async (kind): Promise => ({ + kind, + plan: null, + balance: "0.00 USD", + spent: true, + windows: [] + }) + }); + + const allowance = await readings.read(HarnessKinds.DEEPSEEK); + + expect(allowance.state).toBe(HarnessAllowanceState.EXHAUSTED); + }); + it("reports a vendor that could not be asked as unread rather than spent", async () => { - const readings = new SubscriptionAllowanceReadings({ + const readings = new HarnessAllowanceReadings({ read: async () => { throw new Error("No Keychain entry"); } @@ -36,7 +57,7 @@ describe("SubscriptionAllowanceReadings", () => { const allowance = await readings.read(HarnessKinds.CLAUDE); - expect(allowance.state).toBe(SubscriptionAllowanceState.UNREADABLE); + expect(allowance.state).toBe(HarnessAllowanceState.UNREADABLE); expect(allowance.error).toBe("No Keychain entry"); expect(allowance.windows).toHaveLength(0); }); @@ -44,7 +65,7 @@ describe("SubscriptionAllowanceReadings", () => { it("asks a vendor once per interval however many callers want to know", async () => { let asked = 0; let clock = 0; - const readings = new SubscriptionAllowanceReadings({ + const readings = new HarnessAllowanceReadings({ read: async () => { asked += 1; return reading([{ durationMinutes: 300, usedPercent: asked, resetsAt: null }]); @@ -64,7 +85,7 @@ describe("SubscriptionAllowanceReadings", () => { it("asks again once the reading it held has expired", async () => { let asked = 0; let clock = 0; - const readings = new SubscriptionAllowanceReadings({ + const readings = new HarnessAllowanceReadings({ read: async () => { asked += 1; return reading([{ durationMinutes: 300, usedPercent: asked, resetsAt: null }]); @@ -83,7 +104,7 @@ describe("SubscriptionAllowanceReadings", () => { it("shares one request between callers that arrive together", async () => { let asked = 0; - const readings = new SubscriptionAllowanceReadings({ + const readings = new HarnessAllowanceReadings({ read: async () => { asked += 1; await Promise.resolve(); @@ -103,7 +124,7 @@ describe("SubscriptionAllowanceReadings", () => { it("stands by the last answer when a vendor throttles the next reading", async () => { let asked = 0; let clock = 0; - const readings = new SubscriptionAllowanceReadings({ + const readings = new HarnessAllowanceReadings({ read: async () => { asked += 1; if (asked > 1) { @@ -120,14 +141,14 @@ describe("SubscriptionAllowanceReadings", () => { const throttled = await readings.read(HarnessKinds.CLAUDE); expect(asked).toBe(2); - expect(throttled.state).toBe(SubscriptionAllowanceState.EXHAUSTED); + expect(throttled.state).toBe(HarnessAllowanceState.EXHAUSTED); expect(throttled.read_at).toBe(first.read_at); }); it("waits out the interval again after a refused reading rather than retrying at once", async () => { let asked = 0; let clock = 0; - const readings = new SubscriptionAllowanceReadings({ + const readings = new HarnessAllowanceReadings({ read: async () => { asked += 1; if (asked > 1) { @@ -150,10 +171,10 @@ describe("SubscriptionAllowanceReadings", () => { it("asks the vendors again on a refresh however much of the interval is left", async () => { let asked = 0; - const readings = new SubscriptionAllowanceReadings({ + const readings = new HarnessAllowanceReadings({ read: async (kind) => { asked += 1; - return { kind, plan: "max", windows: [] }; + return { kind, plan: "max", balance: null, spent: false, windows: [] }; }, ttlMs: TTL_MILLISECONDS, now: () => 0 @@ -165,19 +186,19 @@ describe("SubscriptionAllowanceReadings", () => { expect(asked).toBe(EVERY_HARNESS_KIND.length * 2); }); - it("reads every subscription the lab can run on, not only the ones that answered", async () => { - const readings = new SubscriptionAllowanceReadings({ + it("reads every harness the lab can run on, not only the ones that answered", async () => { + const readings = new HarnessAllowanceReadings({ read: async (kind) => { if (kind === HarnessKinds.CODEX) { throw new Error("codex CLI is not installed"); } - return { kind, plan: "pro", windows: [] }; + return { kind, plan: "pro", balance: null, spent: false, windows: [] }; } }); const roster = await readings.readAll(); expect(roster.map((allowance) => allowance.harness)).toEqual([...EVERY_HARNESS_KIND]); - expect(roster[0]?.state).toBe(SubscriptionAllowanceState.UNREADABLE); + expect(roster[0]?.state).toBe(HarnessAllowanceState.UNREADABLE); }); }); diff --git a/apps/daemon/src/subscription-allowance/subscription-allowance-readings.ts b/apps/daemon/src/harness-allowance/harness-allowance-readings.ts similarity index 73% rename from apps/daemon/src/subscription-allowance/subscription-allowance-readings.ts rename to apps/daemon/src/harness-allowance/harness-allowance-readings.ts index 79217f98..2a98efb5 100644 --- a/apps/daemon/src/subscription-allowance/subscription-allowance-readings.ts +++ b/apps/daemon/src/harness-allowance/harness-allowance-readings.ts @@ -1,20 +1,20 @@ import { type HarnessKind, HarnessKinds } from "@openlab/harness/agent-harness.const"; -import { readSubscriptionAllowance } from "@openlab/harness/subscription-allowance"; -import type { SubscriptionAllowance } from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; +import { readHarnessAllowance } from "@openlab/harness/harness-allowance"; +import type { HarnessAllowance } from "@openlab/protocol/harness-allowance/harness-allowance.types"; import { allowanceFromReading, unreadableAllowance -} from "#src/subscription-allowance/subscription-allowance"; -import { ALLOWANCE_READING_TTL_MILLISECONDS } from "#src/subscription-allowance/subscription-allowance.const"; +} from "#src/harness-allowance/harness-allowance"; +import { ALLOWANCE_READING_TTL_MILLISECONDS } from "#src/harness-allowance/harness-allowance.const"; import type { AllowanceReadingsOptions, ReadHarnessAllowance -} from "#src/subscription-allowance/subscription-allowance-readings.types"; +} from "#src/harness-allowance/harness-allowance-readings.types"; const ALL_HARNESS_KINDS: readonly HarnessKind[] = Object.values(HarnessKinds); interface CachedReading { - readonly allowance: SubscriptionAllowance; + readonly allowance: HarnessAllowance; readonly at: number; } @@ -23,20 +23,20 @@ interface CachedReading { * through here, so a vendor is asked once per interval however many callers want to know, and two * callers arriving together share one request rather than launching two. */ -export class SubscriptionAllowanceReadings { +export class HarnessAllowanceReadings { readonly #read: ReadHarnessAllowance; readonly #ttlMs: number; readonly #now: () => number; readonly #cached = new Map(); - readonly #pending = new Map>(); + readonly #pending = new Map>(); constructor(options: AllowanceReadingsOptions = {}) { - this.#read = options.read ?? readSubscriptionAllowance; + this.#read = options.read ?? readHarnessAllowance; this.#ttlMs = options.ttlMs ?? ALLOWANCE_READING_TTL_MILLISECONDS; this.#now = options.now ?? Date.now; } - read(kind: HarnessKind, signal?: AbortSignal): Promise { + read(kind: HarnessKind, signal?: AbortSignal): Promise { const cached = this.#cached.get(kind); if (cached !== undefined && this.#now() - cached.at < this.#ttlMs) { return Promise.resolve(cached.allowance); @@ -45,7 +45,7 @@ export class SubscriptionAllowanceReadings { return this.#ask(kind, signal); } - readAll(signal?: AbortSignal): Promise { + readAll(signal?: AbortSignal): Promise { return Promise.all(ALL_HARNESS_KINDS.map((kind) => this.read(kind, signal))); } @@ -54,12 +54,12 @@ export class SubscriptionAllowanceReadings { * who presses refresh is saying the numbers on the page are not the ones they want, and serving * them the answer they are already looking at would make the button do nothing for a minute. */ - refreshAll(signal?: AbortSignal): Promise { + refreshAll(signal?: AbortSignal): Promise { return Promise.all(ALL_HARNESS_KINDS.map((kind) => this.#ask(kind, signal))); } /** One request per vendor at a time, so callers arriving together join the one in flight. */ - #ask(kind: HarnessKind, signal?: AbortSignal): Promise { + #ask(kind: HarnessKind, signal?: AbortSignal): Promise { const pending = this.#pending.get(kind); if (pending !== undefined) { return pending; @@ -75,9 +75,9 @@ export class SubscriptionAllowanceReadings { * usage endpoint itself, and forgetting a spent plan on a throttled poll would send the next run * straight into it, so the last answer stands and only its interval restarts. */ - async #readVendor(kind: HarnessKind, signal?: AbortSignal): Promise { + async #readVendor(kind: HarnessKind, signal?: AbortSignal): Promise { const at = this.#now(); - let allowance: SubscriptionAllowance; + let allowance: HarnessAllowance; try { allowance = allowanceFromReading( await this.#read(kind, signal), diff --git a/apps/daemon/src/subscription-allowance/subscription-allowance-readings.types.ts b/apps/daemon/src/harness-allowance/harness-allowance-readings.types.ts similarity index 73% rename from apps/daemon/src/subscription-allowance/subscription-allowance-readings.types.ts rename to apps/daemon/src/harness-allowance/harness-allowance-readings.types.ts index 554beefd..a88e36ca 100644 --- a/apps/daemon/src/subscription-allowance/subscription-allowance-readings.types.ts +++ b/apps/daemon/src/harness-allowance/harness-allowance-readings.types.ts @@ -1,10 +1,10 @@ import type { HarnessKind } from "@openlab/harness/agent-harness.const"; -import type { SubscriptionAllowance as HarnessAllowance } from "@openlab/harness/subscription-allowance.types"; +import type { HarnessAllowanceReading } from "@openlab/harness/harness-allowance.types"; export type ReadHarnessAllowance = ( kind: HarnessKind, signal?: AbortSignal -) => Promise; +) => Promise; export interface AllowanceReadingsOptions { readonly read?: ReadHarnessAllowance; diff --git a/apps/daemon/src/subscription-allowance/subscription-allowance.const.ts b/apps/daemon/src/harness-allowance/harness-allowance.const.ts similarity index 90% rename from apps/daemon/src/subscription-allowance/subscription-allowance.const.ts rename to apps/daemon/src/harness-allowance/harness-allowance.const.ts index 84132c95..ac0421f5 100644 --- a/apps/daemon/src/subscription-allowance/subscription-allowance.const.ts +++ b/apps/daemon/src/harness-allowance/harness-allowance.const.ts @@ -1,4 +1,4 @@ -export const SUBSCRIPTION_ALLOWANCE_ROUTE = "/api/subscriptions"; +export const HARNESS_ALLOWANCE_ROUTE = "/api/allowances"; /** * How long a reading stands before the vendor is asked again. Every dashboard poll and every diff --git a/apps/daemon/src/subscription-allowance/subscription-allowance.ts b/apps/daemon/src/harness-allowance/harness-allowance.ts similarity index 58% rename from apps/daemon/src/subscription-allowance/subscription-allowance.ts rename to apps/daemon/src/harness-allowance/harness-allowance.ts index ce9f8775..58ff4524 100644 --- a/apps/daemon/src/subscription-allowance/subscription-allowance.ts +++ b/apps/daemon/src/harness-allowance/harness-allowance.ts @@ -1,23 +1,26 @@ import type { HarnessKind } from "@openlab/harness/agent-harness.const"; -import type { SubscriptionAllowance as HarnessAllowance } from "@openlab/harness/subscription-allowance.types"; +import type { HarnessAllowanceReading } from "@openlab/harness/harness-allowance.types"; import { ALLOWANCE_EXHAUSTED_PERCENT, - SubscriptionAllowanceState -} from "@openlab/protocol/subscription-allowance/subscription-allowance.const"; + HarnessAllowanceState +} from "@openlab/protocol/harness-allowance/harness-allowance.const"; import type { AllowanceWindow, - SubscriptionAllowance -} from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; + HarnessAllowance +} from "@openlab/protocol/harness-allowance/harness-allowance.types"; /** - * Classifies what a vendor answered into what the lab may do with that subscription. A window at + * Classifies what a vendor answered into what the lab may do with that harness. A window at * its ceiling exhausts the plan on its own: the vendor stops serving on whichever window ran out * first, however much the others have left. + * + * A vendor that says outright it has stopped serving is taken at its word, which is the only thing + * an empty wallet can say — it meters no window, so there is no ceiling for it to have reached. */ export function allowanceFromReading( - reading: HarnessAllowance, + reading: HarnessAllowanceReading, readAt: string -): SubscriptionAllowance { +): HarnessAllowance { const windows = reading.windows.map( (window): AllowanceWindow => ({ duration_minutes: window.durationMinutes, @@ -28,10 +31,13 @@ export function allowanceFromReading( return { harness: reading.kind, - state: windows.some((window) => window.used_percent >= ALLOWANCE_EXHAUSTED_PERCENT) - ? SubscriptionAllowanceState.EXHAUSTED - : SubscriptionAllowanceState.AVAILABLE, + state: + reading.spent || + windows.some((window) => window.used_percent >= ALLOWANCE_EXHAUSTED_PERCENT) + ? HarnessAllowanceState.EXHAUSTED + : HarnessAllowanceState.AVAILABLE, plan: reading.plan, + balance: reading.balance, windows, error: null, read_at: readAt @@ -46,11 +52,12 @@ export function unreadableAllowance( kind: HarnessKind, cause: unknown, readAt: string -): SubscriptionAllowance { +): HarnessAllowance { return { harness: kind, - state: SubscriptionAllowanceState.UNREADABLE, + state: HarnessAllowanceState.UNREADABLE, plan: null, + balance: null, windows: [], error: cause instanceof Error ? cause.message : String(cause), read_at: readAt diff --git a/apps/daemon/src/subscription-allowance/subscription-block.const.ts b/apps/daemon/src/harness-allowance/harness-block.const.ts similarity index 59% rename from apps/daemon/src/subscription-allowance/subscription-block.const.ts rename to apps/daemon/src/harness-allowance/harness-block.const.ts index a0478f62..e35ef33a 100644 --- a/apps/daemon/src/subscription-allowance/subscription-block.const.ts +++ b/apps/daemon/src/harness-allowance/harness-block.const.ts @@ -1,13 +1,12 @@ /** - * Why the lab will not dispatch to a subscription. The two are kept apart because they are acted on + * Why the lab will not dispatch to a harness. The two are kept apart because they are acted on * differently: a vendor that has stopped serving is a capability the operator has to restore, while * a cap is the operator's own instruction and needs nothing from them at all. */ -export const SubscriptionBlockKind = { +export const HarnessBlockKind = { EXHAUSTED: "exhausted", WITHHELD: "withheld", /** The harness lost something it needs mid-run: a session, a CLI, a login. */ UNAVAILABLE: "unavailable" } as const; -export type SubscriptionBlockKind = - (typeof SubscriptionBlockKind)[keyof typeof SubscriptionBlockKind]; +export type HarnessBlockKind = (typeof HarnessBlockKind)[keyof typeof HarnessBlockKind]; diff --git a/apps/daemon/src/harness-allowance/harness-block.test.ts b/apps/daemon/src/harness-allowance/harness-block.test.ts new file mode 100644 index 00000000..19fa438f --- /dev/null +++ b/apps/daemon/src/harness-allowance/harness-block.test.ts @@ -0,0 +1,121 @@ +import { HarnessKinds } from "@openlab/harness/agent-harness.const"; +import type { HarnessAllowanceReading } from "@openlab/harness/harness-allowance.types"; +import { AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; +import { SpendCapsSchema } from "@openlab/protocol/spend-caps/spend-cap.schema"; +import { describe, expect, it } from "vitest"; +import { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; +import { harnessBlock } from "#src/harness-allowance/harness-block"; +import { HarnessBlockKind } from "#src/harness-allowance/harness-block.const"; + +const WEEKLY_MINUTES = 10_080; +const RESETS_AT = "2026-08-09T13:50:53.000Z"; + +const CAPS = SpendCapsSchema.parse([ + { harness: AgentHarnessKind.CLAUDE, window_minutes: WEEKLY_MINUTES, max_used_percent: 60 } +]); + +function readings(usedPercent: number, resetsAt: string | null = RESETS_AT) { + return new HarnessAllowanceReadings({ + read: async (kind): Promise => ({ + kind, + plan: "max", + balance: null, + spent: false, + windows: [{ durationMinutes: WEEKLY_MINUTES, usedPercent, resetsAt }] + }) + }); +} + +function spentWalletReadings() { + return new HarnessAllowanceReadings({ + read: async (kind): Promise => ({ + kind, + plan: null, + balance: "0.00 USD", + spent: true, + windows: [] + }) + }); +} + +describe("harnessBlock", () => { + it("says how far past the cap the harness is and when it is worth asking again", async () => { + const block = await harnessBlock({ + readings: readings(74), + caps: CAPS, + kind: HarnessKinds.CLAUDE, + spendPastCaps: false + }); + + expect(block).toMatchObject({ + kind: HarnessBlockKind.WITHHELD, + reason: `The max plan is 74% into a window capped at 60%, and is held until ${RESETS_AT}`, + returnsAt: RESETS_AT + }); + expect(block?.capabilityError).toBeUndefined(); + }); + + it("holds a harness the vendor states no reset for, without promising it back", async () => { + const block = await harnessBlock({ + readings: readings(74, null), + caps: CAPS, + kind: HarnessKinds.CLAUDE, + spendPastCaps: false + }); + + expect(block?.reason).toBe("The max plan is 74% into a window capped at 60%"); + expect(block?.returnsAt).toBeUndefined(); + }); + + it("asks the operator about a harness its vendor has stopped serving", async () => { + const block = await harnessBlock({ + readings: readings(100), + caps: CAPS, + kind: HarnessKinds.CLAUDE, + spendPastCaps: false + }); + + expect(block).toMatchObject({ + kind: HarnessBlockKind.EXHAUSTED, + reason: `The max plan has no allowance left until ${RESETS_AT}` + }); + expect(block?.capabilityError?.capabilityRequest.reason).toContain("no allowance left"); + }); + + /** + * The wallet the preflight would refuse the run on anyway, stopped a step earlier and for the + * reason the vendor gave. Told to wait for a plan it never bought, an operator would sit out a + * renewal that only their own payment brings back. + */ + it("stops a spent wallet without calling it a plan that will renew", async () => { + const block = await harnessBlock({ + readings: spentWalletReadings(), + caps: CAPS, + kind: HarnessKinds.DEEPSEEK, + spendPastCaps: false + }); + + expect(block).toMatchObject({ + kind: HarnessBlockKind.EXHAUSTED, + reason: "The deepseek account has no allowance left" + }); + expect(block?.returnsAt).toBeUndefined(); + }); + + it("lets a harness under its cap through", async () => { + await expect( + harnessBlock({ + readings: readings(59), + caps: CAPS, + kind: HarnessKinds.CLAUDE, + spendPastCaps: false + }) + ).resolves.toBeUndefined(); + }); + + it("dispatches blind rather than guessing when no readings were wired in", async () => { + await expect( + harnessBlock({ caps: CAPS, kind: HarnessKinds.CLAUDE, spendPastCaps: false }) + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/daemon/src/subscription-allowance/subscription-block.ts b/apps/daemon/src/harness-allowance/harness-block.ts similarity index 56% rename from apps/daemon/src/subscription-allowance/subscription-block.ts rename to apps/daemon/src/harness-allowance/harness-block.ts index c2742d50..8d313534 100644 --- a/apps/daemon/src/subscription-allowance/subscription-block.ts +++ b/apps/daemon/src/harness-allowance/harness-block.ts @@ -1,40 +1,35 @@ import type { HarnessKind } from "@openlab/harness/agent-harness.const"; -import { subscriptionUsageLimitError } from "@openlab/harness/subscription-usage-limit"; -import { windowSpendCap, withheldWindows } from "@openlab/protocol/spend-caps/spend-cap"; -import type { SpendCaps } from "@openlab/protocol/spend-caps/spend-cap.types"; +import { spentAllowanceError } from "@openlab/harness/spent-allowance"; import { ALLOWANCE_EXHAUSTED_PERCENT, - SubscriptionAllowanceState -} from "@openlab/protocol/subscription-allowance/subscription-allowance.const"; + HarnessAllowanceState +} from "@openlab/protocol/harness-allowance/harness-allowance.const"; import type { AllowanceWindow, - SubscriptionAllowance -} from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; -import { latestWindowReset } from "@openlab/protocol/subscription-allowance/window-reset"; -import { SubscriptionBlockKind } from "#src/subscription-allowance/subscription-block.const"; -import type { - SubscriptionBlock, - SubscriptionBlockInput -} from "#src/subscription-allowance/subscription-block.types"; + HarnessAllowance +} from "@openlab/protocol/harness-allowance/harness-allowance.types"; +import { latestWindowReset } from "@openlab/protocol/harness-allowance/window-reset"; +import { windowSpendCap, withheldWindows } from "@openlab/protocol/spend-caps/spend-cap"; +import type { SpendCaps } from "@openlab/protocol/spend-caps/spend-cap.types"; +import { HarnessBlockKind } from "#src/harness-allowance/harness-block.const"; +import type { HarnessBlock, HarnessBlockInput } from "#src/harness-allowance/harness-block.types"; /** - * What is stopping the lab from spending one subscription, decided before a run is prepared rather + * What is stopping the lab from spending one harness, decided before a run is prepared rather * than after one died on it. * - * The vendor is asked first, because a subscription it has stopped serving is a fact about the + * The vendor is asked first, because an account it has stopped serving is a fact about the * account rather than about the operator's wishes: it is reported as the capability it is, and * spending past the caps cannot buy past it. A cap is checked only afterwards, and only where the * operator set one — a spent window and a reading that failed both leave every cap out of it. */ -export async function subscriptionBlock( - input: SubscriptionBlockInput -): Promise { +export async function harnessBlock(input: HarnessBlockInput): Promise { if (input.readings === undefined) { return undefined; } const allowance = await input.readings.read(input.kind, input.signal); - if (allowance.state === SubscriptionAllowanceState.EXHAUSTED) { + if (allowance.state === HarnessAllowanceState.EXHAUSTED) { return spentBlock(input.kind, allowance); } if (input.spendPastCaps) { @@ -48,50 +43,54 @@ export async function subscriptionBlock( } /** A harness that lost what it needs mid-run, carried in the same terms as the ones read ahead. */ -export function unavailableBlock(kind: HarnessKind, reason: string): SubscriptionBlock { - return { harness: kind, kind: SubscriptionBlockKind.UNAVAILABLE, reason }; +export function unavailableBlock(kind: HarnessKind, reason: string): HarnessBlock { + return { harness: kind, kind: HarnessBlockKind.UNAVAILABLE, reason }; } /** * States when the allowance comes back, which is the only thing an operator can act on. The window * that ran out is not named: the vendor stops serving whatever else has headroom, so the wait is * the whole answer. + * + * Only an account the vendor sold a tier for is called a plan. A wallet names no tier, and calling + * it a plan tells an operator whose account comes back when they pay for it that they are waiting + * for a renewal that is never coming. */ -export function spentAllowanceMessage(allowance: SubscriptionAllowance): string { +export function spentAllowanceMessage(allowance: HarnessAllowance): string { const returns = latestWindowReset(spentWindows(allowance)); return returns === undefined - ? `The ${planName(allowance)} plan has no allowance left` - : `The ${planName(allowance)} plan has no allowance left until ${returns}`; + ? `${accountName(allowance)} has no allowance left` + : `${accountName(allowance)} has no allowance left until ${returns}`; } -function spentBlock(kind: HarnessKind, allowance: SubscriptionAllowance): SubscriptionBlock { +function spentBlock(kind: HarnessKind, allowance: HarnessAllowance): HarnessBlock { const reason = spentAllowanceMessage(allowance); const returnsAt = latestWindowReset(spentWindows(allowance)); return { harness: kind, - kind: SubscriptionBlockKind.EXHAUSTED, + kind: HarnessBlockKind.EXHAUSTED, reason, ...(returnsAt === undefined ? {} : { returnsAt }), /** * The refusal the vendor would have answered with, raised before a run is dispatched. It is * the same capability error a spent allowance produces mid-run, so the investigation falls - * through to the next subscription, asks the operator about this one, and hibernates on the + * through to the next harness, asks the operator about this one, and hibernates on the * real reason exactly as it already did. */ - capabilityError: subscriptionUsageLimitError(kind, reason) + capabilityError: spentAllowanceError(kind, reason) }; } function withheldBlock( kind: HarnessKind, - allowance: SubscriptionAllowance, + allowance: HarnessAllowance, withheld: readonly AllowanceWindow[], caps: SpendCaps -): SubscriptionBlock { +): HarnessBlock { const returnsAt = latestWindowReset(withheld); return { harness: kind, - kind: SubscriptionBlockKind.WITHHELD, + kind: HarnessBlockKind.WITHHELD, reason: withheldMessage(allowance, withheld, caps), ...(returnsAt === undefined ? {} : { returnsAt }) }; @@ -102,25 +101,31 @@ function withheldBlock( * subscription that still has allowance left only makes sense against the number it was held at. */ function withheldMessage( - allowance: SubscriptionAllowance, + allowance: HarnessAllowance, withheld: readonly AllowanceWindow[], caps: SpendCaps ): string { const window = withheld[0]; if (window === undefined) { - return `The ${planName(allowance)} plan has reached its spend cap`; + return `${accountName(allowance)} has reached its spend cap`; } const cap = windowSpendCap(caps, allowance.harness, window.duration_minutes); - const spent = `The ${planName(allowance)} plan is ${Math.round(window.used_percent)}% into a window capped at ${cap}%`; + const spent = `${accountName(allowance)} is ${Math.round(window.used_percent)}% into a window capped at ${cap}%`; const returns = latestWindowReset(withheld); return returns === undefined ? spent : `${spent}, and is held until ${returns}`; } /** The windows the vendor has stopped serving on, which are the ones the wait is measured by. */ -function spentWindows(allowance: SubscriptionAllowance): AllowanceWindow[] { +function spentWindows(allowance: HarnessAllowance): AllowanceWindow[] { return allowance.windows.filter((window) => window.used_percent >= ALLOWANCE_EXHAUSTED_PERCENT); } -function planName(allowance: SubscriptionAllowance): string { - return allowance.plan ?? allowance.harness; +/** + * The account as the operator bought it: the tier where the vendor sold one, and the harness itself + * where it sold none and there is nothing else to call it by. + */ +function accountName(allowance: HarnessAllowance): string { + return allowance.plan === null + ? `The ${allowance.harness} account` + : `The ${allowance.plan} plan`; } diff --git a/apps/daemon/src/subscription-allowance/subscription-block.types.ts b/apps/daemon/src/harness-allowance/harness-block.types.ts similarity index 62% rename from apps/daemon/src/subscription-allowance/subscription-block.types.ts rename to apps/daemon/src/harness-allowance/harness-block.types.ts index ab1daa22..bf095727 100644 --- a/apps/daemon/src/subscription-allowance/subscription-block.types.ts +++ b/apps/daemon/src/harness-allowance/harness-block.types.ts @@ -1,14 +1,14 @@ import type { HarnessKind } from "@openlab/harness/agent-harness.const"; import type { HarnessCapabilityError } from "@openlab/harness/harness-error"; import type { SpendCaps } from "@openlab/protocol/spend-caps/spend-cap.types"; -import type { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; -import type { SubscriptionBlockKind } from "#src/subscription-allowance/subscription-block.const"; +import type { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; +import type { HarnessBlockKind } from "#src/harness-allowance/harness-block.const"; -/** One subscription the lab may not spend right now, and everything an operator can act on. */ -export interface SubscriptionBlock { +/** One harness the lab may not spend right now, and everything an operator can act on. */ +export interface HarnessBlock { readonly harness: HarnessKind; - readonly kind: SubscriptionBlockKind; - /** Prose that says what stopped this subscription, in the vendor's or the operator's terms. */ + readonly kind: HarnessBlockKind; + /** Prose that says what stopped this harness, in the vendor's or the operator's terms. */ readonly reason: string; /** When it may be dispatched to again, where the vendor stated the reset. */ readonly returnsAt?: string; @@ -19,9 +19,9 @@ export interface SubscriptionBlock { readonly capabilityError?: HarnessCapabilityError; } -export interface SubscriptionBlockInput { +export interface HarnessBlockInput { /** Absent leaves the lab dispatching blind, learning a spent allowance from the vendor. */ - readonly readings?: SubscriptionAllowanceReadings; + readonly readings?: HarnessAllowanceReadings; readonly caps: SpendCaps; readonly kind: HarnessKind; /** This investigation was told to spend past the caps, so only the vendor can stop it. */ diff --git a/apps/daemon/src/harness-readiness/harness-readiness.ts b/apps/daemon/src/harness-readiness/harness-readiness.ts index bbdc24c2..40a86963 100644 --- a/apps/daemon/src/harness-readiness/harness-readiness.ts +++ b/apps/daemon/src/harness-readiness/harness-readiness.ts @@ -15,7 +15,7 @@ import type { HarnessReadiness } from "@openlab/protocol/harness-readiness/harne */ const READINESS_FOR_GAP: Record = { [HarnessCapabilityGaps.INSTALLATION]: HarnessReadinessState.NOT_INSTALLED, - [HarnessCapabilityGaps.SUBSCRIPTION]: HarnessReadinessState.NOT_SIGNED_IN, + [HarnessCapabilityGaps.CREDENTIAL]: HarnessReadinessState.NOT_SIGNED_IN, [HarnessCapabilityGaps.ALLOWANCE]: HarnessReadinessState.UNREADABLE }; diff --git a/apps/daemon/src/investigation-registry/investigation-registry.ts b/apps/daemon/src/investigation-registry/investigation-registry.ts index 350deeb4..8de30e9e 100644 --- a/apps/daemon/src/investigation-registry/investigation-registry.ts +++ b/apps/daemon/src/investigation-registry/investigation-registry.ts @@ -5,6 +5,7 @@ import type { InvestigationSummary } from "@openlab/protocol/investigation-statu import { AgentActivityHub } from "#src/agent-activity/agent-activity-hub"; import { createHarnesses } from "#src/agent-harness/harness-factory"; import { ResearchLoopController } from "#src/daemon-runtime/research-loop-controller"; +import type { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; import { RESTORED_INVESTIGATION_LIMIT, RegistryCloseReason @@ -23,10 +24,9 @@ import type { StatusListener } from "#src/investigation-workspace/investigation- import type { LabSettingsReader } from "#src/lab-settings/lab-settings.types"; import { SHIPPED_LAB_SETTINGS } from "#src/lab-settings/lab-settings-store"; import { runResearchLoop } from "#src/research-cycle/research-loop"; -import type { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; /** - * Every investigation the lab is holding. One lab, one database, one set of subscriptions — and as + * Every investigation the lab is holding. One lab, one database, one set of allowances — and as * many investigations as the operator has started, each with its own run directory, its own agents * and its own research loop running alongside the others. */ @@ -38,7 +38,7 @@ export class InvestigationRegistry { readonly #workspaceRoot: string; readonly #persistence: RegistryPersistence; readonly #investigations: InvestigationRecords; - readonly #subscriptions: SubscriptionAllowanceReadings | undefined; + readonly #allowances: HarnessAllowanceReadings | undefined; readonly #settings: LabSettingsReader; readonly #researchLoop: ResearchLoopRunner; @@ -46,7 +46,7 @@ export class InvestigationRegistry { this.#workspaceRoot = options.workspaceRoot; this.#persistence = options.persistence; this.#investigations = options.investigations; - this.#subscriptions = options.subscriptions; + this.#allowances = options.allowances; this.#settings = options.settings ?? SHIPPED_LAB_SETTINGS; this.#researchLoop = options.researchLoop ?? runResearchLoop; } @@ -124,14 +124,14 @@ export class InvestigationRegistry { } /** - * Gives every investigation the lab put to sleep on its subscriptions another go at them. The + * Gives every investigation the lab put to sleep on its allowances another go at them. The * caps are the lab's, so raising one answers the wait of every run held by it at once, and each * investigation decides for itself whether it was that wait it was sleeping on. */ - async wakeInvestigationsWaitingOnSubscriptions(reason: string): Promise { + async wakeInvestigationsWaitingOnAllowances(reason: string): Promise { await Promise.all( [...this.#held.values()].map((held) => - held.controller.wakeIfWaitingOnSubscriptions(new Error(reason)) + held.controller.wakeIfWaitingOnAllowances(new Error(reason)) ) ); } @@ -171,7 +171,7 @@ export class InvestigationRegistry { this.#researchLoop, () => createHarnesses(workspace.input.harness_kinds), this.#settings, - this.#subscriptions + this.#allowances ); const held: HeldInvestigation = { workspace, activity, controller }; this.#held.set(workspace.investigationId, held); diff --git a/apps/daemon/src/investigation-registry/investigation-registry.types.ts b/apps/daemon/src/investigation-registry/investigation-registry.types.ts index 4d707342..6373da63 100644 --- a/apps/daemon/src/investigation-registry/investigation-registry.types.ts +++ b/apps/daemon/src/investigation-registry/investigation-registry.types.ts @@ -3,13 +3,13 @@ import type { RuntimePersistence } from "@openlab/db/runtime/runtime-persistence import type { InvestigationSummary } from "@openlab/protocol/investigation-status/investigation-summary.types"; import type { AgentActivityHub } from "#src/agent-activity/agent-activity-hub"; import type { ResearchLoopController } from "#src/daemon-runtime/research-loop-controller"; +import type { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; import type { InvestigationWorkspace } from "#src/investigation-workspace/investigation-workspace"; import type { LabSettingsReader } from "#src/lab-settings/lab-settings.types"; import type { ResearchLoopOptions, ResearchLoopOutcome } from "#src/research-cycle/research-loop.types"; -import type { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; export type RegistryPersistence = Pick< RuntimePersistence, @@ -36,7 +36,7 @@ export interface InvestigationRegistryOptions { readonly workspaceRoot: string; readonly persistence: RegistryPersistence; readonly investigations: InvestigationRecords; - readonly subscriptions?: SubscriptionAllowanceReadings; + readonly allowances?: HarnessAllowanceReadings; /** Absent starts every investigation on the shipped roster and every role on its default. */ readonly settings?: LabSettingsReader; readonly researchLoop?: ResearchLoopRunner; diff --git a/apps/daemon/src/investigation-status/status-server.test.ts b/apps/daemon/src/investigation-status/status-server.test.ts index 051c3944..dfb0b0f0 100644 --- a/apps/daemon/src/investigation-status/status-server.test.ts +++ b/apps/daemon/src/investigation-status/status-server.test.ts @@ -4,16 +4,17 @@ import path from "node:path"; import { AgentEffortLevel, AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; import { AgentRole } from "@openlab/protocol/agents/agent-role.const"; import { CapabilityStatus } from "@openlab/protocol/capabilities/capability-request.const"; +import { HarnessAllowanceRosterSchema } from "@openlab/protocol/harness-allowance/harness-allowance.schema"; import { HarnessReadinessRosterSchema } from "@openlab/protocol/harness-readiness/harness-readiness.schema"; import { EventType } from "@openlab/protocol/investigation-events/event-type.const"; import { InvestigationInputSchema } from "@openlab/protocol/investigation-input/investigation-input.schema"; import { InvestigationState } from "@openlab/protocol/investigation-lifecycle/investigation-state.const"; import { LabSettingsSchema } from "@openlab/protocol/lab-settings/lab-settings.schema"; import { LabStorageSchema } from "@openlab/protocol/lab-storage/lab-storage.schema"; -import { SubscriptionAllowanceRosterSchema } from "@openlab/protocol/subscription-allowance/subscription-allowance.schema"; import { describe, expect, it } from "vitest"; import { harnessNotInstalled } from "#src/agent-harness/agent-harness.fixture"; import { EVERY_HARNESS_KIND } from "#src/agent-harness/harness-factory"; +import { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; import { HarnessReadinessChecks } from "#src/harness-readiness/harness-readiness-checks"; import { InvestigationRegistry } from "#src/investigation-registry/investigation-registry"; import type { HeldInvestigation } from "#src/investigation-registry/investigation-registry.types"; @@ -24,7 +25,6 @@ import { InMemoryLabSettings } from "#src/lab-settings/lab-settings.fixture"; import { LabSettingsStore } from "#src/lab-settings/lab-settings-store"; import { ResearchLoopOutcomeStatus } from "#src/research-cycle/research-loop.const"; import type { ResearchLoopOutcome } from "#src/research-cycle/research-loop.types"; -import { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; async function createTestLab(options: StatusServerOptions = {}) { const workspaceRoot = await mkdtemp(path.join(tmpdir(), "lab-server-test-")); @@ -195,7 +195,7 @@ describe("status server", () => { await lab.server.close(); }); - it("puts a run sleeping on its subscriptions back to work when it is pointed elsewhere", async () => { + it("puts a run sleeping on its allowances back to work when it is pointed elsewhere", async () => { const lab = await createTestLab(); const held = await lab.open("Waiting on the allowance"); await held.workspace.hibernate( @@ -342,19 +342,21 @@ describe("status server", () => { it("serves every subscription against the schema the dashboard validates with", async () => { const lab = await createTestLab({ - subscriptions: new SubscriptionAllowanceReadings({ + allowances: new HarnessAllowanceReadings({ read: async (kind) => ({ kind, plan: "max", + balance: null, + spent: false, windows: [{ durationMinutes: 300, usedPercent: 41, resetsAt: null }] }) }) }); - const response = await lab.server.inject({ method: "GET", url: "/api/subscriptions" }); + const response = await lab.server.inject({ method: "GET", url: "/api/allowances" }); expect(response.statusCode).toBe(200); - expect(SubscriptionAllowanceRosterSchema.parse(response.json())).toHaveLength( + expect(HarnessAllowanceRosterSchema.parse(response.json())).toHaveLength( EVERY_HARNESS_KIND.length ); await lab.server.close(); @@ -363,20 +365,20 @@ describe("status server", () => { it("re-asks the vendors for a refresh and serves the held reading to every other poll", async () => { let asked = 0; const lab = await createTestLab({ - subscriptions: new SubscriptionAllowanceReadings({ + allowances: new HarnessAllowanceReadings({ read: async (kind) => { asked += 1; - return { kind, plan: "max", windows: [] }; + return { kind, plan: "max", balance: null, spent: false, windows: [] }; } }) }); - await lab.server.inject({ method: "GET", url: "/api/subscriptions" }); - await lab.server.inject({ method: "GET", url: "/api/subscriptions" }); + await lab.server.inject({ method: "GET", url: "/api/allowances" }); + await lab.server.inject({ method: "GET", url: "/api/allowances" }); const polled = asked; const refreshed = await lab.server.inject({ method: "GET", - url: "/api/subscriptions?fresh=1" + url: "/api/allowances?fresh=1" }); expect(polled).toBe(EVERY_HARNESS_KIND.length); @@ -399,10 +401,10 @@ describe("status server", () => { await lab.server.close(); }); - it("leaves the subscriptions route unserved when no readings were wired in", async () => { + it("leaves the allowances route unserved when no readings were wired in", async () => { const lab = await createTestLab(); - const response = await lab.server.inject({ method: "GET", url: "/api/subscriptions" }); + const response = await lab.server.inject({ method: "GET", url: "/api/allowances" }); expect(response.statusCode).toBe(404); await lab.server.close(); @@ -490,7 +492,7 @@ describe("status server", () => { await lab.server.close(); }); - it("puts a run sleeping on its subscriptions back to work when the caps are raised", async () => { + it("puts a run sleeping on its allowances back to work when the caps are raised", async () => { const store = new LabSettingsStore(new InMemoryLabSettings()); await store.write( LabSettingsSchema.parse({ diff --git a/apps/daemon/src/investigation-status/status-server.ts b/apps/daemon/src/investigation-status/status-server.ts index 8b4c6269..7e576588 100644 --- a/apps/daemon/src/investigation-status/status-server.ts +++ b/apps/daemon/src/investigation-status/status-server.ts @@ -16,6 +16,11 @@ import { isSpendCapLoosened } from "@openlab/protocol/spend-caps/spend-cap"; import Fastify, { type FastifyInstance, type FastifyReply } from "fastify"; import { DaemonLogLevel } from "#src/daemon-runtime/daemon-config.const"; import { registerDeepseekKeyRoute } from "#src/deepseek-key/deepseek-key-route"; +import { + FRESH_READING_PARAM, + FRESH_READING_VALUE, + HARNESS_ALLOWANCE_ROUTE +} from "#src/harness-allowance/harness-allowance.const"; import { HARNESS_READINESS_ROUTE } from "#src/harness-readiness/harness-readiness.const"; import type { InvestigationRegistry } from "#src/investigation-registry/investigation-registry"; import type { HeldInvestigation } from "#src/investigation-registry/investigation-registry.types"; @@ -41,11 +46,6 @@ import { NotificationRoute } from "#src/operator-notifications/operator-notifications.const"; import { RELEASE_NOTICE_ROUTE } from "#src/release-notice/release-notice.const"; -import { - FRESH_READING_PARAM, - FRESH_READING_VALUE, - SUBSCRIPTION_ALLOWANCE_ROUTE -} from "#src/subscription-allowance/subscription-allowance.const"; interface InvestigationParams { id: string; @@ -102,7 +102,7 @@ export function createStatusServer( * out for. Every other settings change is read by the next dispatch anyway. */ if (isSpendCapLoosened(held, stored.spend_caps)) { - await registry.wakeInvestigationsWaitingOnSubscriptions(SPEND_CAPS_RAISED_REASON); + await registry.wakeInvestigationsWaitingOnAllowances(SPEND_CAPS_RAISED_REASON); } return stored; }); @@ -164,14 +164,14 @@ export function createStatusServer( registerDeepseekKeyRoute(app); } - if (options.subscriptions !== undefined) { - const subscriptions = options.subscriptions; + if (options.allowances !== undefined) { + const allowances = options.allowances; app.get<{ Querystring: Record }>( - SUBSCRIPTION_ALLOWANCE_ROUTE, + HARNESS_ALLOWANCE_ROUTE, async (request) => request.query[FRESH_READING_PARAM] === FRESH_READING_VALUE - ? subscriptions.refreshAll() - : subscriptions.readAll() + ? allowances.refreshAll() + : allowances.readAll() ); } @@ -253,7 +253,7 @@ export function createStatusServer( ); /** - * What one investigation dispatches to. Reading it is how the operator sees which subscriptions + * What one investigation dispatches to. Reading it is how the operator sees which allowances * a stopped run may still reach; writing it is one of the two ways past a lab held at its caps, * the other being to lift the caps off this investigation, which is written here as well. */ diff --git a/apps/daemon/src/investigation-status/status-server.types.ts b/apps/daemon/src/investigation-status/status-server.types.ts index a766a67b..a9443c25 100644 --- a/apps/daemon/src/investigation-status/status-server.types.ts +++ b/apps/daemon/src/investigation-status/status-server.types.ts @@ -1,10 +1,10 @@ import type { ReleaseNotice } from "@openlab/protocol/release-notice/release-notice.types"; import type { DaemonLogLevel } from "#src/daemon-runtime/daemon-config.const"; +import type { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; import type { HarnessReadinessChecks } from "#src/harness-readiness/harness-readiness-checks"; import type { LabSettingsStore } from "#src/lab-settings/lab-settings-store"; import type { NotificationDispatch } from "#src/operator-notifications/notification-dispatch"; import type { NotificationSettingsStore } from "#src/operator-notifications/notification-settings-store"; -import type { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; /** * Who the lab reports to, and the thing that does the reporting. They arrive together because the @@ -16,8 +16,8 @@ export interface NotificationServerOptions { } export interface StatusServerOptions { - /** The subscriptions are the lab's, not one investigation's, so the readings are shared. */ - subscriptions?: SubscriptionAllowanceReadings; + /** The allowances are the lab's, not one investigation's, so the readings are shared. */ + allowances?: HarnessAllowanceReadings; /** Whether the CLIs on this machine can run. Without it the lab cannot be set up from a page. */ harnesses?: HarnessReadinessChecks; /** What the operator set for the lab. Without it the lab answers on its shipped defaults. */ diff --git a/apps/daemon/src/investigation-workspace/investigation-workspace.test.ts b/apps/daemon/src/investigation-workspace/investigation-workspace.test.ts index ab6080b9..4a772650 100644 --- a/apps/daemon/src/investigation-workspace/investigation-workspace.test.ts +++ b/apps/daemon/src/investigation-workspace/investigation-workspace.test.ts @@ -200,7 +200,7 @@ describe("InvestigationWorkspace", () => { await workspace.hibernate("Every subscription is at its cap", resumeAt); expect(workspace.getSnapshot().investigation.resume_at).toBe(resumeAt); - await workspace.wakeIfHibernating("The subscriptions are back", WakeTrigger.ALLOWANCE); + await workspace.wakeIfHibernating("The allowances are back", WakeTrigger.ALLOWANCE); expect(workspace.getSnapshot().investigation.state).toBe(InvestigationState.RUNNING); expect(workspace.getSnapshot().investigation.resume_at).toBeUndefined(); diff --git a/apps/daemon/src/research-cycle/agent-dispatch.test.ts b/apps/daemon/src/research-cycle/agent-dispatch.test.ts index 5cb925d5..206ea630 100644 --- a/apps/daemon/src/research-cycle/agent-dispatch.test.ts +++ b/apps/daemon/src/research-cycle/agent-dispatch.test.ts @@ -11,8 +11,8 @@ import type { HarnessPreflight, HarnessRunRequest } from "@openlab/harness/agent-harness.types"; +import type { HarnessAllowanceReading } from "@openlab/harness/harness-allowance.types"; import { HarnessEventTypes } from "@openlab/harness/harness-event.const"; -import type { SubscriptionAllowance as HarnessAllowance } from "@openlab/harness/subscription-allowance.types"; import { AgentEffortLevel, AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; import { AgentRole } from "@openlab/protocol/agents/agent-role.const"; import { CapabilityStatus } from "@openlab/protocol/capabilities/capability-request.const"; @@ -22,6 +22,8 @@ import { LabSettingsSchema } from "@openlab/protocol/lab-settings/lab-settings.s import { describe, expect, it } from "vitest"; import { harnessEvents, harnessRunResult } from "#src/agent-activity/agent-activity.fixture"; import { AgentActivityHub } from "#src/agent-activity/agent-activity-hub"; +import { HarnessAllowanceReadings } from "#src/harness-allowance/harness-allowance-readings"; +import { HarnessBlockKind } from "#src/harness-allowance/harness-block.const"; import { InvestigationWorkspace } from "#src/investigation-workspace/investigation-workspace"; import type { LabSettingsReader } from "#src/lab-settings/lab-settings.types"; import { SHIPPED_LAB_SETTINGS } from "#src/lab-settings/lab-settings-store"; @@ -29,10 +31,8 @@ import { DirectorPlanSchema } from "#src/research-contract/research-contract"; import { DispatchBlockedError, runAgentWithFallback } from "#src/research-cycle/agent-dispatch"; import type { AgentWorkspace } from "#src/research-cycle/agent-workspace.types"; import type { AvailableHarness } from "#src/research-cycle/research-loop.types"; -import { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; -import { SubscriptionBlockKind } from "#src/subscription-allowance/subscription-block.const"; -/** The window every subscription in these tests is metered on, and the cap set against it. */ +/** The window every harness in these tests is metered on, and the cap set against it. */ const WEEKLY_WINDOW_MINUTES = 10_080; const WEEKLY_CAP_PERCENT = 60; @@ -94,11 +94,13 @@ async function agentWorkspace(role: AgentRole): Promise { }; } -function readings(spent: readonly HarnessKind[]): SubscriptionAllowanceReadings { - return new SubscriptionAllowanceReadings({ - read: async (kind): Promise => ({ +function readings(spent: readonly HarnessKind[]): HarnessAllowanceReadings { + return new HarnessAllowanceReadings({ + read: async (kind): Promise => ({ kind, plan: "max", + balance: null, + spent: false, windows: [ { durationMinutes: 10_080, @@ -110,12 +112,14 @@ function readings(spent: readonly HarnessKind[]): SubscriptionAllowanceReadings }); } -/** Every subscription part-spent, so only a cap the operator set can stop the lab reaching one. */ -function partlySpentReadings(usedPercent: number): SubscriptionAllowanceReadings { - return new SubscriptionAllowanceReadings({ - read: async (kind): Promise => ({ +/** Every harness part-spent, so only a cap the operator set can stop the lab reaching one. */ +function partlySpentReadings(usedPercent: number): HarnessAllowanceReadings { + return new HarnessAllowanceReadings({ + read: async (kind): Promise => ({ kind, plan: "max", + balance: null, + spent: false, windows: [ { durationMinutes: WEEKLY_WINDOW_MINUTES, @@ -150,7 +154,7 @@ async function testWorkspace( } describe("runAgentWithFallback", () => { - it("asks the operator instead of dispatching when no subscription has allowance left", async () => { + it("asks the operator instead of dispatching when no harness has allowance left", async () => { const workspace = await testWorkspace("dispatch-spent"); let workspacesCreated = 0; @@ -158,14 +162,14 @@ describe("runAgentWithFallback", () => { workspace, activity: new AgentActivityHub(), available: [unusedHarness(HarnessKinds.CODEX), unusedHarness(HarnessKinds.CLAUDE)], - subscriptions: readings([HarnessKinds.CODEX, HarnessKinds.CLAUDE]), + allowances: readings([HarnessKinds.CODEX, HarnessKinds.CLAUDE]), settings: SHIPPED_LAB_SETTINGS, preferredIndex: 0, role: AgentRole.DIRECTOR, objective: "Find where this goal might be reachable", createAgentWorkspace: async () => { workspacesCreated += 1; - throw new Error("a run was prepared for a spent subscription"); + throw new Error("a run was prepared for a spent harness"); }, prompt: "Plan the cycle", schema: DirectorPlanSchema @@ -180,7 +184,7 @@ describe("runAgentWithFallback", () => { ).not.toHaveLength(0); }); - it("moves on to the subscription that still has allowance", async () => { + it("moves on to the harness that still has allowance", async () => { const workspace = await testWorkspace("dispatch-fallthrough"); const dispatched: string[] = []; const reached = new Error("reached dispatch"); @@ -189,7 +193,7 @@ describe("runAgentWithFallback", () => { workspace, activity: new AgentActivityHub(), available: [unusedHarness(HarnessKinds.CODEX), unusedHarness(HarnessKinds.CLAUDE)], - subscriptions: readings([HarnessKinds.CODEX]), + allowances: readings([HarnessKinds.CODEX]), settings: SHIPPED_LAB_SETTINGS, preferredIndex: 0, role: AgentRole.DIRECTOR, @@ -228,7 +232,7 @@ describe("runAgentWithFallback", () => { await expect(dispatch).rejects.toBe(reached); }); - it("passes over the subscription that reached its cap and reaches for one that has not", async () => { + it("passes over the harness that reached its cap and reaches for one that has not", async () => { const workspace = await testWorkspace("dispatch-capped"); const dispatched: HarnessKind[] = []; const requests: HarnessRunRequest[] = []; @@ -240,7 +244,7 @@ describe("runAgentWithFallback", () => { unusedHarness(HarnessKinds.CODEX), answeringHarness(HarnessKinds.CLAUDE, requests) ], - subscriptions: partlySpentReadings(WEEKLY_CAP_PERCENT + 4), + allowances: partlySpentReadings(WEEKLY_CAP_PERCENT + 4), settings: cappedSettings(AgentHarnessKind.CODEX), preferredIndex: 0, role: AgentRole.DIRECTOR, @@ -262,20 +266,20 @@ describe("runAgentWithFallback", () => { ).toEqual([HarnessKinds.CODEX]); }); - it("asks the operator for nothing when its own caps are what held every subscription", async () => { + it("asks the operator for nothing when its own caps are what held every harness", async () => { const workspace = await testWorkspace("dispatch-all-capped"); const dispatch = runAgentWithFallback({ workspace, activity: new AgentActivityHub(), available: [unusedHarness(HarnessKinds.CODEX), unusedHarness(HarnessKinds.CLAUDE)], - subscriptions: partlySpentReadings(WEEKLY_CAP_PERCENT), + allowances: partlySpentReadings(WEEKLY_CAP_PERCENT), settings: cappedSettings(AgentHarnessKind.CODEX, AgentHarnessKind.CLAUDE), preferredIndex: 0, role: AgentRole.DIRECTOR, objective: "Find where this goal might be reachable", createAgentWorkspace: async () => { - throw new Error("a run was prepared for a subscription at its cap"); + throw new Error("a run was prepared for a harness at its cap"); }, prompt: "Plan the cycle", schema: DirectorPlanSchema @@ -283,8 +287,8 @@ describe("runAgentWithFallback", () => { await expect(dispatch).rejects.toMatchObject({ blocks: [ - { harness: HarnessKinds.CODEX, kind: SubscriptionBlockKind.WITHHELD }, - { harness: HarnessKinds.CLAUDE, kind: SubscriptionBlockKind.WITHHELD } + { harness: HarnessKinds.CODEX, kind: HarnessBlockKind.WITHHELD }, + { harness: HarnessKinds.CLAUDE, kind: HarnessBlockKind.WITHHELD } ] }); expect(workspace.getSnapshot().capability_requests).toHaveLength(0); @@ -298,7 +302,7 @@ describe("runAgentWithFallback", () => { workspace, activity: new AgentActivityHub(), available: [answeringHarness(HarnessKinds.CODEX, requests)], - subscriptions: partlySpentReadings(WEEKLY_CAP_PERCENT + 30), + allowances: partlySpentReadings(WEEKLY_CAP_PERCENT + 30), settings: cappedSettings(AgentHarnessKind.CODEX), preferredIndex: 0, role: AgentRole.DIRECTOR, @@ -318,20 +322,20 @@ describe("runAgentWithFallback", () => { workspace, activity: new AgentActivityHub(), available: [unusedHarness(HarnessKinds.CODEX)], - subscriptions: readings([HarnessKinds.CODEX]), + allowances: readings([HarnessKinds.CODEX]), settings: cappedSettings(AgentHarnessKind.CODEX), preferredIndex: 0, role: AgentRole.DIRECTOR, objective: "Find where this goal might be reachable", createAgentWorkspace: async () => { - throw new Error("a run was prepared for a spent subscription"); + throw new Error("a run was prepared for a spent harness"); }, prompt: "Plan the cycle", schema: DirectorPlanSchema }); await expect(dispatch).rejects.toMatchObject({ - blocks: [{ harness: HarnessKinds.CODEX, kind: SubscriptionBlockKind.EXHAUSTED }] + blocks: [{ harness: HarnessKinds.CODEX, kind: HarnessBlockKind.EXHAUSTED }] }); expect(workspace.getSnapshot().capability_requests).not.toHaveLength(0); }); diff --git a/apps/daemon/src/research-cycle/agent-dispatch.ts b/apps/daemon/src/research-cycle/agent-dispatch.ts index 644aa50f..dd131f6a 100644 --- a/apps/daemon/src/research-cycle/agent-dispatch.ts +++ b/apps/daemon/src/research-cycle/agent-dispatch.ts @@ -4,6 +4,8 @@ import { HarnessAbortedError, HarnessCapabilityError } from "@openlab/harness/ha import { AgentRunStatus } from "@openlab/protocol/agent-runs/agent-run-status.const"; import type { CapabilityRequest } from "@openlab/protocol/capabilities/capability-request.types"; import { EventType } from "@openlab/protocol/investigation-events/event-type.const"; +import { harnessBlock, unavailableBlock } from "#src/harness-allowance/harness-block"; +import type { HarnessBlock } from "#src/harness-allowance/harness-block.types"; import type { InvestigationWorkspace } from "#src/investigation-workspace/investigation-workspace"; import { resolveRoleExecution } from "#src/lab-settings/role-execution"; import type { CapabilityRequestCandidate } from "#src/research-contract/research-contract"; @@ -25,23 +27,18 @@ import { runStructuredAgent, StructuredAgentRunError } from "#src/research-cycle/structured-agent-run"; -import { - subscriptionBlock, - unavailableBlock -} from "#src/subscription-allowance/subscription-block"; -import type { SubscriptionBlock } from "#src/subscription-allowance/subscription-block.types"; /** - * Nothing this investigation may dispatch to would take the work. What stopped each subscription is + * Nothing this investigation may dispatch to would take the work. What stopped each harness is * carried through rather than summarised away, because the investigation hibernates on it and the * operator reads it: a lab held by the caps it was given and one that lost its logins are different * problems, and only one of them is waiting on a person. */ export class DispatchBlockedError extends Error { - readonly blocks: readonly SubscriptionBlock[]; + readonly blocks: readonly HarnessBlock[]; - constructor(role: string, blocks: readonly SubscriptionBlock[]) { - super(`No subscription could take the ${role} work`); + constructor(role: string, blocks: readonly HarnessBlock[]) { + super(`No agent CLI harness could take the ${role} work`); this.name = "DispatchBlockedError"; this.blocks = blocks; } @@ -52,7 +49,7 @@ export class DispatchBlockedError extends Error { * attempt is journalled as its own run: a second harness taking over is a second agent doing the * work, and an operator reading the history should see both. * - * A subscription that has nothing left, or that has reached the cap the operator set on it, is + * A harness that has nothing left, or that has reached the cap the operator set on it, is * passed over before any run is prepared: the same outcome, reached without spending a run to find * out. */ @@ -60,13 +57,13 @@ export async function runAgentWithFallback input: AgentDispatchInput ): Promise> { let lastError: unknown; - const blocks: SubscriptionBlock[] = []; + const blocks: HarnessBlock[] = []; for (let offset = 0; offset < input.available.length; offset += 1) { throwIfAborted(input.signal); const harness = selectHarness(input.available, input.preferredIndex + offset).harness; const settings = input.settings.read(); - const block = await subscriptionBlock({ - ...(input.subscriptions === undefined ? {} : { readings: input.subscriptions }), + const block = await harnessBlock({ + ...(input.allowances === undefined ? {} : { readings: input.allowances }), caps: settings.spend_caps, kind: harness.kind, spendPastCaps: input.workspace.input.spend_past_caps, @@ -75,7 +72,7 @@ export async function runAgentWithFallback if (block !== undefined) { lastError = block.capabilityError ?? new Error(block.reason); blocks.push(block); - await recordSubscriptionBlock(input.workspace, block); + await recordHarnessBlock(input.workspace, block); continue; } @@ -132,27 +129,27 @@ export async function runAgentWithFallback } if (error instanceof HarnessCapabilityError) { blocks.push(unavailableBlock(harness.kind, error.message)); - await requestSubscriptionCapability(input.workspace, error.capabilityRequest); + await requestHarnessCapability(input.workspace, error.capabilityRequest); } } } if (blocks.length === input.available.length) { throw new DispatchBlockedError(input.role, blocks); } - throw lastError ?? new Error("All subscription CLI harness attempts failed"); + throw lastError ?? new Error("All agent CLI harness attempts failed"); } /** - * Writes down that a subscription was passed over. A vendor that has stopped serving is asked + * Writes down that a harness was passed over. A vendor that has stopped serving is asked * about, because only the operator can bring it back; a cap is the operator's own instruction, so * it is journalled and nothing is asked of them. */ -async function recordSubscriptionBlock( +async function recordHarnessBlock( workspace: InvestigationWorkspace, - block: SubscriptionBlock + block: HarnessBlock ): Promise { if (block.capabilityError !== undefined) { - await requestSubscriptionCapability(workspace, block.capabilityError.capabilityRequest); + await requestHarnessCapability(workspace, block.capabilityError.capabilityRequest); return; } await workspace.appendEvent(EventType.HARNESS_WITHHELD, { @@ -204,10 +201,10 @@ export async function persistAgentCapabilityRequests( } /** - * A harness that cannot reach its product subscription genuinely stops the investigation: no agent runs at - * all until the operator restores the session, so this is the one request the daemon raises itself. + * A harness that cannot reach the account it spends genuinely stops the investigation: no agent runs + * at all until the operator restores it, so this is the one request the daemon raises itself. */ -export function requestSubscriptionCapability( +export function requestHarnessCapability( workspace: InvestigationWorkspace, request: HarnessCapabilityRequest ): Promise { diff --git a/apps/daemon/src/research-cycle/harness-roster.ts b/apps/daemon/src/research-cycle/harness-roster.ts index 9a9bca09..f8421652 100644 --- a/apps/daemon/src/research-cycle/harness-roster.ts +++ b/apps/daemon/src/research-cycle/harness-roster.ts @@ -2,7 +2,7 @@ import type { AgentHarness } from "@openlab/harness/agent-harness.types"; import { HarnessCapabilityError } from "@openlab/harness/harness-error"; import { EventType } from "@openlab/protocol/investigation-events/event-type.const"; import type { InvestigationWorkspace } from "#src/investigation-workspace/investigation-workspace"; -import { requestSubscriptionCapability } from "#src/research-cycle/agent-dispatch"; +import { requestHarnessCapability } from "#src/research-cycle/agent-dispatch"; import type { AvailableHarness } from "#src/research-cycle/research-loop.types"; export async function preflightHarnesses( @@ -30,7 +30,7 @@ export async function preflightHarnesses( error: error instanceof Error ? error.message : String(error) }); if (error instanceof HarnessCapabilityError) { - await requestSubscriptionCapability(workspace, error.capabilityRequest); + await requestHarnessCapability(workspace, error.capabilityRequest); } return undefined; } @@ -45,7 +45,7 @@ export function selectHarness( ): AvailableHarness { const selected = available[index % available.length]; if (selected === undefined) { - throw new Error("No subscription-authenticated harness is available"); + throw new Error("No authenticated agent CLI harness is available"); } return selected; } diff --git a/apps/daemon/src/research-cycle/lead-research.ts b/apps/daemon/src/research-cycle/lead-research.ts index 3bb3eac4..63cb3e50 100644 --- a/apps/daemon/src/research-cycle/lead-research.ts +++ b/apps/daemon/src/research-cycle/lead-research.ts @@ -31,7 +31,7 @@ export async function researchLead(input: LeadResearchInput): Promise { await workspace.update((draft) => { for (const run of draft.runs) { @@ -150,10 +148,10 @@ async function hibernateOnBlockedDispatch( const reason = blockedDispatchReason(blocks); if (!heldBySpendCaps(blocks) && workspace.getSnapshot().capability_requests.length === 0) { await workspace.requestCapability({ - need: "A responsive Codex, Claude or GLM CLI with an active product subscription", + need: "A responsive agent CLI harness this investigation can dispatch to", reason, provisioningHint: - "Restore a local product-subscription CLI session and retry; API billing is forbidden", + "Sign a harness back in on the setup page, or give the lab a credential it can spend, then retry", blocking: true }); } @@ -163,15 +161,15 @@ async function hibernateOnBlockedDispatch( } /** Nothing but the operator's own caps stopped the work, so there is nothing to ask them for. */ -function heldBySpendCaps(blocks: readonly SubscriptionBlock[]): boolean { - return blocks.length > 0 && blocks.every(({ kind }) => kind === SubscriptionBlockKind.WITHHELD); +function heldBySpendCaps(blocks: readonly HarnessBlock[]): boolean { + return blocks.length > 0 && blocks.every(({ kind }) => kind === HarnessBlockKind.WITHHELD); } /** - * What every subscription answered, under the one sentence that says what it amounts to. A preflight + * What every harness answered, under the one sentence that says what it amounts to. A preflight * that found nothing carries no answers at all, which is the older and blunter way to be stopped. */ -function blockedDispatchReason(blocks: readonly SubscriptionBlock[]): string { +function blockedDispatchReason(blocks: readonly HarnessBlock[]): string { if (blocks.length === 0) { return HibernationReason.NO_HARNESS; } @@ -183,11 +181,11 @@ function blockedDispatchReason(blocks: readonly SubscriptionBlock[]): string { } /** - * When the first of the blocked subscriptions is worth asking again. One of them coming back is + * When the first of the blocked allowances is worth asking again. One of them coming back is * enough to research on, so the earliest wins, and the margin keeps the investigation from waking * onto a reading taken before the reset it is waiting for. */ -function dispatchResumesAt(blocks: readonly SubscriptionBlock[]): string | undefined { +function dispatchResumesAt(blocks: readonly HarnessBlock[]): string | undefined { const returns = blocks .flatMap(({ returnsAt }) => (returnsAt === undefined ? [] : [Date.parse(returnsAt)])) .filter((instant) => !Number.isNaN(instant)); @@ -203,7 +201,7 @@ async function runResearchCycle(input: ResearchCycleInput): Promise { readonly workspace: InvestigationWorkspace; readonly activity: AgentActivityHub; readonly available: readonly AvailableHarness[]; - readonly subscriptions?: SubscriptionAllowanceReadings; + readonly allowances?: HarnessAllowanceReadings; /** The settings in force, already resolved by the loop for every stage of a cycle. */ readonly settings: LabSettingsReader; readonly preferredIndex: number; diff --git a/apps/daemon/src/subscription-allowance/subscription-block.test.ts b/apps/daemon/src/subscription-allowance/subscription-block.test.ts deleted file mode 100644 index a8e0240f..00000000 --- a/apps/daemon/src/subscription-allowance/subscription-block.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { HarnessKinds } from "@openlab/harness/agent-harness.const"; -import type { SubscriptionAllowance as HarnessAllowance } from "@openlab/harness/subscription-allowance.types"; -import { AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; -import { SpendCapsSchema } from "@openlab/protocol/spend-caps/spend-cap.schema"; -import { describe, expect, it } from "vitest"; -import { SubscriptionAllowanceReadings } from "#src/subscription-allowance/subscription-allowance-readings"; -import { subscriptionBlock } from "#src/subscription-allowance/subscription-block"; -import { SubscriptionBlockKind } from "#src/subscription-allowance/subscription-block.const"; - -const WEEKLY_MINUTES = 10_080; -const RESETS_AT = "2026-08-09T13:50:53.000Z"; - -const CAPS = SpendCapsSchema.parse([ - { harness: AgentHarnessKind.CLAUDE, window_minutes: WEEKLY_MINUTES, max_used_percent: 60 } -]); - -function readings(usedPercent: number, resetsAt: string | null = RESETS_AT) { - return new SubscriptionAllowanceReadings({ - read: async (kind): Promise => ({ - kind, - plan: "max", - windows: [{ durationMinutes: WEEKLY_MINUTES, usedPercent, resetsAt }] - }) - }); -} - -describe("subscriptionBlock", () => { - it("says how far past the cap the subscription is and when it is worth asking again", async () => { - const block = await subscriptionBlock({ - readings: readings(74), - caps: CAPS, - kind: HarnessKinds.CLAUDE, - spendPastCaps: false - }); - - expect(block).toMatchObject({ - kind: SubscriptionBlockKind.WITHHELD, - reason: `The max plan is 74% into a window capped at 60%, and is held until ${RESETS_AT}`, - returnsAt: RESETS_AT - }); - expect(block?.capabilityError).toBeUndefined(); - }); - - it("holds a subscription the vendor states no reset for, without promising it back", async () => { - const block = await subscriptionBlock({ - readings: readings(74, null), - caps: CAPS, - kind: HarnessKinds.CLAUDE, - spendPastCaps: false - }); - - expect(block?.reason).toBe("The max plan is 74% into a window capped at 60%"); - expect(block?.returnsAt).toBeUndefined(); - }); - - it("asks the operator about a subscription its vendor has stopped serving", async () => { - const block = await subscriptionBlock({ - readings: readings(100), - caps: CAPS, - kind: HarnessKinds.CLAUDE, - spendPastCaps: false - }); - - expect(block).toMatchObject({ - kind: SubscriptionBlockKind.EXHAUSTED, - reason: `The max plan has no allowance left until ${RESETS_AT}` - }); - expect(block?.capabilityError?.capabilityRequest.reason).toContain("no allowance left"); - }); - - it("lets a subscription under its cap through", async () => { - await expect( - subscriptionBlock({ - readings: readings(59), - caps: CAPS, - kind: HarnessKinds.CLAUDE, - spendPastCaps: false - }) - ).resolves.toBeUndefined(); - }); - - it("dispatches blind rather than guessing when no readings were wired in", async () => { - await expect( - subscriptionBlock({ caps: CAPS, kind: HarnessKinds.CLAUDE, spendPastCaps: false }) - ).resolves.toBeUndefined(); - }); -}); diff --git a/apps/dashboard/src/allowance-panel/allowance-client.ts b/apps/dashboard/src/allowance-panel/allowance-client.ts new file mode 100644 index 00000000..9f1243af --- /dev/null +++ b/apps/dashboard/src/allowance-panel/allowance-client.ts @@ -0,0 +1,35 @@ +import { HarnessAllowanceRosterSchema } from "@openlab/protocol/harness-allowance/harness-allowance.schema"; +import type { HarnessAllowanceRoster } from "@openlab/protocol/harness-allowance/harness-allowance.types"; + +const HARNESS_ALLOWANCE_ENDPOINT = "/api/allowances"; + +/** Tells the daemon to put the reading it is holding aside and ask the vendors again. */ +const FRESH_READING_QUERY = "?fresh=1"; + +export const harnessAllowanceQueryKey = ["lab", "allowances"] as const; + +/** Whatever the daemon has. Asking faster than it reads the vendors returns the same answer. */ +export function fetchHarnessAllowance(signal?: AbortSignal): Promise { + return requestAllowance(HARNESS_ALLOWANCE_ENDPOINT, signal); +} + +/** What the vendors say right now, which is the only thing a refresh can honestly mean. */ +export function refreshHarnessAllowance(signal?: AbortSignal): Promise { + return requestAllowance(`${HARNESS_ALLOWANCE_ENDPOINT}${FRESH_READING_QUERY}`, signal); +} + +async function requestAllowance( + url: string, + signal?: AbortSignal +): Promise { + const response = await fetch(url, { + headers: { Accept: "application/json" }, + ...(signal ? { signal } : {}) + }); + + if (!response.ok) { + throw new Error(`Allowances endpoint returned ${response.status}.`); + } + + return HarnessAllowanceRosterSchema.parse(await response.json()); +} diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance-list.const.ts b/apps/dashboard/src/allowance-panel/allowance-list.const.ts similarity index 91% rename from apps/dashboard/src/subscription-allowance/subscription-allowance-list.const.ts rename to apps/dashboard/src/allowance-panel/allowance-list.const.ts index 0b1e151f..ef3e820d 100644 --- a/apps/dashboard/src/subscription-allowance/subscription-allowance-list.const.ts +++ b/apps/dashboard/src/allowance-panel/allowance-list.const.ts @@ -2,7 +2,7 @@ export const ALLOWANCE_LIST = "grid list-none gap-3" as const; export const ALLOWANCE_CARD = "flex min-w-0 flex-col gap-3 rounded-2xl border p-4" as const; -/** A subscription the lab will pass over is worth spotting from across the page. */ +/** A harness the lab will pass over is worth spotting from across the page. */ export const ALLOWANCE_CARD_SPENT = "border-destructive/40" as const; /** The share of a window a vendor stops serving at, which is also where a meter fills up. */ @@ -10,7 +10,7 @@ export const SPENT_PERCENT = 100; export const ALLOWANCE_CARD_HEADER = "flex flex-wrap items-center gap-x-3 gap-y-2" as const; -export const ALLOWANCE_SUBSCRIPTION_NAME = "text-base font-medium" as const; +export const ALLOWANCE_HARNESS_NAME = "text-base font-medium" as const; /** * A vendor names its own tiers and spells them however it likes, so the page cases them on the way diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance-list.test.tsx b/apps/dashboard/src/allowance-panel/allowance-list.test.tsx similarity index 67% rename from apps/dashboard/src/subscription-allowance/subscription-allowance-list.test.tsx rename to apps/dashboard/src/allowance-panel/allowance-list.test.tsx index bcd9905c..82b41e73 100644 --- a/apps/dashboard/src/subscription-allowance/subscription-allowance-list.test.tsx +++ b/apps/dashboard/src/allowance-panel/allowance-list.test.tsx @@ -1,16 +1,17 @@ import { AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; +import { HarnessAllowanceState } from "@openlab/protocol/harness-allowance/harness-allowance.const"; +import type { HarnessAllowanceRoster } from "@openlab/protocol/harness-allowance/harness-allowance.types"; import type { SpendCaps } from "@openlab/protocol/spend-caps/spend-cap.types"; -import { SubscriptionAllowanceState } from "@openlab/protocol/subscription-allowance/subscription-allowance.const"; -import type { SubscriptionAllowanceRoster } from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { SubscriptionAllowanceList } from "#src/subscription-allowance/subscription-allowance-list"; +import { HarnessAllowanceList } from "#src/allowance-panel/allowance-list"; -const ROSTER: SubscriptionAllowanceRoster = [ +const ROSTER: HarnessAllowanceRoster = [ { harness: AgentHarnessKind.CLAUDE, - state: SubscriptionAllowanceState.AVAILABLE, + state: HarnessAllowanceState.AVAILABLE, plan: "max", + balance: null, windows: [ { duration_minutes: 300, used_percent: 41, resets_at: "2026-08-03T17:40:00.000Z" }, { duration_minutes: 10_080, used_percent: 28, resets_at: "2026-08-08T07:00:00.000Z" } @@ -20,8 +21,9 @@ const ROSTER: SubscriptionAllowanceRoster = [ }, { harness: AgentHarnessKind.CODEX, - state: SubscriptionAllowanceState.EXHAUSTED, + state: HarnessAllowanceState.EXHAUSTED, plan: "plus", + balance: null, windows: [ { duration_minutes: 10_080, used_percent: 100, resets_at: "2026-08-09T13:50:53.000Z" } ], @@ -30,11 +32,21 @@ const ROSTER: SubscriptionAllowanceRoster = [ }, { harness: AgentHarnessKind.GLM, - state: SubscriptionAllowanceState.UNREADABLE, + state: HarnessAllowanceState.UNREADABLE, plan: null, + balance: null, windows: [], error: "No ZCode login store at /Users/operator/.zcode/v2/config.json", read_at: "2026-08-03T12:00:00.000Z" + }, + { + harness: AgentHarnessKind.DEEPSEEK, + state: HarnessAllowanceState.AVAILABLE, + plan: null, + balance: "4.21 USD", + windows: [], + error: null, + read_at: "2026-08-03T12:00:00.000Z" } ]; @@ -44,7 +56,7 @@ const CLAUDE_SESSION_CAP = [ function renderList(caps: SpendCaps = [], heldBy: SpendCaps = caps) { return render( - { +describe("HarnessAllowanceList", () => { it("meters each window against what the vendor said is spent", () => { renderList(); @@ -64,14 +76,14 @@ describe("SubscriptionAllowanceList", () => { expect(week.map((meter) => meter.value)).toEqual([28, 100]); }); - it("marks the subscription the lab will pass over", () => { + it("marks the harness the lab will pass over", () => { renderList(); expect(screen.getByText("No allowance left")).toBeInTheDocument(); expect(screen.getByText("Plus")).toBeInTheDocument(); }); - it("gives the vendor's reason in full when a subscription could not be read", () => { + it("gives the vendor's reason in full when a harness could not be read", () => { renderList(); expect( @@ -93,7 +105,7 @@ describe("SubscriptionAllowanceList", () => { ).toBe("100"); }); - it("says which subscription the lab is holding back at the operator's own cap", () => { + it("says which harness the lab is holding back at the operator's own cap", () => { renderList(CLAUDE_SESSION_CAP); expect(screen.getByText("Held at your cap")).toBeInTheDocument(); @@ -106,8 +118,19 @@ describe("SubscriptionAllowanceList", () => { expect(screen.queryByText("Held at your cap")).not.toBeInTheDocument(); }); + /** + * A wallet reading carries no windows, so without its balance a token-billed harness would sit + * on the page as a name and nothing else — which is how it used to reach here before money and + * plan tiers became separate readings. + */ + it("states what a token-billed harness has left, which is all it has to report", () => { + renderList(); + + expect(screen.getByText("4.21 USD")).toBeInTheDocument(); + }); + it("meters without a limiter where the caps are not the page's to move", () => { - render(); + render(); expect(screen.queryByRole("slider")).not.toBeInTheDocument(); expect(screen.getByLabelText("5 hours window")).toBeInTheDocument(); diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance-list.tsx b/apps/dashboard/src/allowance-panel/allowance-list.tsx similarity index 73% rename from apps/dashboard/src/subscription-allowance/subscription-allowance-list.tsx rename to apps/dashboard/src/allowance-panel/allowance-list.tsx index 91500d32..a534c12c 100644 --- a/apps/dashboard/src/subscription-allowance/subscription-allowance-list.tsx +++ b/apps/dashboard/src/allowance-panel/allowance-list.tsx @@ -1,33 +1,33 @@ import type { AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; +import { HarnessAllowanceState } from "@openlab/protocol/harness-allowance/harness-allowance.const"; +import type { + HarnessAllowance, + HarnessAllowanceRoster +} from "@openlab/protocol/harness-allowance/harness-allowance.types"; import { windowSpendCap, withheldWindows } from "@openlab/protocol/spend-caps/spend-cap"; import type { SpendCaps } from "@openlab/protocol/spend-caps/spend-cap.types"; -import { SubscriptionAllowanceState } from "@openlab/protocol/subscription-allowance/subscription-allowance.const"; -import type { - SubscriptionAllowance, - SubscriptionAllowanceRoster -} from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; import { useTranslation } from "react-i18next"; import { HARNESS_NAME } from "#src/agent-harness/harness-name.const"; import { subscriptionPlanName } from "#src/agent-harness/subscription-plan-name"; -import { Badge } from "#src/design-system/badge"; -import { cn } from "#src/design-system/class-names"; -import { allowanceWindowName } from "#src/subscription-allowance/allowance-window-label"; -import { SpendCapMeter } from "#src/subscription-allowance/spend-cap-meter"; -import { SUBSCRIPTION_ALLOWANCE_NAMESPACE } from "#src/subscription-allowance/subscription-allowance.i18n"; import { ALLOWANCE_CARD, ALLOWANCE_CARD_HEADER, ALLOWANCE_CARD_SPENT, ALLOWANCE_ERROR, + ALLOWANCE_HARNESS_NAME, ALLOWANCE_LIST, ALLOWANCE_PLAN_NAME, - ALLOWANCE_SUBSCRIPTION_NAME, ALLOWANCE_WINDOW, ALLOWANCE_WINDOW_LIST -} from "#src/subscription-allowance/subscription-allowance-list.const"; +} from "#src/allowance-panel/allowance-list.const"; +import { HARNESS_ALLOWANCE_NAMESPACE } from "#src/allowance-panel/allowance-panel.i18n"; +import { allowanceWindowName } from "#src/allowance-panel/allowance-window-label"; +import { SpendCapMeter } from "#src/allowance-panel/spend-cap-meter"; +import { Badge } from "#src/design-system/badge"; +import { cn } from "#src/design-system/class-names"; -interface SubscriptionAllowanceListProps { - allowances: SubscriptionAllowanceRoster; +interface HarnessAllowanceListProps { + allowances: HarnessAllowanceRoster; /** Where the limiters stand on the page, which is where the operator has dragged them. */ caps: SpendCaps; /** The caps the lab has actually been given, which decide what it is doing right now. */ @@ -41,13 +41,13 @@ interface SubscriptionAllowanceListProps { * spending it. The readings are drawn as a plain list under the block's heading rather than boxed * in a card that would repeat it. */ -export function SubscriptionAllowanceList({ +export function HarnessAllowanceList({ allowances, caps, heldBy, setCap -}: SubscriptionAllowanceListProps) { - const { t } = useTranslation(SUBSCRIPTION_ALLOWANCE_NAMESPACE); +}: HarnessAllowanceListProps) { + const { t } = useTranslation(HARNESS_ALLOWANCE_NAMESPACE); const windowLabel = (durationMinutes: number): string => { const name = allowanceWindowName(durationMinutes); return "count" in name ? t(name.key, { count: name.count }) : t(name.key); @@ -62,13 +62,13 @@ export function SubscriptionAllowanceList({ key={allowance.harness} className={cn( ALLOWANCE_CARD, - (allowance.state === SubscriptionAllowanceState.EXHAUSTED || + (allowance.state === HarnessAllowanceState.EXHAUSTED || held.length > 0) && ALLOWANCE_CARD_SPENT )} > - 0} /> - {allowance.state === SubscriptionAllowanceState.UNREADABLE ? ( + 0} /> + {allowance.state === HarnessAllowanceState.UNREADABLE ? (

{allowance.error}

) : (
    @@ -105,24 +105,31 @@ export function SubscriptionAllowanceList({ ); } -function SubscriptionHeader({ +function AllowanceHeader({ allowance, withheld }: { - allowance: SubscriptionAllowance; + allowance: HarnessAllowance; withheld: boolean; }) { - const { t } = useTranslation(SUBSCRIPTION_ALLOWANCE_NAMESPACE); + const { t } = useTranslation(HARNESS_ALLOWANCE_NAMESPACE); return (
    - {HARNESS_NAME[allowance.harness]} + {HARNESS_NAME[allowance.harness]} {allowance.plan === null ? null : ( {subscriptionPlanName(allowance.plan)} )} - {allowance.state === SubscriptionAllowanceState.EXHAUSTED ? ( + {/** + * A balance is money and stays in the vendor's own spelling: capitalizing it the way a + * plan tier is capitalized would present a wallet as a plan the operator had bought. + */} + {allowance.balance === null ? null : ( + {allowance.balance} + )} + {allowance.state === HarnessAllowanceState.EXHAUSTED ? ( {t("exhausted")} ) : null} {withheld ? {t("withheld")} : null} diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance.i18n.ts b/apps/dashboard/src/allowance-panel/allowance-panel.i18n.ts similarity index 68% rename from apps/dashboard/src/subscription-allowance/subscription-allowance.i18n.ts rename to apps/dashboard/src/allowance-panel/allowance-panel.i18n.ts index 1457162d..823b1bf3 100644 --- a/apps/dashboard/src/subscription-allowance/subscription-allowance.i18n.ts +++ b/apps/dashboard/src/allowance-panel/allowance-panel.i18n.ts @@ -1,15 +1,15 @@ import type { Translated } from "#src/interface-language/translation-catalog.types"; -export const SUBSCRIPTION_ALLOWANCE_NAMESPACE = "subscription-allowance" as const; +export const HARNESS_ALLOWANCE_NAMESPACE = "harness-allowance" as const; -export const SUBSCRIPTION_ALLOWANCE_EN = { - title: "Subscriptions", +export const HARNESS_ALLOWANCE_EN = { + title: "Allowances", description: - "What each subscription has left, and how far into it the lab may spend. Drag a limiter to stop the lab dispatching before a window runs out; a run already under way still finishes, and an investigation can be told to spend past the caps.", + "What each harness has left, and how far into it the lab may spend. Drag a limiter to stop the lab dispatching before a window runs out; a run already under way still finishes, and an investigation can be told to spend past the caps. A harness billed by the token carries no window, so it states a balance and takes no limiter.", pending: "Asking the vendors what is left", - emptyTitle: "No subscription readings", + emptyTitle: "No allowance readings", emptyDescription: - "The local runtime is not reporting what the subscriptions have left, so the lab is dispatching without seeing their allowance.", + "The local runtime is not reporting what the harnesses have left, so the lab is dispatching without seeing their allowance.", readAt: "Read at", autoRefresh_one: "Refreshes on its own every minute", @@ -44,14 +44,14 @@ export const SUBSCRIPTION_ALLOWANCE_EN = { windowMinutes_other: "{{count}} minutes" }; -export const SUBSCRIPTION_ALLOWANCE_RU = { - title: "Подписки", +export const HARNESS_ALLOWANCE_RU = { + title: "Лимиты", description: - "Сколько осталось у каждой подписки и до какой отметки лаборатория может её тратить. Перетащите ограничитель, чтобы она перестала отправлять работу раньше, чем окно закончится: уже запущенный агент доработает, а отдельному исследованию можно разрешить тратить дальше ограничителя.", + "Сколько осталось у каждой оболочки и до какой отметки лаборатория может это тратить. Перетащите ограничитель, чтобы она перестала отправлять работу раньше, чем окно закончится: уже запущенный агент доработает, а отдельному исследованию можно разрешить тратить дальше ограничителя. У оболочки с оплатой по токенам окна нет — она показывает баланс и ограничителя не принимает.", pending: "Спрашиваем у вендоров, сколько осталось", - emptyTitle: "Нет данных по подпискам", + emptyTitle: "Нет данных по лимитам", emptyDescription: - "Локальная среда не сообщает, сколько осталось у подписок, поэтому лаборатория отправляет работу, не видя их лимитов.", + "Локальная среда не сообщает, сколько осталось у оболочек, поэтому лаборатория отправляет работу, не видя их лимитов.", readAt: "Считано", autoRefresh_one: "Обновляется само каждую минуту", @@ -88,4 +88,4 @@ export const SUBSCRIPTION_ALLOWANCE_RU = { windowMinutes_few: "{{count}} минуты", windowMinutes_many: "{{count}} минут", windowMinutes_other: "{{count}} минут" -} satisfies Translated; +} satisfies Translated; diff --git a/apps/dashboard/src/subscription-allowance/allowance-reading-header.const.ts b/apps/dashboard/src/allowance-panel/allowance-reading-header.const.ts similarity index 92% rename from apps/dashboard/src/subscription-allowance/allowance-reading-header.const.ts rename to apps/dashboard/src/allowance-panel/allowance-reading-header.const.ts index a8f91ef4..0624072a 100644 --- a/apps/dashboard/src/subscription-allowance/allowance-reading-header.const.ts +++ b/apps/dashboard/src/allowance-panel/allowance-reading-header.const.ts @@ -1,4 +1,4 @@ -import { ALLOWANCE_REFETCH_MILLISECONDS } from "#src/subscription-allowance/subscription-allowance-section.const"; +import { ALLOWANCE_REFETCH_MILLISECONDS } from "#src/allowance-panel/allowance-section.const"; const MILLISECONDS_PER_MINUTE = 60_000; diff --git a/apps/dashboard/src/subscription-allowance/allowance-reading-header.test.tsx b/apps/dashboard/src/allowance-panel/allowance-reading-header.test.tsx similarity index 73% rename from apps/dashboard/src/subscription-allowance/allowance-reading-header.test.tsx rename to apps/dashboard/src/allowance-panel/allowance-reading-header.test.tsx index e109ddce..a0710de4 100644 --- a/apps/dashboard/src/subscription-allowance/allowance-reading-header.test.tsx +++ b/apps/dashboard/src/allowance-panel/allowance-reading-header.test.tsx @@ -1,8 +1,8 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import { AllowanceReadingHeader } from "#src/subscription-allowance/allowance-reading-header"; -import { SUBSCRIPTION_ALLOWANCE_EN } from "#src/subscription-allowance/subscription-allowance.i18n"; +import { HARNESS_ALLOWANCE_EN } from "#src/allowance-panel/allowance-panel.i18n"; +import { AllowanceReadingHeader } from "#src/allowance-panel/allowance-reading-header"; const READ_AT = "2026-08-04T12:48:00.000Z"; @@ -19,7 +19,7 @@ describe("AllowanceReadingHeader", () => { /> ); - await user.click(screen.getByRole("button", { name: SUBSCRIPTION_ALLOWANCE_EN.refresh })); + await user.click(screen.getByRole("button", { name: HARNESS_ALLOWANCE_EN.refresh })); expect(refresh).toHaveBeenCalledOnce(); }); @@ -36,7 +36,7 @@ describe("AllowanceReadingHeader", () => { /> ); - await user.click(screen.getByRole("button", { name: SUBSCRIPTION_ALLOWANCE_EN.refresh })); + await user.click(screen.getByRole("button", { name: HARNESS_ALLOWANCE_EN.refresh })); expect(refresh).not.toHaveBeenCalled(); }); diff --git a/apps/dashboard/src/subscription-allowance/allowance-reading-header.tsx b/apps/dashboard/src/allowance-panel/allowance-reading-header.tsx similarity index 90% rename from apps/dashboard/src/subscription-allowance/allowance-reading-header.tsx rename to apps/dashboard/src/allowance-panel/allowance-reading-header.tsx index 2ee7e9bf..074e99be 100644 --- a/apps/dashboard/src/subscription-allowance/allowance-reading-header.tsx +++ b/apps/dashboard/src/allowance-panel/allowance-reading-header.tsx @@ -1,7 +1,6 @@ import { RefreshCwIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { Button } from "#src/design-system/button"; -import { cn } from "#src/design-system/class-names"; +import { HARNESS_ALLOWANCE_NAMESPACE } from "#src/allowance-panel/allowance-panel.i18n"; import { AUTO_REFRESH_MINUTES, READING_ACTIONS, @@ -11,8 +10,9 @@ import { READING_REFRESH_TURNING, READING_STATE, READING_TIME -} from "#src/subscription-allowance/allowance-reading-header.const"; -import { SUBSCRIPTION_ALLOWANCE_NAMESPACE } from "#src/subscription-allowance/subscription-allowance.i18n"; +} from "#src/allowance-panel/allowance-reading-header.const"; +import { Button } from "#src/design-system/button"; +import { cn } from "#src/design-system/class-names"; import { formatDate } from "#src/value-display/timestamp-display"; interface AllowanceReadingHeaderProps { @@ -39,7 +39,7 @@ export function AllowanceReadingHeader({ reading, failed }: AllowanceReadingHeaderProps) { - const { t } = useTranslation(SUBSCRIPTION_ALLOWANCE_NAMESPACE); + const { t } = useTranslation(HARNESS_ALLOWANCE_NAMESPACE); return (
    diff --git a/apps/dashboard/src/subscription-allowance/allowance-reading-time.test.ts b/apps/dashboard/src/allowance-panel/allowance-reading-time.test.ts similarity index 58% rename from apps/dashboard/src/subscription-allowance/allowance-reading-time.test.ts rename to apps/dashboard/src/allowance-panel/allowance-reading-time.test.ts index 73a30f25..c9c4d434 100644 --- a/apps/dashboard/src/subscription-allowance/allowance-reading-time.test.ts +++ b/apps/dashboard/src/allowance-panel/allowance-reading-time.test.ts @@ -1,14 +1,15 @@ import { AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; -import { SubscriptionAllowanceState } from "@openlab/protocol/subscription-allowance/subscription-allowance.const"; -import type { SubscriptionAllowanceRoster } from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; +import { HarnessAllowanceState } from "@openlab/protocol/harness-allowance/harness-allowance.const"; +import type { HarnessAllowanceRoster } from "@openlab/protocol/harness-allowance/harness-allowance.types"; import { describe, expect, it } from "vitest"; -import { allowanceReadingTime } from "#src/subscription-allowance/allowance-reading-time"; +import { allowanceReadingTime } from "#src/allowance-panel/allowance-reading-time"; -function readAt(harness: AgentHarnessKind, read_at: string): SubscriptionAllowanceRoster[number] { +function readAt(harness: AgentHarnessKind, read_at: string): HarnessAllowanceRoster[number] { return { harness, - state: SubscriptionAllowanceState.AVAILABLE, + state: HarnessAllowanceState.AVAILABLE, plan: "max", + balance: null, windows: [], error: null, read_at @@ -26,7 +27,7 @@ describe("allowanceReadingTime", () => { expect(allowanceReadingTime(roster)).toBe("2026-08-03T11:31:00.000Z"); }); - it("has no reading time to give when no subscription was read", () => { + it("has no reading time to give when no harness was read", () => { expect(allowanceReadingTime([])).toBeUndefined(); }); }); diff --git a/apps/dashboard/src/subscription-allowance/allowance-reading-time.ts b/apps/dashboard/src/allowance-panel/allowance-reading-time.ts similarity index 65% rename from apps/dashboard/src/subscription-allowance/allowance-reading-time.ts rename to apps/dashboard/src/allowance-panel/allowance-reading-time.ts index 3bdc3ee8..02d0ca30 100644 --- a/apps/dashboard/src/subscription-allowance/allowance-reading-time.ts +++ b/apps/dashboard/src/allowance-panel/allowance-reading-time.ts @@ -1,12 +1,12 @@ -import type { SubscriptionAllowanceRoster } from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; +import type { HarnessAllowanceRoster } from "@openlab/protocol/harness-allowance/harness-allowance.types"; /** * When the numbers on the page were read. The daemon holds each vendor separately and stands by the - * last answer from one that throttles the next reading, so the subscriptions can be read at + * last answer from one that throttles the next reading, so the allowances can be read at * different moments. The oldest of them is the only one that cannot claim the page is fresher than * it is. The readings are UTC timestamps, so the earliest is also the first in text order. */ -export function allowanceReadingTime(allowances: SubscriptionAllowanceRoster): string | undefined { +export function allowanceReadingTime(allowances: HarnessAllowanceRoster): string | undefined { return allowances .map((allowance) => allowance.read_at) .sort() diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance-section.const.ts b/apps/dashboard/src/allowance-panel/allowance-section.const.ts similarity index 100% rename from apps/dashboard/src/subscription-allowance/subscription-allowance-section.const.ts rename to apps/dashboard/src/allowance-panel/allowance-section.const.ts diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance-section.test.tsx b/apps/dashboard/src/allowance-panel/allowance-section.test.tsx similarity index 78% rename from apps/dashboard/src/subscription-allowance/subscription-allowance-section.test.tsx rename to apps/dashboard/src/allowance-panel/allowance-section.test.tsx index d591dae3..5cdc8101 100644 --- a/apps/dashboard/src/subscription-allowance/subscription-allowance-section.test.tsx +++ b/apps/dashboard/src/allowance-panel/allowance-section.test.tsx @@ -1,26 +1,27 @@ import { AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; +import { HarnessAllowanceState } from "@openlab/protocol/harness-allowance/harness-allowance.const"; +import type { HarnessAllowanceRoster } from "@openlab/protocol/harness-allowance/harness-allowance.types"; import { LabSettingsSchema } from "@openlab/protocol/lab-settings/lab-settings.schema"; -import { SubscriptionAllowanceState } from "@openlab/protocol/subscription-allowance/subscription-allowance.const"; -import type { SubscriptionAllowanceRoster } from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; -import { SUBSCRIPTION_ALLOWANCE_EN } from "#src/subscription-allowance/subscription-allowance.i18n"; -import { SubscriptionAllowanceSection } from "#src/subscription-allowance/subscription-allowance-section"; +import { HARNESS_ALLOWANCE_EN } from "#src/allowance-panel/allowance-panel.i18n"; +import { HarnessAllowanceSection } from "#src/allowance-panel/allowance-section"; import { formatDate } from "#src/value-display/timestamp-display"; const HELD_READING = "2026-08-03T12:00:00.000Z"; const FRESH_READING = "2026-08-03T12:41:00.000Z"; -function roster(readAt: string, usedPercent: number): SubscriptionAllowanceRoster { +function roster(readAt: string, usedPercent: number): HarnessAllowanceRoster { return [ { harness: AgentHarnessKind.GLM, - state: SubscriptionAllowanceState.AVAILABLE, + state: HarnessAllowanceState.AVAILABLE, plan: "pro", + balance: null, windows: [{ duration_minutes: 300, used_percent: usedPercent, resets_at: null }], error: null, read_at: readAt @@ -89,14 +90,14 @@ function savedCaps(request: ReturnType): unknown { function renderView() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - return render(, { + return render(, { wrapper: ({ children }: { children: ReactNode }) => ( {children} ) }); } -describe("SubscriptionAllowanceSection", () => { +describe("HarnessAllowanceSection", () => { it("reads the allowance off the runtime and meters it", async () => { respondWith(ROSTER); renderView(); @@ -112,9 +113,7 @@ describe("SubscriptionAllowanceSection", () => { renderView(); expect( - await screen.findByText( - `${SUBSCRIPTION_ALLOWANCE_EN.readAt} ${formatDate(HELD_READING)}` - ) + await screen.findByText(`${HARNESS_ALLOWANCE_EN.readAt} ${formatDate(HELD_READING)}`) ).toBeInTheDocument(); }); @@ -123,14 +122,10 @@ describe("SubscriptionAllowanceSection", () => { renderView(); await screen.findByLabelText("5 hours window"); - await userEvent.click( - screen.getByRole("button", { name: SUBSCRIPTION_ALLOWANCE_EN.refresh }) - ); + await userEvent.click(screen.getByRole("button", { name: HARNESS_ALLOWANCE_EN.refresh })); expect( - await screen.findByText( - `${SUBSCRIPTION_ALLOWANCE_EN.readAt} ${formatDate(FRESH_READING)}` - ) + await screen.findByText(`${HARNESS_ALLOWANCE_EN.readAt} ${formatDate(FRESH_READING)}`) ).toBeInTheDocument(); expect(request.mock.calls.at(-1)?.[0]).toContain("fresh=1"); expect(screen.getByLabelText("5 hours window").value).toBe(12); @@ -142,13 +137,11 @@ describe("SubscriptionAllowanceSection", () => { await screen.findByLabelText("5 hours window"); respondWith({ error: "Codex is rate-limiting the usage endpoint" }, 503); - await userEvent.click( - screen.getByRole("button", { name: SUBSCRIPTION_ALLOWANCE_EN.refresh }) - ); + await userEvent.click(screen.getByRole("button", { name: HARNESS_ALLOWANCE_EN.refresh })); expect(await screen.findByRole("alert")).toBeInTheDocument(); expect( - screen.getByText(`${SUBSCRIPTION_ALLOWANCE_EN.readAt} ${formatDate(HELD_READING)}`) + screen.getByText(`${HARNESS_ALLOWANCE_EN.readAt} ${formatDate(HELD_READING)}`) ).toBeInTheDocument(); }); @@ -156,7 +149,7 @@ describe("SubscriptionAllowanceSection", () => { respondWith({ error: "Not found" }, 404); renderView(); - expect(await screen.findByText(SUBSCRIPTION_ALLOWANCE_EN.emptyTitle)).toBeInTheDocument(); + expect(await screen.findByText(HARNESS_ALLOWANCE_EN.emptyTitle)).toBeInTheDocument(); expect(screen.queryByLabelText("5 hours window")).not.toBeInTheDocument(); }); @@ -167,9 +160,7 @@ describe("SubscriptionAllowanceSection", () => { limiter.focus(); await userEvent.keyboard("{ArrowLeft}{ArrowLeft}"); - await userEvent.click( - screen.getByRole("button", { name: SUBSCRIPTION_ALLOWANCE_EN.capSave }) - ); + await userEvent.click(screen.getByRole("button", { name: HARNESS_ALLOWANCE_EN.capSave })); expect(savedCaps(request)).toEqual([ { harness: AgentHarnessKind.GLM, window_minutes: 300, max_used_percent: 98 } @@ -190,11 +181,9 @@ describe("SubscriptionAllowanceSection", () => { limiter.focus(); await userEvent.keyboard("{ArrowLeft}"); - await userEvent.click( - screen.getByRole("button", { name: SUBSCRIPTION_ALLOWANCE_EN.capSave }) - ); + await userEvent.click(screen.getByRole("button", { name: HARNESS_ALLOWANCE_EN.capSave })); - expect(await screen.findByText(SUBSCRIPTION_ALLOWANCE_EN.capSaved)).toBeInTheDocument(); + expect(await screen.findByText(HARNESS_ALLOWANCE_EN.capSaved)).toBeInTheDocument(); expect( screen.getByRole("slider", { name: "5 hours spend cap" }).getAttribute("aria-valuenow") ).toBe("45"); @@ -207,7 +196,7 @@ describe("SubscriptionAllowanceSection", () => { await screen.findByRole("slider", { name: "5 hours spend cap" }); expect( - screen.queryByRole("button", { name: SUBSCRIPTION_ALLOWANCE_EN.capSave }) + screen.queryByRole("button", { name: HARNESS_ALLOWANCE_EN.capSave }) ).not.toBeInTheDocument(); }); diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance-section.tsx b/apps/dashboard/src/allowance-panel/allowance-section.tsx similarity index 81% rename from apps/dashboard/src/subscription-allowance/subscription-allowance-section.tsx rename to apps/dashboard/src/allowance-panel/allowance-section.tsx index c3d0897b..c615cd7e 100644 --- a/apps/dashboard/src/subscription-allowance/subscription-allowance-section.tsx +++ b/apps/dashboard/src/allowance-panel/allowance-section.tsx @@ -1,11 +1,28 @@ import type { AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; +import type { HarnessAllowanceRoster } from "@openlab/protocol/harness-allowance/harness-allowance.types"; import type { LabSettings } from "@openlab/protocol/lab-settings/lab-settings.types"; import type { SpendCaps } from "@openlab/protocol/spend-caps/spend-cap.types"; -import type { SubscriptionAllowanceRoster } from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckIcon } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { + fetchHarnessAllowance, + harnessAllowanceQueryKey, + refreshHarnessAllowance +} from "#src/allowance-panel/allowance-client"; +import { HarnessAllowanceList } from "#src/allowance-panel/allowance-list"; +import { HARNESS_ALLOWANCE_NAMESPACE } from "#src/allowance-panel/allowance-panel.i18n"; +import { AllowanceReadingHeader } from "#src/allowance-panel/allowance-reading-header"; +import { allowanceReadingTime } from "#src/allowance-panel/allowance-reading-time"; +import { + ALLOWANCE_READING_PENDING, + ALLOWANCE_REFETCH_MILLISECONDS, + CAPS_ACTIONS, + CAPS_FAILURE, + CAPS_SAVED +} from "#src/allowance-panel/allowance-section.const"; +import { hasCapEdits, withWindowCap } from "#src/allowance-panel/spend-cap-draft"; import { Button } from "#src/design-system/button"; import { Spinner } from "#src/design-system/spinner"; import { @@ -15,23 +32,6 @@ import { } from "#src/lab-configuration/lab-settings-client"; import { Panel } from "#src/panel/panel"; import { PanelEmptyState } from "#src/panel/panel-empty-state"; -import { AllowanceReadingHeader } from "#src/subscription-allowance/allowance-reading-header"; -import { allowanceReadingTime } from "#src/subscription-allowance/allowance-reading-time"; -import { hasCapEdits, withWindowCap } from "#src/subscription-allowance/spend-cap-draft"; -import { SUBSCRIPTION_ALLOWANCE_NAMESPACE } from "#src/subscription-allowance/subscription-allowance.i18n"; -import { - fetchSubscriptionAllowance, - refreshSubscriptionAllowance, - subscriptionAllowanceQueryKey -} from "#src/subscription-allowance/subscription-allowance-client"; -import { SubscriptionAllowanceList } from "#src/subscription-allowance/subscription-allowance-list"; -import { - ALLOWANCE_READING_PENDING, - ALLOWANCE_REFETCH_MILLISECONDS, - CAPS_ACTIONS, - CAPS_FAILURE, - CAPS_SAVED -} from "#src/subscription-allowance/subscription-allowance-section.const"; /** What the lab last stored, beside the caps the operator has since dragged the limiters to. */ interface SpendCapEdit { @@ -40,16 +40,16 @@ interface SpendCapEdit { } /** - * What every subscription the lab can run on has left, and how far into each one the lab may spend. + * What every harness the lab can run on has left, and how far into each one the lab may spend. * The two belong on one block because neither is worth much alone: a cap is a mark on a reading, * and a reading with no cap on it does not say what the lab will actually do with the allowance. */ -export function SubscriptionAllowanceSection() { - const { t } = useTranslation(SUBSCRIPTION_ALLOWANCE_NAMESPACE); +export function HarnessAllowanceSection() { + const { t } = useTranslation(HARNESS_ALLOWANCE_NAMESPACE); const queryClient = useQueryClient(); const allowances = useQuery({ - queryKey: subscriptionAllowanceQueryKey, - queryFn: ({ signal }) => fetchSubscriptionAllowance(signal), + queryKey: harnessAllowanceQueryKey, + queryFn: ({ signal }) => fetchHarnessAllowance(signal), retry: false, staleTime: ALLOWANCE_REFETCH_MILLISECONDS, refetchInterval: ALLOWANCE_REFETCH_MILLISECONDS, @@ -62,8 +62,8 @@ export function SubscriptionAllowanceSection() { * the interval carries on from there. */ const refresh = useMutation({ - mutationFn: () => refreshSubscriptionAllowance(), - onSuccess: (roster) => queryClient.setQueryData(subscriptionAllowanceQueryKey, roster) + mutationFn: () => refreshHarnessAllowance(), + onSuccess: (roster) => queryClient.setQueryData(harnessAllowanceQueryKey, roster) }); const settings = useQuery({ @@ -152,7 +152,7 @@ export function SubscriptionAllowanceSection() { } interface AllowanceReadingsProps { - allowances: SubscriptionAllowanceRoster | undefined; + allowances: HarnessAllowanceRoster | undefined; /** The first reading is still on its way, so there is nothing to show yet rather than nothing. */ pending: boolean; refresh: () => void; @@ -177,7 +177,7 @@ function AllowanceReadings({ heldBy, setCap }: AllowanceReadingsProps) { - const { t } = useTranslation(SUBSCRIPTION_ALLOWANCE_NAMESPACE); + const { t } = useTranslation(HARNESS_ALLOWANCE_NAMESPACE); if (allowances === undefined) { return pending ? ( @@ -198,7 +198,7 @@ function AllowanceReadings({ reading={reading} failed={failed} /> - void; @@ -41,7 +41,7 @@ interface SpendCapMeterProps { * consumption already reaches. */ export function SpendCapMeter({ window, label, cap, withheld, setCap }: SpendCapMeterProps) { - const { t } = useTranslation(SUBSCRIPTION_ALLOWANCE_NAMESPACE); + const { t } = useTranslation(HARNESS_ALLOWANCE_NAMESPACE); const spent = window.used_percent >= SPENT_PERCENT; return ( diff --git a/apps/dashboard/src/dashboard-routes/settings-view.const.ts b/apps/dashboard/src/dashboard-routes/settings-view.const.ts index 9f8674fc..a4f67190 100644 --- a/apps/dashboard/src/dashboard-routes/settings-view.const.ts +++ b/apps/dashboard/src/dashboard-routes/settings-view.const.ts @@ -1,7 +1,7 @@ +import { HARNESS_ALLOWANCE_NAMESPACE } from "#src/allowance-panel/allowance-panel.i18n"; import { HARNESS_SETTINGS_NAMESPACE } from "#src/harness-settings/harness-settings.i18n"; import { LAB_MAINTENANCE_NAMESPACE } from "#src/lab-maintenance/lab-maintenance.i18n"; import { OPERATOR_NOTIFICATIONS_NAMESPACE } from "#src/operator-notifications/operator-notifications.i18n"; -import { SUBSCRIPTION_ALLOWANCE_NAMESPACE } from "#src/subscription-allowance/subscription-allowance.i18n"; /** * The blocks the lab itself is read and set through. They are panels of one page rather than @@ -10,7 +10,7 @@ import { SUBSCRIPTION_ALLOWANCE_NAMESPACE } from "#src/subscription-allowance/su */ export const SettingsSection = { HARNESSES: "harnesses", - SUBSCRIPTIONS: "subscriptions", + ALLOWANCES: "allowances", NOTIFICATIONS: "notifications", STORAGE: "storage" } as const; @@ -22,7 +22,7 @@ export type SettingsSection = (typeof SettingsSection)[keyof typeof SettingsSect */ export const SETTINGS_SECTIONS = [ { section: SettingsSection.HARNESSES, title: `${HARNESS_SETTINGS_NAMESPACE}:title` }, - { section: SettingsSection.SUBSCRIPTIONS, title: `${SUBSCRIPTION_ALLOWANCE_NAMESPACE}:title` }, + { section: SettingsSection.ALLOWANCES, title: `${HARNESS_ALLOWANCE_NAMESPACE}:title` }, { section: SettingsSection.NOTIFICATIONS, title: `${OPERATOR_NOTIFICATIONS_NAMESPACE}:title` @@ -33,7 +33,7 @@ export const SETTINGS_SECTIONS = [ /** The vocabularies the tab strip reads from, which are the ones its blocks are written in. */ export const SETTINGS_NAMESPACES = [ HARNESS_SETTINGS_NAMESPACE, - SUBSCRIPTION_ALLOWANCE_NAMESPACE, + HARNESS_ALLOWANCE_NAMESPACE, OPERATOR_NOTIFICATIONS_NAMESPACE, LAB_MAINTENANCE_NAMESPACE ] as const; diff --git a/apps/dashboard/src/dashboard-routes/settings-view.tsx b/apps/dashboard/src/dashboard-routes/settings-view.tsx index 94e80546..df1c71cd 100644 --- a/apps/dashboard/src/dashboard-routes/settings-view.tsx +++ b/apps/dashboard/src/dashboard-routes/settings-view.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { HarnessAllowanceSection } from "#src/allowance-panel/allowance-section"; import { SETTINGS_NAMESPACES, SETTINGS_PAGE, @@ -9,7 +10,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "#src/design-system/tab import { HarnessSettingsSection } from "#src/harness-settings/harness-settings-section"; import { LabStorageSection } from "#src/lab-maintenance/lab-storage-section"; import { NotificationSettingsSection } from "#src/operator-notifications/notification-settings-section"; -import { SubscriptionAllowanceSection } from "#src/subscription-allowance/subscription-allowance-section"; /** * What the operator reads and sets for the lab itself rather than for one investigation. The @@ -32,8 +32,8 @@ export function SettingsView() { - - + + diff --git a/apps/dashboard/src/harness-setup/harness-setup.i18n.ts b/apps/dashboard/src/harness-setup/harness-setup.i18n.ts index 82f9eb03..816f2ff2 100644 --- a/apps/dashboard/src/harness-setup/harness-setup.i18n.ts +++ b/apps/dashboard/src/harness-setup/harness-setup.i18n.ts @@ -5,7 +5,7 @@ export const HARNESS_SETUP_NAMESPACE = "harness-setup" as const; export const HARNESS_SETUP_EN = { title: "Give the lab something to think with", - lead: "The lab has no model of its own. It drives the coding CLIs you already pay for, on this machine, under your own subscription.", + lead: "The lab has no model of its own. It drives the coding CLIs you already pay for, on this machine, under your own account.", noKeys: "Subscriptions are the way to run this. A plan you already bought costs the same whether the lab works for ten minutes or all night. Two harnesses do not work that way — DeepSeek and Muse Code are billed by the token, and their cards say so.", enough: "One is enough to start. A second is what lets the lab check its own work — a claim only counts once a different vendor reproduces it.", @@ -60,7 +60,7 @@ export const HARNESS_SETUP_EN = { export const HARNESS_SETUP_RU = { title: "Дай лаборатории, чем думать", - lead: "У лаборатории нет своей модели. Она запускает те CLI, за которые ты уже платишь, — на этой машине и по твоей подписке.", + lead: "У лаборатории нет своей модели. Она запускает те CLI, за которые ты уже платишь, — на этой машине и под твоей учётной записью.", noKeys: "Правильный способ — подписки. План, который уже куплен, стоит одинаково, работает лаборатория десять минут или всю ночь. Иначе устроены две оболочки: DeepSeek и Muse Code платят по токенам, и на их карточках это написано.", enough: "Чтобы начать, хватит одного. Второй нужен, чтобы лаборатория проверяла саму себя: заявление засчитывается, только когда его воспроизводит другой вендор.", diff --git a/apps/dashboard/src/interface-language/translation-catalog.ts b/apps/dashboard/src/interface-language/translation-catalog.ts index f676b38b..c115c7cf 100644 --- a/apps/dashboard/src/interface-language/translation-catalog.ts +++ b/apps/dashboard/src/interface-language/translation-catalog.ts @@ -1,3 +1,8 @@ +import { + HARNESS_ALLOWANCE_EN, + HARNESS_ALLOWANCE_NAMESPACE, + HARNESS_ALLOWANCE_RU +} from "#src/allowance-panel/allowance-panel.i18n"; import { BREAKTHROUGH_EN, BREAKTHROUGH_NAMESPACE, @@ -111,11 +116,6 @@ import { STATUS_TAG_NAMESPACE, STATUS_TAG_RU } from "#src/status-tag/status-tag.i18n"; -import { - SUBSCRIPTION_ALLOWANCE_EN, - SUBSCRIPTION_ALLOWANCE_NAMESPACE, - SUBSCRIPTION_ALLOWANCE_RU -} from "#src/subscription-allowance/subscription-allowance.i18n"; import { TEAM_EN, TEAM_NAMESPACE, TEAM_RU } from "#src/team/team.i18n"; import { THEME_EN, THEME_NAMESPACE, THEME_RU } from "#src/theme/theme.i18n"; import { @@ -156,7 +156,7 @@ export const EN_TRANSLATIONS = { [OPERATOR_NOTIFICATIONS_NAMESPACE]: OPERATOR_NOTIFICATIONS_EN, [RESEARCH_CYCLE_NAMESPACE]: RESEARCH_CYCLE_EN, [STATUS_TAG_NAMESPACE]: STATUS_TAG_EN, - [SUBSCRIPTION_ALLOWANCE_NAMESPACE]: SUBSCRIPTION_ALLOWANCE_EN, + [HARNESS_ALLOWANCE_NAMESPACE]: HARNESS_ALLOWANCE_EN, [TEAM_NAMESPACE]: TEAM_EN, [THEME_NAMESPACE]: THEME_EN, [VALUE_DISPLAY_NAMESPACE]: VALUE_DISPLAY_EN, @@ -190,7 +190,7 @@ export const RU_TRANSLATIONS = { [OPERATOR_NOTIFICATIONS_NAMESPACE]: OPERATOR_NOTIFICATIONS_RU, [RESEARCH_CYCLE_NAMESPACE]: RESEARCH_CYCLE_RU, [STATUS_TAG_NAMESPACE]: STATUS_TAG_RU, - [SUBSCRIPTION_ALLOWANCE_NAMESPACE]: SUBSCRIPTION_ALLOWANCE_RU, + [HARNESS_ALLOWANCE_NAMESPACE]: HARNESS_ALLOWANCE_RU, [TEAM_NAMESPACE]: TEAM_RU, [THEME_NAMESPACE]: THEME_RU, [VALUE_DISPLAY_NAMESPACE]: VALUE_DISPLAY_RU, diff --git a/apps/dashboard/src/investigation-dispatch/dispatch-panel.tsx b/apps/dashboard/src/investigation-dispatch/dispatch-panel.tsx index 63a3f1a7..b638c38b 100644 --- a/apps/dashboard/src/investigation-dispatch/dispatch-panel.tsx +++ b/apps/dashboard/src/investigation-dispatch/dispatch-panel.tsx @@ -41,7 +41,7 @@ interface DispatchEdit { } /** - * The two moves an operator has over one investigation's spending: point it at other subscriptions, + * The two moves an operator has over one investigation's spending: point it at other harnesses, * or take the lab's caps off it. They belong together because they are the same decision seen from * either side — this run matters more than the allowance, or it can wait on a harness that has some. */ diff --git a/apps/dashboard/src/investigation-dispatch/investigation-dispatch.i18n.ts b/apps/dashboard/src/investigation-dispatch/investigation-dispatch.i18n.ts index 111f3bd7..042e6f16 100644 --- a/apps/dashboard/src/investigation-dispatch/investigation-dispatch.i18n.ts +++ b/apps/dashboard/src/investigation-dispatch/investigation-dispatch.i18n.ts @@ -5,7 +5,7 @@ export const INVESTIGATION_DISPATCH_NAMESPACE = "investigation-dispatch" as cons export const INVESTIGATION_DISPATCH_EN = { title: "Dispatch", description: - "Which subscriptions this investigation may put agents on, and whether the lab's spend caps hold it. Changing either gives up the cycle in flight and starts the next one on the answer.", + "Which harnesses this investigation may put agents on, and whether the lab's spend caps hold it. Changing either gives up the cycle in flight and starts the next one on the answer.", harnesses: "Harnesses", harnessesHint: "It rotates through them in this order. At least one is needed to dispatch to.", @@ -13,7 +13,7 @@ export const INVESTIGATION_DISPATCH_EN = { pastCaps: "Spend past the caps", pastCapsHint: - "Runs this investigation on whatever the vendors will still serve, ignoring the caps set on the subscriptions. A vendor that has stopped serving still stops it.", + "Runs this investigation on whatever the vendors will still serve, ignoring the caps set on them. A vendor that has stopped serving still stops it.", save: "Save the dispatch", saving: "Saving", @@ -28,7 +28,7 @@ export const INVESTIGATION_DISPATCH_EN = { export const INVESTIGATION_DISPATCH_RU = { title: "Отправка работы", description: - "На какие подписки это исследование может ставить агентов и держат ли его ограничители лаборатории. Смена любого из двух прерывает текущий цикл, а следующий стартует уже с ответом лаборатории.", + "На какие оболочки это исследование может ставить агентов и держат ли его ограничители лаборатории. Смена любого из двух прерывает текущий цикл, а следующий стартует уже с ответом лаборатории.", harnesses: "Оболочки", harnessesHint: diff --git a/apps/dashboard/src/subscription-allowance/subscription-allowance-client.ts b/apps/dashboard/src/subscription-allowance/subscription-allowance-client.ts deleted file mode 100644 index b4d83286..00000000 --- a/apps/dashboard/src/subscription-allowance/subscription-allowance-client.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { SubscriptionAllowanceRosterSchema } from "@openlab/protocol/subscription-allowance/subscription-allowance.schema"; -import type { SubscriptionAllowanceRoster } from "@openlab/protocol/subscription-allowance/subscription-allowance.types"; - -const SUBSCRIPTION_ALLOWANCE_ENDPOINT = "/api/subscriptions"; - -/** Tells the daemon to put the reading it is holding aside and ask the vendors again. */ -const FRESH_READING_QUERY = "?fresh=1"; - -export const subscriptionAllowanceQueryKey = ["lab", "subscriptions"] as const; - -/** Whatever the daemon has. Asking faster than it reads the vendors returns the same answer. */ -export function fetchSubscriptionAllowance( - signal?: AbortSignal -): Promise { - return requestAllowance(SUBSCRIPTION_ALLOWANCE_ENDPOINT, signal); -} - -/** What the vendors say right now, which is the only thing a refresh can honestly mean. */ -export function refreshSubscriptionAllowance( - signal?: AbortSignal -): Promise { - return requestAllowance(`${SUBSCRIPTION_ALLOWANCE_ENDPOINT}${FRESH_READING_QUERY}`, signal); -} - -async function requestAllowance( - url: string, - signal?: AbortSignal -): Promise { - const response = await fetch(url, { - headers: { Accept: "application/json" }, - ...(signal ? { signal } : {}) - }); - - if (!response.ok) { - throw new Error(`Subscriptions endpoint returned ${response.status}.`); - } - - return SubscriptionAllowanceRosterSchema.parse(await response.json()); -} diff --git a/apps/landing/package.json b/apps/landing/package.json index c0aad831..e2d39e34 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -17,6 +17,7 @@ "format": "biome format --write ." }, "dependencies": { + "@openlab/protocol": "workspace:*", "astro": "7.1.6" }, "devDependencies": { diff --git a/apps/landing/src/harness-roster/harness-roster.astro b/apps/landing/src/harness-roster/harness-roster.astro index 2a9c7d89..658eafb7 100644 --- a/apps/landing/src/harness-roster/harness-roster.astro +++ b/apps/landing/src/harness-roster/harness-roster.astro @@ -1,8 +1,11 @@ --- +import { + AgentHarnessBilling, + HARNESS_BILLING +} from "@openlab/protocol/agents/agent-billing.const"; import SectionMarker from "#src/brand/section-marker.astro"; import { HARNESS, - HARNESS_BILLING, HARNESS_BILLING_LABEL, HARNESS_STRIP_REPEATS } from "#src/harness-roster/harness-roster.const"; @@ -10,10 +13,11 @@ import { /** * What the lab thinks with. A page selling autonomous research has to answer the money question * before anything else, so the roster leads with who pays for each harness rather than burying it: - * three run on a plan already bought, and the fourth says outright that it bills a wallet. + * three run on a plan already bought, and two say outright that they bill a wallet. Which is which + * is read from the runtime's own billing table, so the site cannot drift from what the lab does. * - * The moving strip above the roster is the same four names and carries no information the cells - * below do not, which is why it is hidden from assistive technology rather than read out twice. + * The moving strip above the roster is the same names and carries no information the cells below do + * not, which is why it is hidden from assistive technology rather than read out twice. */ const stripRoster = Array.from({ length: HARNESS_STRIP_REPEATS }, () => HARNESS).flat(); --- @@ -59,12 +63,12 @@ const stripRoster = Array.from({ length: HARNESS_STRIP_REPEATS }, () => HARNESS)

    - {HARNESS_BILLING_LABEL[harness.billing]} + {HARNESS_BILLING_LABEL[HARNESS_BILLING[harness.kind]]}

    {harness.note}

    diff --git a/apps/landing/src/harness-roster/harness-roster.const.ts b/apps/landing/src/harness-roster/harness-roster.const.ts index 3f1fe242..4e532e24 100644 --- a/apps/landing/src/harness-roster/harness-roster.const.ts +++ b/apps/landing/src/harness-roster/harness-roster.const.ts @@ -1,63 +1,64 @@ -/** - * How a harness is paid for. It is a named set rather than a boolean because the two are not - * opposites an operator can infer one from the other: a plan already bought costs the same whether - * the lab works for ten minutes or all night, and a wallet billed by the token does not. - */ -export const HARNESS_BILLING = { - SUBSCRIPTION: "subscription", - USAGE: "usage" -} as const; - -export type HarnessBilling = (typeof HARNESS_BILLING)[keyof typeof HARNESS_BILLING]; +import { AgentHarnessBilling } from "@openlab/protocol/agents/agent-billing.const"; +import { AgentHarnessKind } from "@openlab/protocol/agents/agent-execution.const"; /** * What each kind of billing is called on the page. The usage line is blunt on purpose: an operator - * hands over a key for exactly one of these four, and they should read that before they do, not - * after the first invoice. + * hands over a key for exactly one of these, and they should read that before they do, not after the + * first invoice. */ export const HARNESS_BILLING_LABEL = { - [HARNESS_BILLING.SUBSCRIPTION]: "Plan you already hold", - [HARNESS_BILLING.USAGE]: "Your wallet, by the token" -} as const satisfies Record; + [AgentHarnessBilling.SUBSCRIPTION]: "Plan you already hold", + [AgentHarnessBilling.USAGE]: "Your wallet, by the token" +} as const satisfies Record; /** * Every harness the lab dispatches agents to, each named the way its vendor names it. Two of them * are driven through another vendor's CLI, and saying which keeps an operator from reading them as * a second subscription to that vendor. The `command` is the executable `openlab doctor` looks for, * so it is the literal name on `PATH` and never a prettier spelling of it. + * + * An entry names the harness it stands for rather than restating how that harness is paid for. The + * lab already holds one billing table, and this is the page an operator decides what to install + * from: a site that disagreed with the runtime about which harness spends money would be the worst + * place in the product to be wrong. */ export const HARNESS = [ { name: "Codex", command: "codex", - billing: HARNESS_BILLING.SUBSCRIPTION, + kind: AgentHarnessKind.CODEX, note: "The Codex CLI, on the subscription it is already signed in to." }, { name: "Claude", command: "claude", - billing: HARNESS_BILLING.SUBSCRIPTION, + kind: AgentHarnessKind.CLAUDE, note: "The Claude Code CLI, on the subscription it is already signed in to." }, { name: "GLM", command: "glm", - billing: HARNESS_BILLING.SUBSCRIPTION, + kind: AgentHarnessKind.GLM, note: "A Z.ai coding plan, served over an Anthropic-compatible API and driven through the Claude CLI." }, { name: "DeepSeek", command: "deepseek", - billing: HARNESS_BILLING.USAGE, + kind: AgentHarnessKind.DEEPSEEK, note: "A key you hand over, driven through the Codex CLI. Nothing caps it, and its setup card says so first." }, { name: "Muse Code", command: "muse", - billing: HARNESS_BILLING.USAGE, + kind: AgentHarnessKind.MUSE, note: "A Meta account, metered the same way and publishing no balance at all, so the lab cannot say what a run cost until Meta does." } -] as const; +] as const satisfies readonly { + readonly name: string; + readonly command: string; + readonly kind: AgentHarnessKind; + readonly note: string; +}[]; /** * How many times the roster is written into one half of the moving strip. Four keeps the line full diff --git a/packages/harness/package.json b/packages/harness/package.json index 2230c58f..49a87526 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -28,21 +28,21 @@ "./harness-error": "./dist/cli-execution/harness-error.js", "./harness-error.const": "./dist/cli-execution/harness-error.const.js", "./harness-event-parser.types": "./dist/agent-harness/harness-event-parser.types.js", - "./harness-run-artifacts.const": "./dist/subscription-cli-harness/harness-run-artifacts.const.js", - "./harness-run-outcome": "./dist/subscription-cli-harness/harness-run-outcome.js", - "./harness-run-outcome.types": "./dist/subscription-cli-harness/harness-run-outcome.types.js", + "./harness-run-artifacts.const": "./dist/cli-agent-harness/harness-run-artifacts.const.js", + "./harness-run-outcome": "./dist/cli-agent-harness/harness-run-outcome.js", + "./harness-run-outcome.types": "./dist/cli-agent-harness/harness-run-outcome.types.js", "./harness-event.const": "./dist/agent-harness/harness-event.const.js", "./harness-event.types": "./dist/agent-harness/harness-event.types.js", "./muse-cli.const": "./dist/muse-cli/muse-cli.const.js", "./muse-harness": "./dist/muse-cli/muse-harness.js", - "./response-schema": "./dist/subscription-cli-harness/response-schema.js", - "./subscription-cli-harness": "./dist/subscription-cli-harness/subscription-cli-harness.js", - "./subscription-cli-harness.types": "./dist/subscription-cli-harness/subscription-cli-harness.types.js", - "./subscription-allowance": "./dist/subscription-allowance/subscription-allowance.js", - "./subscription-allowance.types": "./dist/subscription-allowance/subscription-allowance.types.js", - "./subscription-environment": "./dist/cli-execution/subscription-environment.js", - "./subscription-usage-limit": "./dist/subscription-usage-limit/subscription-usage-limit.js", - "./subscription-usage-limit.const": "./dist/subscription-usage-limit/subscription-usage-limit.const.js" + "./response-schema": "./dist/cli-agent-harness/response-schema.js", + "./cli-agent-harness": "./dist/cli-agent-harness/cli-agent-harness.js", + "./cli-agent-harness.types": "./dist/cli-agent-harness/cli-agent-harness.types.js", + "./harness-allowance": "./dist/harness-allowance/harness-allowance.js", + "./harness-allowance.types": "./dist/harness-allowance/harness-allowance.types.js", + "./harness-environment": "./dist/cli-execution/harness-environment.js", + "./spent-allowance": "./dist/spent-allowance/spent-allowance.js", + "./spent-allowance.const": "./dist/spent-allowance/spent-allowance.const.js" }, "imports": { "#src/*": "./dist/*.js" @@ -68,21 +68,21 @@ "./harness-error": "./src/cli-execution/harness-error.ts", "./harness-error.const": "./src/cli-execution/harness-error.const.ts", "./harness-event-parser.types": "./src/agent-harness/harness-event-parser.types.ts", - "./harness-run-artifacts.const": "./src/subscription-cli-harness/harness-run-artifacts.const.ts", - "./harness-run-outcome": "./src/subscription-cli-harness/harness-run-outcome.ts", - "./harness-run-outcome.types": "./src/subscription-cli-harness/harness-run-outcome.types.ts", + "./harness-run-artifacts.const": "./src/cli-agent-harness/harness-run-artifacts.const.ts", + "./harness-run-outcome": "./src/cli-agent-harness/harness-run-outcome.ts", + "./harness-run-outcome.types": "./src/cli-agent-harness/harness-run-outcome.types.ts", "./harness-event.const": "./src/agent-harness/harness-event.const.ts", "./harness-event.types": "./src/agent-harness/harness-event.types.ts", "./muse-cli.const": "./src/muse-cli/muse-cli.const.ts", "./muse-harness": "./src/muse-cli/muse-harness.ts", - "./response-schema": "./src/subscription-cli-harness/response-schema.ts", - "./subscription-cli-harness": "./src/subscription-cli-harness/subscription-cli-harness.ts", - "./subscription-cli-harness.types": "./src/subscription-cli-harness/subscription-cli-harness.types.ts", - "./subscription-allowance": "./src/subscription-allowance/subscription-allowance.ts", - "./subscription-allowance.types": "./src/subscription-allowance/subscription-allowance.types.ts", - "./subscription-environment": "./src/cli-execution/subscription-environment.ts", - "./subscription-usage-limit": "./src/subscription-usage-limit/subscription-usage-limit.ts", - "./subscription-usage-limit.const": "./src/subscription-usage-limit/subscription-usage-limit.const.ts" + "./response-schema": "./src/cli-agent-harness/response-schema.ts", + "./cli-agent-harness": "./src/cli-agent-harness/cli-agent-harness.ts", + "./cli-agent-harness.types": "./src/cli-agent-harness/cli-agent-harness.types.ts", + "./harness-allowance": "./src/harness-allowance/harness-allowance.ts", + "./harness-allowance.types": "./src/harness-allowance/harness-allowance.types.ts", + "./harness-environment": "./src/cli-execution/harness-environment.ts", + "./spent-allowance": "./src/spent-allowance/spent-allowance.ts", + "./spent-allowance.const": "./src/spent-allowance/spent-allowance.const.ts" }, "imports": { "#src/*": "./src/*.ts" @@ -95,6 +95,7 @@ "format": "biome format --write ." }, "dependencies": { + "@openlab/protocol": "workspace:*", "execa": "10.0.1", "write-file-atomic": "8.0.0", "zod": "4.4.3" diff --git a/packages/harness/src/claude-cli/claude-harness.test.ts b/packages/harness/src/claude-cli/claude-harness.test.ts index 6beb402a..bd5e3177 100644 --- a/packages/harness/src/claude-cli/claude-harness.test.ts +++ b/packages/harness/src/claude-cli/claude-harness.test.ts @@ -9,20 +9,20 @@ import { import { HarnessEventTypes, HarnessToolPhases } from "#src/agent-harness/harness-event.const"; import { ClaudePermissionModes, ClaudeSessionDefaults } from "#src/claude-cli/claude-cli.const"; import { ClaudeHarness } from "#src/claude-cli/claude-harness"; -import { - captureSuccess, - FakeHarnessProcessRunner, - streamSuccess -} from "#src/cli-execution/cli-process-runner.fixture"; -import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; -import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; import { AnswerJsonSchema, answerSchema, harnessRequest, lastCompleted, removeHarnessRunDirectories -} from "#src/subscription-cli-harness/harness-run.fixture"; +} from "#src/cli-agent-harness/harness-run.fixture"; +import { + captureSuccess, + FakeHarnessProcessRunner, + streamSuccess +} from "#src/cli-execution/cli-process-runner.fixture"; +import { testEnvironment } from "#src/cli-execution/harness-environment.fixture"; +import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; const ClaudeTestNativeEventTypes = { SYSTEM: "system", diff --git a/packages/harness/src/claude-cli/claude-harness.ts b/packages/harness/src/claude-cli/claude-harness.ts index 7b0dc960..83f553eb 100644 --- a/packages/harness/src/claude-cli/claude-harness.ts +++ b/packages/harness/src/claude-cli/claude-harness.ts @@ -16,15 +16,15 @@ import { claudeAuthenticationCommand, claudeRunArguments } from "#src/claude-cli/claude-run-arguments"; +import { CliAgentHarness } from "#src/cli-agent-harness/cli-agent-harness"; +import type { + CliHarnessOptions, + HarnessCommand, + HarnessRunPaths +} from "#src/cli-agent-harness/cli-agent-harness.types"; import type { HarnessCaptureResult } from "#src/cli-execution/cli-process-runner.types"; import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; import { HarnessCapabilityGaps } from "#src/cli-execution/harness-error.const"; -import { SubscriptionCliHarness } from "#src/subscription-cli-harness/subscription-cli-harness"; -import type { - HarnessCommand, - HarnessRunPaths, - SubscriptionHarnessOptions -} from "#src/subscription-cli-harness/subscription-cli-harness.types"; const ClaudeAuthStatusSchema = z.looseObject({ loggedIn: z.literal(true), @@ -33,10 +33,10 @@ const ClaudeAuthStatusSchema = z.looseObject({ subscriptionType: z.string().trim().min(1) }); -export class ClaudeHarness extends SubscriptionCliHarness { +export class ClaudeHarness extends CliAgentHarness { readonly kind = HarnessKinds.CLAUDE; - constructor(options: SubscriptionHarnessOptions = {}) { + constructor(options: CliHarnessOptions = {}) { super(CLAUDE_BINARY, options); } @@ -58,7 +58,7 @@ export class ClaudeHarness extends SubscriptionCliHarness { } catch (error) { throw new HarnessCapabilityError( this.kind, - HarnessCapabilityGaps.SUBSCRIPTION, + HarnessCapabilityGaps.CREDENTIAL, "Claude CLI is not authenticated through a claude.ai subscription", { need: "Claude CLI logged in through an active claude.ai subscription", diff --git a/packages/harness/src/claude-cli/claude-run-arguments.ts b/packages/harness/src/claude-cli/claude-run-arguments.ts index 9042995d..e0992833 100644 --- a/packages/harness/src/claude-cli/claude-run-arguments.ts +++ b/packages/harness/src/claude-cli/claude-run-arguments.ts @@ -1,7 +1,7 @@ import { HarnessExecutionProfiles } from "#src/agent-harness/agent-harness.const"; import type { HarnessRunRequest, HarnessSession } from "#src/agent-harness/agent-harness.types"; import { ClaudeOutputFormats, ClaudePermissionModes } from "#src/claude-cli/claude-cli.const"; -import { responseJsonSchema } from "#src/subscription-cli-harness/response-schema"; +import { responseJsonSchema } from "#src/cli-agent-harness/response-schema"; /** * The headless command line for one Claude CLI run. Every harness that drives this binary shares it, diff --git a/packages/harness/src/subscription-cli-harness/subscription-cli-harness.test.ts b/packages/harness/src/cli-agent-harness/cli-agent-harness.test.ts similarity index 97% rename from packages/harness/src/subscription-cli-harness/subscription-cli-harness.test.ts rename to packages/harness/src/cli-agent-harness/cli-agent-harness.test.ts index e005e2ec..9a1ce5ca 100644 --- a/packages/harness/src/subscription-cli-harness/subscription-cli-harness.test.ts +++ b/packages/harness/src/cli-agent-harness/cli-agent-harness.test.ts @@ -4,6 +4,12 @@ import { afterEach, describe, expect, it } from "vitest"; import { HarnessRunStatuses } from "#src/agent-harness/agent-harness.const"; import { HarnessEventTypes } from "#src/agent-harness/harness-event.const"; import type { HarnessEvent } from "#src/agent-harness/harness-event.types"; +import { + harnessRequest, + lastCompleted, + removeHarnessRunDirectories, + TestTimeoutMilliseconds +} from "#src/cli-agent-harness/harness-run.fixture"; import { captureCancellationOnAbort, captureSuccess, @@ -13,25 +19,19 @@ import { TestProcessSignals } from "#src/cli-execution/cli-process-runner.fixture"; import type { HarnessProcessExit } from "#src/cli-execution/cli-process-runner.types"; +import { testEnvironment } from "#src/cli-execution/harness-environment.fixture"; import { HarnessAbortedError } from "#src/cli-execution/harness-error"; import { HarnessErrorCodes, HarnessTimeoutPhases } from "#src/cli-execution/harness-error.const"; -import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; import { CodexTestCliValues, CodexTestLoginMarkers, CodexTestNativeEventTypes } from "#src/codex-cli/codex-cli.fixture"; import { CodexHarness } from "#src/codex-cli/codex-harness"; -import { - harnessRequest, - lastCompleted, - removeHarnessRunDirectories, - TestTimeoutMilliseconds -} from "#src/subscription-cli-harness/harness-run.fixture"; afterEach(removeHarnessRunDirectories); -describe("subscription CLI harness run lifecycle", () => { +describe("CLI agent harness run lifecycle", () => { it("honors an already-aborted signal before invoking a CLI", async () => { const runner = new FakeHarnessProcessRunner([]); const harness = new CodexHarness({ runner, environment: testEnvironment() }); diff --git a/packages/harness/src/subscription-cli-harness/subscription-cli-harness.ts b/packages/harness/src/cli-agent-harness/cli-agent-harness.ts similarity index 88% rename from packages/harness/src/subscription-cli-harness/subscription-cli-harness.ts rename to packages/harness/src/cli-agent-harness/cli-agent-harness.ts index 4c0baf9d..120d0da6 100644 --- a/packages/harness/src/subscription-cli-harness/subscription-cli-harness.ts +++ b/packages/harness/src/cli-agent-harness/cli-agent-harness.ts @@ -18,65 +18,62 @@ import type { import { HarnessDiagnosticLevels, HarnessEventTypes } from "#src/agent-harness/harness-event.const"; import type { HarnessCompletedEvent, HarnessEvent } from "#src/agent-harness/harness-event.types"; import type { HarnessEventParser } from "#src/agent-harness/harness-event-parser.types"; -import { ExecaHarnessProcessRunner } from "#src/cli-execution/cli-process-runner"; import type { - HarnessCaptureResult, - HarnessProcessExit, - HarnessProcessRunner -} from "#src/cli-execution/cli-process-runner.types"; -import { - HarnessCapabilityError, - HarnessProtocolError, - HarnessTimeoutError -} from "#src/cli-execution/harness-error"; -import { HarnessTimeoutPhases } from "#src/cli-execution/harness-error.const"; -import { - removedHarnessEnvironmentVariables, - sanitizeHarnessEnvironment -} from "#src/cli-execution/subscription-environment"; + CliHarnessOptions, + HarnessCommand, + HarnessRunPaths +} from "#src/cli-agent-harness/cli-agent-harness.types"; +import { runHarnessPreflight } from "#src/cli-agent-harness/harness-preflight"; import { closeRunFiles, collectArtifacts, createRunFiles, writeStderrArtifact -} from "#src/subscription-cli-harness/harness-run-artifacts"; +} from "#src/cli-agent-harness/harness-run-artifacts"; import { appendEvent, appendNativeEventLine, parseNativeEvent, stampEvent -} from "#src/subscription-cli-harness/harness-run-events"; +} from "#src/cli-agent-harness/harness-run-events"; import { writeFinishedRunManifest, writeStartedRunManifest -} from "#src/subscription-cli-harness/harness-run-manifest"; -import type { FinishedRunManifest } from "#src/subscription-cli-harness/harness-run-manifest.types"; -import { validateHarnessRunRequest } from "#src/subscription-cli-harness/harness-run-request-validation"; -import { determineRunStatus } from "#src/subscription-cli-harness/harness-run-status"; +} from "#src/cli-agent-harness/harness-run-manifest"; +import type { FinishedRunManifest } from "#src/cli-agent-harness/harness-run-manifest.types"; +import { validateHarnessRunRequest } from "#src/cli-agent-harness/harness-run-request-validation"; +import { determineRunStatus } from "#src/cli-agent-harness/harness-run-status"; import { createWatchdogSignal, validateTimeoutMilliseconds -} from "#src/subscription-cli-harness/harness-run-watchdog"; -import { parseStructuredOutput } from "#src/subscription-cli-harness/response-schema"; +} from "#src/cli-agent-harness/harness-run-watchdog"; +import { parseStructuredOutput } from "#src/cli-agent-harness/response-schema"; +import { ExecaHarnessProcessRunner } from "#src/cli-execution/cli-process-runner"; import type { - HarnessCommand, - HarnessRunPaths, - SubscriptionHarnessOptions -} from "#src/subscription-cli-harness/subscription-cli-harness.types"; -import { runSubscriptionPreflight } from "#src/subscription-cli-harness/subscription-preflight"; + HarnessCaptureResult, + HarnessProcessExit, + HarnessProcessRunner +} from "#src/cli-execution/cli-process-runner.types"; +import { + removedHarnessEnvironmentVariables, + sanitizeHarnessEnvironment +} from "#src/cli-execution/harness-environment"; import { - subscriptionUsageLimitError, - subscriptionUsageLimitMessage -} from "#src/subscription-usage-limit/subscription-usage-limit"; + HarnessCapabilityError, + HarnessProtocolError, + HarnessTimeoutError +} from "#src/cli-execution/harness-error"; +import { HarnessTimeoutPhases } from "#src/cli-execution/harness-error.const"; +import { spentAllowanceError, spentAllowanceMessage } from "#src/spent-allowance/spent-allowance"; -export abstract class SubscriptionCliHarness implements AgentHarness { +export abstract class CliAgentHarness implements AgentHarness { abstract readonly kind: HarnessKind; readonly #binary: string; readonly #runner: HarnessProcessRunner; readonly #sourceEnvironment: Readonly; readonly #preflightTimeoutMs: number; - protected constructor(defaultBinary: string, options: SubscriptionHarnessOptions) { + protected constructor(defaultBinary: string, options: CliHarnessOptions) { this.#binary = options.binary ?? defaultBinary; this.#runner = options.runner ?? new ExecaHarnessProcessRunner(); this.#sourceEnvironment = options.environment ?? process.env; @@ -117,7 +114,7 @@ export abstract class SubscriptionCliHarness implements AgentHarness { /** * The environment the CLI runs with, once every inherited provider credential has been stripped. - * A harness whose subscription credential lives outside the CLI's own login adds it back here, + * A harness whose credential lives outside the CLI's own login adds it back here, * and nowhere else, so the run can never inherit a stray endpoint from the operator's shell. */ protected extendEnvironment( @@ -146,7 +143,7 @@ export abstract class SubscriptionCliHarness implements AgentHarness { cwd: string, signal?: AbortSignal ): Promise { - return runSubscriptionPreflight( + return runHarnessPreflight( { kind: this.kind, binary: this.#binary, @@ -205,7 +202,7 @@ export abstract class SubscriptionCliHarness implements AgentHarness { let sequence = 0; let streamCompleted = false; let semanticError: Error | undefined; - let usageLimitMessage: string | undefined; + let spentAllowance: string | undefined; let structuredOutput: unknown; let hasStructuredOutput = false; const tailEvents: HarnessEvent[] = []; @@ -242,7 +239,7 @@ export abstract class SubscriptionCliHarness implements AgentHarness { await appendNativeEventLine(files.nativeEventsHandle, line); const nativeEvent = parseNativeEvent(this.kind, line); for (const parsedEvent of parser.parse(nativeEvent)) { - usageLimitMessage ??= subscriptionUsageLimitMessage(parsedEvent); + spentAllowance ??= spentAllowanceMessage(parsedEvent); const event = stampEvent(parsedEvent, ++sequence, this.kind, parser.sessionId); await appendEvent(files.eventsHandle, event); yield event; @@ -254,15 +251,15 @@ export abstract class SubscriptionCliHarness implements AgentHarness { throw new HarnessTimeoutError(this.kind, HarnessTimeoutPhases.RUN, timeoutMs); } /** - * A CLI that stopped because its subscription allowance is spent never reaches the end - * of its protocol, so every later check would report a symptom: a missing final message, - * an absent structured output. The refusal itself is the finding. + * A CLI that stopped because the vendor will no longer serve its account never reaches + * the end of its protocol, so every later check would report a symptom: a missing final + * message, an absent structured output. The refusal itself is the finding. */ if ( - usageLimitMessage !== undefined && + spentAllowance !== undefined && (processExit.failed || processExit.exitCode !== 0) ) { - throw subscriptionUsageLimitError(this.kind, usageLimitMessage); + throw spentAllowanceError(this.kind, spentAllowance); } for (const parsedEvent of parser.finish()) { const event = stampEvent(parsedEvent, ++sequence, this.kind, parser.sessionId); diff --git a/packages/harness/src/subscription-cli-harness/subscription-cli-harness.types.ts b/packages/harness/src/cli-agent-harness/cli-agent-harness.types.ts similarity index 94% rename from packages/harness/src/subscription-cli-harness/subscription-cli-harness.types.ts rename to packages/harness/src/cli-agent-harness/cli-agent-harness.types.ts index 4a2b39cc..46a91464 100644 --- a/packages/harness/src/subscription-cli-harness/subscription-cli-harness.types.ts +++ b/packages/harness/src/cli-agent-harness/cli-agent-harness.types.ts @@ -1,6 +1,6 @@ import type { HarnessProcessRunner } from "#src/cli-execution/cli-process-runner.types"; -export interface SubscriptionHarnessOptions { +export interface CliHarnessOptions { readonly binary?: string; readonly runner?: HarnessProcessRunner; readonly environment?: Readonly; diff --git a/packages/harness/src/subscription-cli-harness/subscription-preflight.test.ts b/packages/harness/src/cli-agent-harness/harness-preflight.test.ts similarity index 52% rename from packages/harness/src/subscription-cli-harness/subscription-preflight.test.ts rename to packages/harness/src/cli-agent-harness/harness-preflight.test.ts index 46eb4ced..b3347e2b 100644 --- a/packages/harness/src/subscription-cli-harness/subscription-preflight.test.ts +++ b/packages/harness/src/cli-agent-harness/harness-preflight.test.ts @@ -3,11 +3,12 @@ import { captureSuccess, FakeHarnessProcessRunner } from "#src/cli-execution/cli-process-runner.fixture"; +import { testEnvironment } from "#src/cli-execution/harness-environment.fixture"; import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; import { HarnessCapabilityGaps } from "#src/cli-execution/harness-error.const"; -import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; import { CodexTestCliValues, CodexTestLoginMarkers } from "#src/codex-cli/codex-cli.fixture"; import { CodexHarness } from "#src/codex-cli/codex-harness"; +import { DeepseekHarness } from "#src/deepseek-cli/deepseek-harness"; function preflightRefusal(...captures: readonly ReturnType[]) { const harness = new CodexHarness({ @@ -25,7 +26,7 @@ function preflightRefusal(...captures: readonly ReturnType { +describe("runHarnessPreflight", () => { it("reports a CLI that is not there as a missing installation", async () => { const refusal = await preflightRefusal({ ...captureSuccess(""), @@ -45,6 +46,40 @@ describe("runSubscriptionPreflight", () => { }); expect(refusal).toBeInstanceOf(HarnessCapabilityError); - expect(refusal).toMatchObject({ gap: HarnessCapabilityGaps.SUBSCRIPTION }); + expect(refusal).toMatchObject({ gap: HarnessCapabilityGaps.CREDENTIAL }); + }); + + /** + * This refusal is the one sentence all five harnesses share, and two of them have no subscription + * to be asked for: DeepSeek authenticates with a key the operator hands over and Muse Code with a + * Meta login. Asking either to sign in to a product subscription sends them after something that + * does not exist, and the capability card shows the sentence verbatim. + */ + it("asks a token-billed harness for its CLI rather than for a subscription", async () => { + const harness = new DeepseekHarness({ + runner: new FakeHarnessProcessRunner([ + { ...captureSuccess(""), failed: true, exitCode: null, error: "spawn codex ENOENT" } + ]), + environment: testEnvironment(), + resolveWallet: () => + Promise.resolve({ apiKey: "sk-test", available: true, balance: "1.00 USD" }) + }); + + const refusal = await harness.preflight().then( + () => undefined, + (error: unknown) => error + ); + + expect(refusal).toBeInstanceOf(HarnessCapabilityError); + const capability = (refusal as HarnessCapabilityError).capabilityRequest; + expect(capability.provisioningHint).toContain("Install"); + for (const sentence of [ + (refusal as HarnessCapabilityError).message, + capability.need, + capability.reason, + capability.provisioningHint + ]) { + expect(sentence).not.toMatch(/subscription/iu); + } }); }); diff --git a/packages/harness/src/subscription-cli-harness/subscription-preflight.ts b/packages/harness/src/cli-agent-harness/harness-preflight.ts similarity index 77% rename from packages/harness/src/subscription-cli-harness/subscription-preflight.ts rename to packages/harness/src/cli-agent-harness/harness-preflight.ts index d9bc1ce6..ac117f23 100644 --- a/packages/harness/src/subscription-cli-harness/subscription-preflight.ts +++ b/packages/harness/src/cli-agent-harness/harness-preflight.ts @@ -1,5 +1,8 @@ import type { HarnessKind } from "#src/agent-harness/agent-harness.const"; import type { HarnessPreflight } from "#src/agent-harness/agent-harness.types"; +import type { HarnessPreflightRequest } from "#src/cli-agent-harness/harness-preflight.types"; +import { createWatchdogSignal } from "#src/cli-agent-harness/harness-run-watchdog"; +import type { WatchdogSignal } from "#src/cli-agent-harness/harness-run-watchdog.types"; import type { HarnessCaptureResult } from "#src/cli-execution/cli-process-runner.types"; import { HarnessAbortedError, @@ -10,12 +13,9 @@ import { HarnessCapabilityGaps, HarnessTimeoutPhases } from "#src/cli-execution/harness-error.const"; -import { createWatchdogSignal } from "#src/subscription-cli-harness/harness-run-watchdog"; -import type { WatchdogSignal } from "#src/subscription-cli-harness/harness-run-watchdog.types"; -import type { SubscriptionPreflightRequest } from "#src/subscription-cli-harness/subscription-preflight.types"; -export async function runSubscriptionPreflight( - request: SubscriptionPreflightRequest, +export async function runHarnessPreflight( + request: HarnessPreflightRequest, signal?: AbortSignal ): Promise { throwIfAborted(request.kind, signal); @@ -83,7 +83,7 @@ function throwIfAborted( } function throwIfPreflightStopped( - request: SubscriptionPreflightRequest, + request: HarnessPreflightRequest, watchdog: WatchdogSignal, signal: AbortSignal | undefined, result: HarnessCaptureResult @@ -100,18 +100,21 @@ function throwIfPreflightStopped( /** * The version command is what decides whether the CLI is there at all, so everything that goes wrong - * before the authentication check is answered is a missing installation. Whether the login behind it - * is the right one is the authentication check's to say, and it says so with its own gap. + * before the authentication check is answered is a missing installation. Whether the credential + * behind it is the right one is the authentication check's to say, and it says so with its own gap + * and in its own words — which is why nothing here names a subscription. Three harnesses are signed + * in interactively, one is handed a key and one logs in to Meta, and this sentence is the one all + * five get. */ function unavailableCapability(kind: HarnessKind, cause: unknown): HarnessCapabilityError { return new HarnessCapabilityError( kind, HarnessCapabilityGaps.INSTALLATION, - `${kind} CLI is unavailable or cannot report subscription authentication`, + `${kind} CLI is unavailable or cannot report its version`, { - need: `${kind} CLI with an active product subscription login`, - reason: `The ${kind} agent cannot run without proven subscription authentication`, - provisioningHint: `Install ${kind}, log in interactively with the product subscription, then retry` + need: `The ${kind} CLI on this machine`, + reason: `The ${kind} agent cannot run until its own CLI answers`, + provisioningHint: `Install the ${kind} CLI, make sure it is on PATH, then retry` }, { cause } ); diff --git a/packages/harness/src/subscription-cli-harness/subscription-preflight.types.ts b/packages/harness/src/cli-agent-harness/harness-preflight.types.ts similarity index 93% rename from packages/harness/src/subscription-cli-harness/subscription-preflight.types.ts rename to packages/harness/src/cli-agent-harness/harness-preflight.types.ts index 03b65718..86d8b0ef 100644 --- a/packages/harness/src/subscription-cli-harness/subscription-preflight.types.ts +++ b/packages/harness/src/cli-agent-harness/harness-preflight.types.ts @@ -5,7 +5,7 @@ import type { HarnessProcessRunner } from "#src/cli-execution/cli-process-runner.types"; -export interface SubscriptionPreflightRequest { +export interface HarnessPreflightRequest { readonly kind: HarnessKind; readonly binary: string; readonly runner: HarnessProcessRunner; diff --git a/packages/harness/src/subscription-cli-harness/harness-run-artifacts.const.ts b/packages/harness/src/cli-agent-harness/harness-run-artifacts.const.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/harness-run-artifacts.const.ts rename to packages/harness/src/cli-agent-harness/harness-run-artifacts.const.ts diff --git a/packages/harness/src/subscription-cli-harness/harness-run-artifacts.ts b/packages/harness/src/cli-agent-harness/harness-run-artifacts.ts similarity index 95% rename from packages/harness/src/subscription-cli-harness/harness-run-artifacts.ts rename to packages/harness/src/cli-agent-harness/harness-run-artifacts.ts index 8be7a345..c61e01d4 100644 --- a/packages/harness/src/subscription-cli-harness/harness-run-artifacts.ts +++ b/packages/harness/src/cli-agent-harness/harness-run-artifacts.ts @@ -4,16 +4,16 @@ import { mkdir, open, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import type { HarnessKind } from "#src/agent-harness/agent-harness.const"; import type { HarnessArtifact, HarnessRunRequest } from "#src/agent-harness/agent-harness.types"; -import { HarnessRequestError } from "#src/cli-execution/harness-error"; import { HARNESS_ARTIFACT_FILE_MODE, HarnessArtifactFiles -} from "#src/subscription-cli-harness/harness-run-artifacts.const"; +} from "#src/cli-agent-harness/harness-run-artifacts.const"; import type { HarnessRunFiles, NonManifestArtifacts -} from "#src/subscription-cli-harness/harness-run-artifacts.types"; -import { responseJsonSchema } from "#src/subscription-cli-harness/response-schema"; +} from "#src/cli-agent-harness/harness-run-artifacts.types"; +import { responseJsonSchema } from "#src/cli-agent-harness/response-schema"; +import { HarnessRequestError } from "#src/cli-execution/harness-error"; /** * `promptText` is what the CLI will actually be given, which is not always what the caller wrote: a diff --git a/packages/harness/src/subscription-cli-harness/harness-run-artifacts.types.ts b/packages/harness/src/cli-agent-harness/harness-run-artifacts.types.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/harness-run-artifacts.types.ts rename to packages/harness/src/cli-agent-harness/harness-run-artifacts.types.ts diff --git a/packages/harness/src/subscription-cli-harness/harness-run-events.ts b/packages/harness/src/cli-agent-harness/harness-run-events.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/harness-run-events.ts rename to packages/harness/src/cli-agent-harness/harness-run-events.ts diff --git a/packages/harness/src/subscription-cli-harness/harness-run-manifest.const.ts b/packages/harness/src/cli-agent-harness/harness-run-manifest.const.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/harness-run-manifest.const.ts rename to packages/harness/src/cli-agent-harness/harness-run-manifest.const.ts diff --git a/packages/harness/src/subscription-cli-harness/harness-run-manifest.ts b/packages/harness/src/cli-agent-harness/harness-run-manifest.ts similarity index 76% rename from packages/harness/src/subscription-cli-harness/harness-run-manifest.ts rename to packages/harness/src/cli-agent-harness/harness-run-manifest.ts index f93fe14a..d2a4091f 100644 --- a/packages/harness/src/subscription-cli-harness/harness-run-manifest.ts +++ b/packages/harness/src/cli-agent-harness/harness-run-manifest.ts @@ -1,13 +1,13 @@ import writeFileAtomic from "write-file-atomic"; import { HarnessRunStatuses } from "#src/agent-harness/agent-harness.const"; import type { HarnessArtifact } from "#src/agent-harness/agent-harness.types"; -import { hashArtifact } from "#src/subscription-cli-harness/harness-run-artifacts"; -import { HARNESS_ARTIFACT_FILE_MODE } from "#src/subscription-cli-harness/harness-run-artifacts.const"; -import { HARNESS_MANIFEST_SCHEMA_VERSION } from "#src/subscription-cli-harness/harness-run-manifest.const"; +import { hashArtifact } from "#src/cli-agent-harness/harness-run-artifacts"; +import { HARNESS_ARTIFACT_FILE_MODE } from "#src/cli-agent-harness/harness-run-artifacts.const"; +import { HARNESS_MANIFEST_SCHEMA_VERSION } from "#src/cli-agent-harness/harness-run-manifest.const"; import type { FinishedRunManifest, StartedRunManifest -} from "#src/subscription-cli-harness/harness-run-manifest.types"; +} from "#src/cli-agent-harness/harness-run-manifest.types"; export async function writeStartedRunManifest( manifestPath: string, diff --git a/packages/harness/src/subscription-cli-harness/harness-run-manifest.types.ts b/packages/harness/src/cli-agent-harness/harness-run-manifest.types.ts similarity index 92% rename from packages/harness/src/subscription-cli-harness/harness-run-manifest.types.ts rename to packages/harness/src/cli-agent-harness/harness-run-manifest.types.ts index 94d2b2b6..83c9bcf6 100644 --- a/packages/harness/src/subscription-cli-harness/harness-run-manifest.types.ts +++ b/packages/harness/src/cli-agent-harness/harness-run-manifest.types.ts @@ -5,7 +5,7 @@ import type { HarnessCommandRecord, HarnessSession } from "#src/agent-harness/agent-harness.types"; -import type { NonManifestArtifacts } from "#src/subscription-cli-harness/harness-run-artifacts.types"; +import type { NonManifestArtifacts } from "#src/cli-agent-harness/harness-run-artifacts.types"; export interface StartedRunManifest { readonly kind: HarnessKind; diff --git a/packages/harness/src/subscription-cli-harness/harness-run-outcome.test.ts b/packages/harness/src/cli-agent-harness/harness-run-outcome.test.ts similarity index 87% rename from packages/harness/src/subscription-cli-harness/harness-run-outcome.test.ts rename to packages/harness/src/cli-agent-harness/harness-run-outcome.test.ts index 433558f8..a4900987 100644 --- a/packages/harness/src/subscription-cli-harness/harness-run-outcome.test.ts +++ b/packages/harness/src/cli-agent-harness/harness-run-outcome.test.ts @@ -8,27 +8,27 @@ import { HarnessKinds, HarnessTimeoutMilliseconds } from "#src/agent-harness/agent-harness.const"; +import { + harnessRequest, + lastCompleted, + removeHarnessRunDirectories, + TestTimeoutMilliseconds +} from "#src/cli-agent-harness/harness-run.fixture"; +import { HarnessArtifactFiles } from "#src/cli-agent-harness/harness-run-artifacts.const"; +import { writeStartedRunManifest } from "#src/cli-agent-harness/harness-run-manifest"; +import { readFinishedRunOutcome } from "#src/cli-agent-harness/harness-run-outcome"; import { captureSuccess, FakeHarnessProcessRunner, streamUntilAbort } from "#src/cli-execution/cli-process-runner.fixture"; -import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; +import { testEnvironment } from "#src/cli-execution/harness-environment.fixture"; import { CodexTestCliValues, CodexTestLoginMarkers, CodexTestNativeEventTypes } from "#src/codex-cli/codex-cli.fixture"; import { CodexHarness } from "#src/codex-cli/codex-harness"; -import { - harnessRequest, - lastCompleted, - removeHarnessRunDirectories, - TestTimeoutMilliseconds -} from "#src/subscription-cli-harness/harness-run.fixture"; -import { HarnessArtifactFiles } from "#src/subscription-cli-harness/harness-run-artifacts.const"; -import { writeStartedRunManifest } from "#src/subscription-cli-harness/harness-run-manifest"; -import { readFinishedRunOutcome } from "#src/subscription-cli-harness/harness-run-outcome"; afterEach(removeHarnessRunDirectories); diff --git a/packages/harness/src/subscription-cli-harness/harness-run-outcome.ts b/packages/harness/src/cli-agent-harness/harness-run-outcome.ts similarity index 90% rename from packages/harness/src/subscription-cli-harness/harness-run-outcome.ts rename to packages/harness/src/cli-agent-harness/harness-run-outcome.ts index 3f5db739..5c3ab966 100644 --- a/packages/harness/src/subscription-cli-harness/harness-run-outcome.ts +++ b/packages/harness/src/cli-agent-harness/harness-run-outcome.ts @@ -2,8 +2,8 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { z } from "zod"; import { HarnessRunStatuses } from "#src/agent-harness/agent-harness.const"; -import { HarnessArtifactFiles } from "#src/subscription-cli-harness/harness-run-artifacts.const"; -import type { FinishedRunOutcome } from "#src/subscription-cli-harness/harness-run-outcome.types"; +import { HarnessArtifactFiles } from "#src/cli-agent-harness/harness-run-artifacts.const"; +import type { FinishedRunOutcome } from "#src/cli-agent-harness/harness-run-outcome.types"; /** The fields both the started and the finished manifest share, plus the two only an end has. */ const RunManifestOutcomeSchema = z.object({ diff --git a/packages/harness/src/subscription-cli-harness/harness-run-outcome.types.ts b/packages/harness/src/cli-agent-harness/harness-run-outcome.types.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/harness-run-outcome.types.ts rename to packages/harness/src/cli-agent-harness/harness-run-outcome.types.ts diff --git a/packages/harness/src/subscription-cli-harness/harness-run-request-validation.ts b/packages/harness/src/cli-agent-harness/harness-run-request-validation.ts similarity index 92% rename from packages/harness/src/subscription-cli-harness/harness-run-request-validation.ts rename to packages/harness/src/cli-agent-harness/harness-run-request-validation.ts index 7cd1ad92..3aaa48d5 100644 --- a/packages/harness/src/subscription-cli-harness/harness-run-request-validation.ts +++ b/packages/harness/src/cli-agent-harness/harness-run-request-validation.ts @@ -3,8 +3,8 @@ import { HarnessTimeoutMilliseconds } from "#src/agent-harness/agent-harness.const"; import type { HarnessRunRequest } from "#src/agent-harness/agent-harness.types"; +import { validateTimeoutMilliseconds } from "#src/cli-agent-harness/harness-run-watchdog"; import { HarnessRequestError } from "#src/cli-execution/harness-error"; -import { validateTimeoutMilliseconds } from "#src/subscription-cli-harness/harness-run-watchdog"; export function validateHarnessRunRequest(kind: HarnessKind, request: HarnessRunRequest): void { if (!request.prompt.trim()) { diff --git a/packages/harness/src/subscription-cli-harness/harness-run-status.ts b/packages/harness/src/cli-agent-harness/harness-run-status.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/harness-run-status.ts rename to packages/harness/src/cli-agent-harness/harness-run-status.ts diff --git a/packages/harness/src/subscription-cli-harness/harness-run-watchdog.const.ts b/packages/harness/src/cli-agent-harness/harness-run-watchdog.const.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/harness-run-watchdog.const.ts rename to packages/harness/src/cli-agent-harness/harness-run-watchdog.const.ts diff --git a/packages/harness/src/subscription-cli-harness/harness-run-watchdog.ts b/packages/harness/src/cli-agent-harness/harness-run-watchdog.ts similarity index 81% rename from packages/harness/src/subscription-cli-harness/harness-run-watchdog.ts rename to packages/harness/src/cli-agent-harness/harness-run-watchdog.ts index 8de85828..847e101d 100644 --- a/packages/harness/src/subscription-cli-harness/harness-run-watchdog.ts +++ b/packages/harness/src/cli-agent-harness/harness-run-watchdog.ts @@ -1,5 +1,5 @@ -import { MAXIMUM_TIMEOUT_MILLISECONDS } from "#src/subscription-cli-harness/harness-run-watchdog.const"; -import type { WatchdogSignal } from "#src/subscription-cli-harness/harness-run-watchdog.types"; +import { MAXIMUM_TIMEOUT_MILLISECONDS } from "#src/cli-agent-harness/harness-run-watchdog.const"; +import type { WatchdogSignal } from "#src/cli-agent-harness/harness-run-watchdog.types"; export function createWatchdogSignal( timeoutMs: number, diff --git a/packages/harness/src/subscription-cli-harness/harness-run-watchdog.types.ts b/packages/harness/src/cli-agent-harness/harness-run-watchdog.types.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/harness-run-watchdog.types.ts rename to packages/harness/src/cli-agent-harness/harness-run-watchdog.types.ts diff --git a/packages/harness/src/subscription-cli-harness/harness-run.fixture.ts b/packages/harness/src/cli-agent-harness/harness-run.fixture.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/harness-run.fixture.ts rename to packages/harness/src/cli-agent-harness/harness-run.fixture.ts diff --git a/packages/harness/src/subscription-cli-harness/response-schema.test.ts b/packages/harness/src/cli-agent-harness/response-schema.test.ts similarity index 93% rename from packages/harness/src/subscription-cli-harness/response-schema.test.ts rename to packages/harness/src/cli-agent-harness/response-schema.test.ts index 073b4a10..7631b86e 100644 --- a/packages/harness/src/subscription-cli-harness/response-schema.test.ts +++ b/packages/harness/src/cli-agent-harness/response-schema.test.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; +import { parseStructuredOutput, responseJsonSchema } from "#src/cli-agent-harness/response-schema"; import { HarnessProtocolError } from "#src/cli-execution/harness-error"; -import { - parseStructuredOutput, - responseJsonSchema -} from "#src/subscription-cli-harness/response-schema"; const SourcesSchema = z.object({ sources: z.array(z.object({ url: z.url() })) diff --git a/packages/harness/src/subscription-cli-harness/response-schema.ts b/packages/harness/src/cli-agent-harness/response-schema.ts similarity index 100% rename from packages/harness/src/subscription-cli-harness/response-schema.ts rename to packages/harness/src/cli-agent-harness/response-schema.ts diff --git a/packages/harness/src/cli-execution/cli-process-runner.fixture.ts b/packages/harness/src/cli-execution/cli-process-runner.fixture.ts index f4bf93f1..f474bf62 100644 --- a/packages/harness/src/cli-execution/cli-process-runner.fixture.ts +++ b/packages/harness/src/cli-execution/cli-process-runner.fixture.ts @@ -127,6 +127,14 @@ export function streamUntilAbort( } request.signal?.addEventListener("abort", reject, { once: true }); }); + /** + * The run awaits this promise, but only after the events above have been written to disk, so the + * watchdog can reject it a macrotask before anything is watching. Node reports that window as an + * unhandled rejection and the test run fails on it even though the run went on to handle it. This + * marks the rejection as observed at the moment it can first happen; the run still awaits the same + * promise and still sees the same rejection. + */ + completed.catch(() => {}); return { stdout: Readable.from(events.map((event) => `${JSON.stringify(event)}\n`)), completed diff --git a/packages/harness/src/cli-execution/subscription-environment.const.ts b/packages/harness/src/cli-execution/harness-environment.const.ts similarity index 100% rename from packages/harness/src/cli-execution/subscription-environment.const.ts rename to packages/harness/src/cli-execution/harness-environment.const.ts diff --git a/packages/harness/src/cli-execution/subscription-environment.fixture.ts b/packages/harness/src/cli-execution/harness-environment.fixture.ts similarity index 100% rename from packages/harness/src/cli-execution/subscription-environment.fixture.ts rename to packages/harness/src/cli-execution/harness-environment.fixture.ts diff --git a/packages/harness/src/cli-execution/subscription-environment.test.ts b/packages/harness/src/cli-execution/harness-environment.test.ts similarity index 85% rename from packages/harness/src/cli-execution/subscription-environment.test.ts rename to packages/harness/src/cli-execution/harness-environment.test.ts index 3140e8ac..d82b5dce 100644 --- a/packages/harness/src/cli-execution/subscription-environment.test.ts +++ b/packages/harness/src/cli-execution/harness-environment.test.ts @@ -3,10 +3,10 @@ import { isForbiddenHarnessEnvironmentVariable, removedHarnessEnvironmentVariables, sanitizeHarnessEnvironment -} from "#src/cli-execution/subscription-environment"; -import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; +} from "#src/cli-execution/harness-environment"; +import { testEnvironment } from "#src/cli-execution/harness-environment.fixture"; -describe("subscription environment isolation", () => { +describe("harness environment isolation", () => { it("strips model API credentials and billing routes while retaining local CLI auth state", () => { const environment = testEnvironment(); const sanitized = sanitizeHarnessEnvironment(environment); diff --git a/packages/harness/src/cli-execution/subscription-environment.ts b/packages/harness/src/cli-execution/harness-environment.ts similarity index 94% rename from packages/harness/src/cli-execution/subscription-environment.ts rename to packages/harness/src/cli-execution/harness-environment.ts index 81afb06e..ba90c1ed 100644 --- a/packages/harness/src/cli-execution/subscription-environment.ts +++ b/packages/harness/src/cli-execution/harness-environment.ts @@ -1,7 +1,7 @@ import { ForbiddenEnvironmentPrefix, ForbiddenEnvironmentVariable -} from "#src/cli-execution/subscription-environment.const"; +} from "#src/cli-execution/harness-environment.const"; const forbiddenEnvironmentVariables: ReadonlySet = new Set( Object.values(ForbiddenEnvironmentVariable) diff --git a/packages/harness/src/cli-execution/harness-error.const.ts b/packages/harness/src/cli-execution/harness-error.const.ts index 3eb628d7..9cc1a785 100644 --- a/packages/harness/src/cli-execution/harness-error.const.ts +++ b/packages/harness/src/cli-execution/harness-error.const.ts @@ -1,5 +1,5 @@ export const HarnessErrorCodes = { - SUBSCRIPTION_AUTH_REQUIRED: "subscription_auth_required", + CAPABILITY_REQUIRED: "capability_required", INVALID_REQUEST: "invalid_request", INVALID_EVENT_STREAM: "invalid_event_stream", ABORTED: "aborted", @@ -10,12 +10,18 @@ export type HarnessErrorCode = (typeof HarnessErrorCodes)[keyof typeof HarnessEr /** * What a harness turned out not to have. The three are different jobs for whoever has to fix it — - * install the CLI, sign it in, or wait for the allowance — and only the code that found the gap can - * tell them apart, so it says so here rather than leaving a reader to guess from a message. + * install the CLI, give it a credential it can spend, or wait for the allowance — and only the code + * that found the gap can tell them apart, so it says so here rather than leaving a reader to guess + * from a message. + * + * `CREDENTIAL` covers all five harnesses rather than the three that hold a subscription: DeepSeek is + * handed an API key and Muse Code logs in to a Meta account, and both raise this same gap. Naming it + * after a subscription would make the readiness state it maps to, `NOT_SIGNED_IN`, disagree with the + * reason it was set. */ export const HarnessCapabilityGaps = { INSTALLATION: "installation", - SUBSCRIPTION: "subscription", + CREDENTIAL: "credential", ALLOWANCE: "allowance" } as const; diff --git a/packages/harness/src/cli-execution/harness-error.ts b/packages/harness/src/cli-execution/harness-error.ts index 4e8a81be..4603cd3f 100644 --- a/packages/harness/src/cli-execution/harness-error.ts +++ b/packages/harness/src/cli-execution/harness-error.ts @@ -41,7 +41,7 @@ export class HarnessCapabilityError extends HarnessError { capabilityRequest: HarnessCapabilityRequest, options?: ErrorOptions ) { - super(harness, HarnessErrorCodes.SUBSCRIPTION_AUTH_REQUIRED, message, options); + super(harness, HarnessErrorCodes.CAPABILITY_REQUIRED, message, options); this.name = "HarnessCapabilityError"; this.gap = gap; this.capabilityRequest = capabilityRequest; diff --git a/packages/harness/src/codex-cli/codex-harness.test.ts b/packages/harness/src/codex-cli/codex-harness.test.ts index 561a4e0a..16c4a30d 100644 --- a/packages/harness/src/codex-cli/codex-harness.test.ts +++ b/packages/harness/src/codex-cli/codex-harness.test.ts @@ -10,14 +10,20 @@ import { } from "#src/agent-harness/agent-harness.const"; import { HarnessEventTypes } from "#src/agent-harness/harness-event.const"; import type { HarnessEvent } from "#src/agent-harness/harness-event.types"; +import { + answerSchema, + harnessRequest, + lastCompleted, + removeHarnessRunDirectories +} from "#src/cli-agent-harness/harness-run.fixture"; import { captureSuccess, FakeHarnessProcessRunner, streamFailure, streamSuccess } from "#src/cli-execution/cli-process-runner.fixture"; +import { testEnvironment } from "#src/cli-execution/harness-environment.fixture"; import { HarnessErrorCodes } from "#src/cli-execution/harness-error.const"; -import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; import { CodexColorModes, CodexPermissionModes, @@ -31,12 +37,6 @@ import { CodexTestUsageLimitMessage } from "#src/codex-cli/codex-cli.fixture"; import { CodexHarness } from "#src/codex-cli/codex-harness"; -import { - answerSchema, - harnessRequest, - lastCompleted, - removeHarnessRunDirectories -} from "#src/subscription-cli-harness/harness-run.fixture"; afterEach(removeHarnessRunDirectories); @@ -229,7 +229,7 @@ describe("CodexHarness", () => { const harness = new CodexHarness({ runner, environment: testEnvironment() }); await expect(harness.preflight()).rejects.toMatchObject({ - code: HarnessErrorCodes.SUBSCRIPTION_AUTH_REQUIRED, + code: HarnessErrorCodes.CAPABILITY_REQUIRED, harness: HarnessKinds.CODEX, capabilityRequest: { need: expect.stringContaining("ChatGPT") @@ -266,12 +266,12 @@ describe("CodexHarness", () => { }; await expect(drain()).rejects.toMatchObject({ - code: HarnessErrorCodes.SUBSCRIPTION_AUTH_REQUIRED, + code: HarnessErrorCodes.CAPABILITY_REQUIRED, harness: HarnessKinds.CODEX, capabilityRequest: { reason: CodexTestUsageLimitMessage } }); const completed = lastCompleted(events); expect(completed.result.status).toBe(HarnessRunStatuses.FAILED); - expect(completed.result.error).toContain("subscription usage limit reached"); + expect(completed.result.error).toContain(CodexTestUsageLimitMessage); }); }); diff --git a/packages/harness/src/codex-cli/codex-harness.ts b/packages/harness/src/codex-cli/codex-harness.ts index a422581e..8415a21b 100644 --- a/packages/harness/src/codex-cli/codex-harness.ts +++ b/packages/harness/src/codex-cli/codex-harness.ts @@ -5,6 +5,12 @@ import type { HarnessSession } from "#src/agent-harness/agent-harness.types"; import type { HarnessEventParser } from "#src/agent-harness/harness-event-parser.types"; +import { CliAgentHarness } from "#src/cli-agent-harness/cli-agent-harness"; +import type { + CliHarnessOptions, + HarnessCommand, + HarnessRunPaths +} from "#src/cli-agent-harness/cli-agent-harness.types"; import type { HarnessCaptureResult } from "#src/cli-execution/cli-process-runner.types"; import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; import { HarnessCapabilityGaps } from "#src/cli-execution/harness-error.const"; @@ -18,17 +24,11 @@ import { codexReasoningEffortOverride, codexRunArguments } from "#src/codex-cli/codex-run-arguments"; -import { SubscriptionCliHarness } from "#src/subscription-cli-harness/subscription-cli-harness"; -import type { - HarnessCommand, - HarnessRunPaths, - SubscriptionHarnessOptions -} from "#src/subscription-cli-harness/subscription-cli-harness.types"; -export class CodexHarness extends SubscriptionCliHarness { +export class CodexHarness extends CliAgentHarness { readonly kind = HarnessKinds.CODEX; - constructor(options: SubscriptionHarnessOptions = {}) { + constructor(options: CliHarnessOptions = {}) { super(CODEX_BINARY, options); } @@ -41,7 +41,7 @@ export class CodexHarness extends SubscriptionCliHarness { if (result.failed || !statusOutput.includes(CodexLoginMarkers.CHATGPT)) { throw new HarnessCapabilityError( this.kind, - HarnessCapabilityGaps.SUBSCRIPTION, + HarnessCapabilityGaps.CREDENTIAL, "Codex CLI is not authenticated through ChatGPT", { need: "Codex CLI logged in through an active ChatGPT subscription", diff --git a/packages/harness/src/deepseek-cli/deepseek-harness.test.ts b/packages/harness/src/deepseek-cli/deepseek-harness.test.ts index e698fe19..b6c4ee22 100644 --- a/packages/harness/src/deepseek-cli/deepseek-harness.test.ts +++ b/packages/harness/src/deepseek-cli/deepseek-harness.test.ts @@ -5,14 +5,19 @@ import { HarnessKinds, HarnessRunStatuses } from "#src/agent-harness/agent-harness.const"; +import { + harnessRequest, + lastCompleted, + removeHarnessRunDirectories +} from "#src/cli-agent-harness/harness-run.fixture"; import { captureSuccess, type FakeCaptureResult, FakeHarnessProcessRunner, streamSuccess } from "#src/cli-execution/cli-process-runner.fixture"; +import { testEnvironment } from "#src/cli-execution/harness-environment.fixture"; import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; -import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; import { CodexConfigKeys, CodexNativeEventTypes, @@ -27,11 +32,6 @@ import { } from "#src/deepseek-cli/deepseek-cli.const"; import type { DeepseekWallet } from "#src/deepseek-cli/deepseek-credential.types"; import { DeepseekHarness } from "#src/deepseek-cli/deepseek-harness"; -import { - harnessRequest, - lastCompleted, - removeHarnessRunDirectories -} from "#src/subscription-cli-harness/harness-run.fixture"; const DeepseekTestCliValues = { VERSION: "codex-cli 0.146.0", diff --git a/packages/harness/src/deepseek-cli/deepseek-harness.ts b/packages/harness/src/deepseek-cli/deepseek-harness.ts index 9b6f4fd6..43f1daed 100644 --- a/packages/harness/src/deepseek-cli/deepseek-harness.ts +++ b/packages/harness/src/deepseek-cli/deepseek-harness.ts @@ -7,6 +7,11 @@ import type { } from "#src/agent-harness/agent-harness.types"; import type { HarnessEvent } from "#src/agent-harness/harness-event.types"; import type { HarnessEventParser } from "#src/agent-harness/harness-event-parser.types"; +import { CliAgentHarness } from "#src/cli-agent-harness/cli-agent-harness"; +import type { + HarnessCommand, + HarnessRunPaths +} from "#src/cli-agent-harness/cli-agent-harness.types"; import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; import { HarnessCapabilityGaps } from "#src/cli-execution/harness-error.const"; import { CodexEventParser } from "#src/codex-cli/codex-event-parser"; @@ -29,11 +34,6 @@ import type { ResolveDeepseekWallet } from "#src/deepseek-cli/deepseek-credential.types"; import type { DeepseekHarnessOptions } from "#src/deepseek-cli/deepseek-harness.types"; -import { SubscriptionCliHarness } from "#src/subscription-cli-harness/subscription-cli-harness"; -import type { - HarnessCommand, - HarnessRunPaths -} from "#src/subscription-cli-harness/subscription-cli-harness.types"; /** * DeepSeek driven through the Codex CLI, which speaks the Responses API that DeepSeek serves. It is @@ -41,7 +41,7 @@ import type { * spent by the token, so an investigation left running costs money for as long as it runs. Nothing * here caps that, and the setup page says so where the operator hands the key over. */ -export class DeepseekHarness extends SubscriptionCliHarness { +export class DeepseekHarness extends CliAgentHarness { readonly kind = HarnessKinds.DEEPSEEK; readonly #resolveWallet: ResolveDeepseekWallet; #held: Promise | undefined; @@ -157,7 +157,7 @@ export class DeepseekHarness extends SubscriptionCliHarness { #unusableWallet(cause: unknown): HarnessCapabilityError { return new HarnessCapabilityError( this.kind, - HarnessCapabilityGaps.SUBSCRIPTION, + HarnessCapabilityGaps.CREDENTIAL, "The lab holds no DeepSeek key it can spend", { need: "A DeepSeek API key with a wallet DeepSeek will still serve", diff --git a/packages/harness/src/deepseek-cli/deepseek-harness.types.ts b/packages/harness/src/deepseek-cli/deepseek-harness.types.ts index b74a9ef0..55206ca9 100644 --- a/packages/harness/src/deepseek-cli/deepseek-harness.types.ts +++ b/packages/harness/src/deepseek-cli/deepseek-harness.types.ts @@ -1,6 +1,6 @@ +import type { CliHarnessOptions } from "#src/cli-agent-harness/cli-agent-harness.types"; import type { ResolveDeepseekWallet } from "#src/deepseek-cli/deepseek-credential.types"; -import type { SubscriptionHarnessOptions } from "#src/subscription-cli-harness/subscription-cli-harness.types"; -export interface DeepseekHarnessOptions extends SubscriptionHarnessOptions { +export interface DeepseekHarnessOptions extends CliHarnessOptions { readonly resolveWallet?: ResolveDeepseekWallet; } diff --git a/packages/harness/src/glm-cli/glm-harness.test.ts b/packages/harness/src/glm-cli/glm-harness.test.ts index 96d21261..83d80dc3 100644 --- a/packages/harness/src/glm-cli/glm-harness.test.ts +++ b/packages/harness/src/glm-cli/glm-harness.test.ts @@ -5,14 +5,19 @@ import { HarnessRunStatuses } from "#src/agent-harness/agent-harness.const"; import { ClaudeApiProviders } from "#src/claude-cli/claude-cli.const"; +import { + harnessRequest, + lastCompleted, + removeHarnessRunDirectories +} from "#src/cli-agent-harness/harness-run.fixture"; import { captureSuccess, FakeHarnessProcessRunner, streamSuccess } from "#src/cli-execution/cli-process-runner.fixture"; +import { ForbiddenEnvironmentVariable } from "#src/cli-execution/harness-environment.const"; +import { testEnvironment } from "#src/cli-execution/harness-environment.fixture"; import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; -import { ForbiddenEnvironmentVariable } from "#src/cli-execution/subscription-environment.const"; -import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; import { ClaudeReportedAuthMethods, GlmSessionDefaults, @@ -20,11 +25,6 @@ import { } from "#src/glm-cli/glm-cli.const"; import { GlmHarness } from "#src/glm-cli/glm-harness"; import type { ZaiCodingPlan } from "#src/glm-cli/zai-coding-plan.types"; -import { - harnessRequest, - lastCompleted, - removeHarnessRunDirectories -} from "#src/subscription-cli-harness/harness-run.fixture"; const GlmTestNativeEventTypes = { SYSTEM: "system", diff --git a/packages/harness/src/glm-cli/glm-harness.ts b/packages/harness/src/glm-cli/glm-harness.ts index 9ba09b21..98b83f4d 100644 --- a/packages/harness/src/glm-cli/glm-harness.ts +++ b/packages/harness/src/glm-cli/glm-harness.ts @@ -12,10 +12,15 @@ import { claudeAuthenticationCommand, claudeRunArguments } from "#src/claude-cli/claude-run-arguments"; +import { CliAgentHarness } from "#src/cli-agent-harness/cli-agent-harness"; +import type { + HarnessCommand, + HarnessRunPaths +} from "#src/cli-agent-harness/cli-agent-harness.types"; import type { HarnessCaptureResult } from "#src/cli-execution/cli-process-runner.types"; +import { ForbiddenEnvironmentVariable } from "#src/cli-execution/harness-environment.const"; import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; import { HarnessCapabilityGaps } from "#src/cli-execution/harness-error.const"; -import { ForbiddenEnvironmentVariable } from "#src/cli-execution/subscription-environment.const"; import { ClaudeReportedAuthMethods, GLM_BINARY, @@ -25,11 +30,6 @@ import { import type { GlmHarnessOptions } from "#src/glm-cli/glm-harness.types"; import { resolveZaiCodingPlan } from "#src/glm-cli/zai-coding-plan"; import type { ResolveZaiCodingPlan, ZaiCodingPlan } from "#src/glm-cli/zai-coding-plan.types"; -import { SubscriptionCliHarness } from "#src/subscription-cli-harness/subscription-cli-harness"; -import type { - HarnessCommand, - HarnessRunPaths -} from "#src/subscription-cli-harness/subscription-cli-harness.types"; /** * A CLI that reported anything else ignored the injected coding-plan token and fell back to its own @@ -41,7 +41,7 @@ const GlmAuthStatusSchema = z.looseObject({ apiProvider: z.literal(ClaudeApiProviders.FIRST_PARTY) }); -export class GlmHarness extends SubscriptionCliHarness { +export class GlmHarness extends CliAgentHarness { readonly kind = HarnessKinds.GLM; readonly #resolveCodingPlan: ResolveZaiCodingPlan; @@ -112,7 +112,7 @@ export class GlmHarness extends SubscriptionCliHarness { #missingCodingPlan(cause: unknown): HarnessCapabilityError { return new HarnessCapabilityError( this.kind, - HarnessCapabilityGaps.SUBSCRIPTION, + HarnessCapabilityGaps.CREDENTIAL, "Claude CLI is not running on a Z.ai GLM Coding Plan subscription", { need: "A local ZCode sign-in carrying an active GLM Coding Plan", diff --git a/packages/harness/src/glm-cli/glm-harness.types.ts b/packages/harness/src/glm-cli/glm-harness.types.ts index 559b62be..2c78adf4 100644 --- a/packages/harness/src/glm-cli/glm-harness.types.ts +++ b/packages/harness/src/glm-cli/glm-harness.types.ts @@ -1,6 +1,6 @@ +import type { CliHarnessOptions } from "#src/cli-agent-harness/cli-agent-harness.types"; import type { ResolveZaiCodingPlan } from "#src/glm-cli/zai-coding-plan.types"; -import type { SubscriptionHarnessOptions } from "#src/subscription-cli-harness/subscription-cli-harness.types"; -export interface GlmHarnessOptions extends SubscriptionHarnessOptions { +export interface GlmHarnessOptions extends CliHarnessOptions { readonly resolveCodingPlan?: ResolveZaiCodingPlan; } diff --git a/packages/harness/src/subscription-allowance/allowance-deadline.ts b/packages/harness/src/harness-allowance/allowance-deadline.ts similarity index 80% rename from packages/harness/src/subscription-allowance/allowance-deadline.ts rename to packages/harness/src/harness-allowance/allowance-deadline.ts index 34636340..744e582c 100644 --- a/packages/harness/src/subscription-allowance/allowance-deadline.ts +++ b/packages/harness/src/harness-allowance/allowance-deadline.ts @@ -1,4 +1,4 @@ -import { ALLOWANCE_TIMEOUT_MILLISECONDS } from "#src/subscription-allowance/subscription-allowance.const"; +import { ALLOWANCE_TIMEOUT_MILLISECONDS } from "#src/harness-allowance/harness-allowance.const"; /** * Bounds one reading. A vendor that stopped answering must not hold up the work the reading was diff --git a/packages/harness/src/subscription-allowance/allowance-instant.ts b/packages/harness/src/harness-allowance/allowance-instant.ts similarity index 100% rename from packages/harness/src/subscription-allowance/allowance-instant.ts rename to packages/harness/src/harness-allowance/allowance-instant.ts diff --git a/packages/harness/src/subscription-allowance/claude-allowance.test.ts b/packages/harness/src/harness-allowance/claude-allowance.test.ts similarity index 90% rename from packages/harness/src/subscription-allowance/claude-allowance.test.ts rename to packages/harness/src/harness-allowance/claude-allowance.test.ts index 7544df18..b2624b8c 100644 --- a/packages/harness/src/subscription-allowance/claude-allowance.test.ts +++ b/packages/harness/src/harness-allowance/claude-allowance.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { claudeAllowanceFromUsage } from "#src/subscription-allowance/claude-allowance"; -import { ClaudeWindowMinutes } from "#src/subscription-allowance/subscription-allowance.const"; +import { claudeAllowanceFromUsage } from "#src/harness-allowance/claude-allowance"; +import { ClaudeWindowMinutes } from "#src/harness-allowance/harness-allowance.const"; const ClaudeUsagePayload = { five_hour: { diff --git a/packages/harness/src/subscription-allowance/claude-allowance.ts b/packages/harness/src/harness-allowance/claude-allowance.ts similarity index 90% rename from packages/harness/src/subscription-allowance/claude-allowance.ts rename to packages/harness/src/harness-allowance/claude-allowance.ts index 0a5daa2c..f5929d67 100644 --- a/packages/harness/src/subscription-allowance/claude-allowance.ts +++ b/packages/harness/src/harness-allowance/claude-allowance.ts @@ -1,18 +1,18 @@ import { execa } from "execa"; import { z } from "zod"; import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { allowanceDeadline } from "#src/subscription-allowance/allowance-deadline"; -import { allowanceInstant } from "#src/subscription-allowance/allowance-instant"; +import { allowanceDeadline } from "#src/harness-allowance/allowance-deadline"; +import { allowanceInstant } from "#src/harness-allowance/allowance-instant"; import { CLAUDE_OAUTH_BETA, CLAUDE_OAUTH_USAGE_URL, ClaudeCredentialStore, ClaudeWindowMinutes -} from "#src/subscription-allowance/subscription-allowance.const"; +} from "#src/harness-allowance/harness-allowance.const"; import type { AllowanceWindow, - SubscriptionAllowance -} from "#src/subscription-allowance/subscription-allowance.types"; + HarnessAllowanceReading +} from "#src/harness-allowance/harness-allowance.types"; const KeychainCredentialsSchema = z.looseObject({ claudeAiOauth: z.looseObject({ @@ -44,7 +44,7 @@ interface ClaudeCredentials { export function claudeAllowanceFromUsage( payload: unknown, plan: string | null -): SubscriptionAllowance { +): HarnessAllowanceReading { const usage = OAuthUsageSchema.parse(payload); const windows = [ claudeWindow(usage.five_hour, ClaudeWindowMinutes.FIVE_HOUR), @@ -53,6 +53,8 @@ export function claudeAllowanceFromUsage( return { kind: HarnessKinds.CLAUDE, plan, + balance: null, + spent: false, windows: windows.filter((window): window is AllowanceWindow => window !== undefined) }; } @@ -61,7 +63,7 @@ export function claudeAllowanceFromUsage( * Asks Anthropic what is left of the login the Claude CLI provisioned. The token is read from the * same Keychain entry the CLI writes, so the lab never holds a credential of its own. */ -export async function readClaudeAllowance(signal?: AbortSignal): Promise { +export async function readClaudeAllowance(signal?: AbortSignal): Promise { const credentials = await readClaudeCredentials(signal); const response = await fetch(CLAUDE_OAUTH_USAGE_URL, { headers: { diff --git a/packages/harness/src/subscription-allowance/codex-allowance.test.ts b/packages/harness/src/harness-allowance/codex-allowance.test.ts similarity index 95% rename from packages/harness/src/subscription-allowance/codex-allowance.test.ts rename to packages/harness/src/harness-allowance/codex-allowance.test.ts index e7f411ef..7a05a6aa 100644 --- a/packages/harness/src/subscription-allowance/codex-allowance.test.ts +++ b/packages/harness/src/harness-allowance/codex-allowance.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { codexAllowanceFromRateLimits } from "#src/subscription-allowance/codex-allowance"; +import { codexAllowanceFromRateLimits } from "#src/harness-allowance/codex-allowance"; const CodexRateLimitsPayload = { rateLimits: { diff --git a/packages/harness/src/subscription-allowance/codex-allowance.ts b/packages/harness/src/harness-allowance/codex-allowance.ts similarity index 79% rename from packages/harness/src/subscription-allowance/codex-allowance.ts rename to packages/harness/src/harness-allowance/codex-allowance.ts index 59430aa4..5e6092f9 100644 --- a/packages/harness/src/subscription-allowance/codex-allowance.ts +++ b/packages/harness/src/harness-allowance/codex-allowance.ts @@ -1,12 +1,12 @@ import { z } from "zod"; import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { allowanceInstant } from "#src/subscription-allowance/allowance-instant"; -import { requestCodexAppServer } from "#src/subscription-allowance/codex-app-server"; -import { CodexAppServer } from "#src/subscription-allowance/subscription-allowance.const"; +import { allowanceInstant } from "#src/harness-allowance/allowance-instant"; +import { requestCodexAppServer } from "#src/harness-allowance/codex-app-server"; +import { CodexAppServer } from "#src/harness-allowance/harness-allowance.const"; import type { AllowanceWindow, - SubscriptionAllowance -} from "#src/subscription-allowance/subscription-allowance.types"; + HarnessAllowanceReading +} from "#src/harness-allowance/harness-allowance.types"; /** Codex is the one vendor that states how long a window runs, so nothing has to be assumed. */ const CodexWindowSchema = z.looseObject({ @@ -23,18 +23,20 @@ const CodexRateLimitsSchema = z.looseObject({ }) }); -export function codexAllowanceFromRateLimits(payload: unknown): SubscriptionAllowance { +export function codexAllowanceFromRateLimits(payload: unknown): HarnessAllowanceReading { const limits = CodexRateLimitsSchema.parse(payload).rateLimits; return { kind: HarnessKinds.CODEX, plan: limits.planType ?? null, + balance: null, + spent: false, windows: [limits.primary, limits.secondary] .filter((window) => window !== null && window !== undefined) .map(codexWindow) }; } -export async function readCodexAllowance(signal?: AbortSignal): Promise { +export async function readCodexAllowance(signal?: AbortSignal): Promise { return codexAllowanceFromRateLimits( await requestCodexAppServer(CodexAppServer.RATE_LIMITS, signal) ); diff --git a/packages/harness/src/subscription-allowance/codex-app-server.ts b/packages/harness/src/harness-allowance/codex-app-server.ts similarity index 94% rename from packages/harness/src/subscription-allowance/codex-app-server.ts rename to packages/harness/src/harness-allowance/codex-app-server.ts index a3f7eb84..fdb893bf 100644 --- a/packages/harness/src/subscription-allowance/codex-app-server.ts +++ b/packages/harness/src/harness-allowance/codex-app-server.ts @@ -2,8 +2,8 @@ import { createInterface } from "node:readline"; import { execa } from "execa"; import { z } from "zod"; import { CODEX_BINARY } from "#src/codex-cli/codex-cli.const"; -import { allowanceDeadline } from "#src/subscription-allowance/allowance-deadline"; -import { CodexAppServer } from "#src/subscription-allowance/subscription-allowance.const"; +import { allowanceDeadline } from "#src/harness-allowance/allowance-deadline"; +import { CodexAppServer } from "#src/harness-allowance/harness-allowance.const"; const INITIALIZE_ID = 1; const REQUEST_ID = 2; diff --git a/packages/harness/src/harness-allowance/deepseek-allowance.ts b/packages/harness/src/harness-allowance/deepseek-allowance.ts new file mode 100644 index 00000000..1d865489 --- /dev/null +++ b/packages/harness/src/harness-allowance/deepseek-allowance.ts @@ -0,0 +1,32 @@ +import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; +import { resolveDeepseekWallet } from "#src/deepseek-cli/deepseek-credential"; +import type { ResolveDeepseekWallet } from "#src/deepseek-cli/deepseek-credential.types"; +import { allowanceDeadline } from "#src/harness-allowance/allowance-deadline"; +import type { HarnessAllowanceReading } from "#src/harness-allowance/harness-allowance.types"; + +/** + * DeepSeek meters nothing. There is no window that fills and resets, so this reading carries none: + * a wallet is spent until it is empty, whenever that happens. What it has left is reported as the + * balance it is, and DeepSeek sells no tier, so it names no plan. + * + * Whether the wallet may still be spent is DeepSeek's own verdict and is carried as it was given. + * It is the same verdict the preflight refuses a run on, and a reading that dropped it would leave + * the panel calling a wallet available while the lab was already refusing to dispatch to it. + * + * Carrying no windows also means no spend cap can be set against DeepSeek, which is the truth rather + * than an omission: a cap is a fraction of a window, and there is no window here to take a fraction + * of. + */ +export async function readDeepseekAllowance( + signal?: AbortSignal, + resolveWallet: ResolveDeepseekWallet = resolveDeepseekWallet +): Promise { + const wallet = await resolveWallet(allowanceDeadline(signal)); + return { + kind: HarnessKinds.DEEPSEEK, + plan: null, + balance: wallet.balance, + spent: !wallet.available, + windows: [] + }; +} diff --git a/packages/harness/src/subscription-allowance/glm-allowance.test.ts b/packages/harness/src/harness-allowance/glm-allowance.test.ts similarity index 94% rename from packages/harness/src/subscription-allowance/glm-allowance.test.ts rename to packages/harness/src/harness-allowance/glm-allowance.test.ts index f61b2feb..bb230475 100644 --- a/packages/harness/src/subscription-allowance/glm-allowance.test.ts +++ b/packages/harness/src/harness-allowance/glm-allowance.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { glmAllowanceFromQuota } from "#src/subscription-allowance/glm-allowance"; -import { ZaiSpendingLimitType } from "#src/subscription-allowance/subscription-allowance.const"; +import { glmAllowanceFromQuota } from "#src/harness-allowance/glm-allowance"; +import { ZaiSpendingLimitType } from "#src/harness-allowance/harness-allowance.const"; const ZaiQuotaPayload = { code: 200, diff --git a/packages/harness/src/subscription-allowance/glm-allowance.ts b/packages/harness/src/harness-allowance/glm-allowance.ts similarity index 84% rename from packages/harness/src/subscription-allowance/glm-allowance.ts rename to packages/harness/src/harness-allowance/glm-allowance.ts index 0865bb7a..f7584f96 100644 --- a/packages/harness/src/subscription-allowance/glm-allowance.ts +++ b/packages/harness/src/harness-allowance/glm-allowance.ts @@ -2,16 +2,16 @@ import { z } from "zod"; import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; import { ZAI_CODING_PLAN_QUOTA_URL } from "#src/glm-cli/glm-cli.const"; import { readCodingPlanKey } from "#src/glm-cli/zai-coding-plan"; -import { allowanceDeadline } from "#src/subscription-allowance/allowance-deadline"; -import { allowanceInstant } from "#src/subscription-allowance/allowance-instant"; +import { allowanceDeadline } from "#src/harness-allowance/allowance-deadline"; +import { allowanceInstant } from "#src/harness-allowance/allowance-instant"; import { ZaiPeriodMinutes, ZaiSpendingLimitType -} from "#src/subscription-allowance/subscription-allowance.const"; +} from "#src/harness-allowance/harness-allowance.const"; import type { AllowanceWindow, - SubscriptionAllowance -} from "#src/subscription-allowance/subscription-allowance.types"; + HarnessAllowanceReading +} from "#src/harness-allowance/harness-allowance.types"; const ZaiLimitSchema = z.looseObject({ type: z.string().trim().min(1), @@ -31,11 +31,13 @@ const ZaiQuotaSchema = z.looseObject({ const SPENDING_LIMIT_TYPES: readonly string[] = Object.values(ZaiSpendingLimitType); -export function glmAllowanceFromQuota(payload: unknown): SubscriptionAllowance { +export function glmAllowanceFromQuota(payload: unknown): HarnessAllowanceReading { const quota = ZaiQuotaSchema.parse(payload).data; return { kind: HarnessKinds.GLM, plan: quota.level ?? null, + balance: null, + spent: false, windows: quota.limits .filter((limit) => SPENDING_LIMIT_TYPES.includes(limit.type)) .map(glmWindow) @@ -48,7 +50,7 @@ export function glmAllowanceFromQuota(payload: unknown): SubscriptionAllowance { * The same endpoint already proves a run is subscription-billed; this reads the quota it states * alongside the tier. */ -export async function readGlmAllowance(signal?: AbortSignal): Promise { +export async function readGlmAllowance(signal?: AbortSignal): Promise { const apiKey = await readCodingPlanKey(); const response = await fetch(ZAI_CODING_PLAN_QUOTA_URL, { headers: { Authorization: apiKey }, diff --git a/packages/harness/src/subscription-allowance/subscription-allowance.const.ts b/packages/harness/src/harness-allowance/harness-allowance.const.ts similarity index 93% rename from packages/harness/src/subscription-allowance/subscription-allowance.const.ts rename to packages/harness/src/harness-allowance/harness-allowance.const.ts index 53b13e9a..10b9762b 100644 --- a/packages/harness/src/subscription-allowance/subscription-allowance.const.ts +++ b/packages/harness/src/harness-allowance/harness-allowance.const.ts @@ -55,12 +55,12 @@ export const ZaiSpendingLimitType = { export type ZaiSpendingLimitType = (typeof ZaiSpendingLimitType)[keyof typeof ZaiSpendingLimitType]; /** - * What stands where a plan tier goes for Muse Code, which sells no tier. It is a sentence rather than + * What stands where a balance goes for Muse Code, which publishes none. It is a sentence rather than * a blank because a blank reads as a reading that failed, and this one succeeded: there is nothing to * report, and an operator deciding whether to leave an investigation running overnight has to be told * that rather than left to infer it from an empty field. */ -export const MUSE_METERED_PLAN = "Metered — Meta publishes no balance"; +export const MUSE_UNPUBLISHED_BALANCE = "Meta publishes no balance"; /** A reading is monitoring, not work: it may never outlast the preflight it rides alongside. */ export const ALLOWANCE_TIMEOUT_MILLISECONDS = 15_000; diff --git a/packages/harness/src/harness-allowance/harness-allowance.ts b/packages/harness/src/harness-allowance/harness-allowance.ts new file mode 100644 index 00000000..bd347c66 --- /dev/null +++ b/packages/harness/src/harness-allowance/harness-allowance.ts @@ -0,0 +1,30 @@ +import { type HarnessKind, HarnessKinds } from "#src/agent-harness/agent-harness.const"; +import { readClaudeAllowance } from "#src/harness-allowance/claude-allowance"; +import { readCodexAllowance } from "#src/harness-allowance/codex-allowance"; +import { readDeepseekAllowance } from "#src/harness-allowance/deepseek-allowance"; +import { readGlmAllowance } from "#src/harness-allowance/glm-allowance"; +import type { + HarnessAllowanceReading, + ReadHarnessAllowance +} from "#src/harness-allowance/harness-allowance.types"; +import { readMuseAllowance } from "#src/harness-allowance/muse-allowance"; + +const ALLOWANCE_READER: Record = { + [HarnessKinds.CLAUDE]: readClaudeAllowance, + [HarnessKinds.CODEX]: readCodexAllowance, + [HarnessKinds.GLM]: readGlmAllowance, + [HarnessKinds.DEEPSEEK]: readDeepseekAllowance, + [HarnessKinds.MUSE]: readMuseAllowance +}; + +/** + * Asks one vendor what is left of the subscription that harness runs on. Every vendor answers from + * the login the operator already made, so a reading needs no credential of the lab's own and never + * spends the allowance it is reporting on. + */ +export function readHarnessAllowance( + kind: HarnessKind, + signal?: AbortSignal +): Promise { + return ALLOWANCE_READER[kind](signal); +} diff --git a/packages/harness/src/harness-allowance/harness-allowance.types.ts b/packages/harness/src/harness-allowance/harness-allowance.types.ts new file mode 100644 index 00000000..0fbc72c2 --- /dev/null +++ b/packages/harness/src/harness-allowance/harness-allowance.types.ts @@ -0,0 +1,38 @@ +import type { HarnessKind } from "#src/agent-harness/agent-harness.const"; + +/** + * One rolling window a vendor meters, stated in the vendor's own terms. Nothing here is normalised + * into a shared taxonomy: the window carries its own length so a vendor that adds a third window + * needs no new name invented for it. + */ +export interface AllowanceWindow { + readonly durationMinutes: number; + /** Vendors report consumption rather than headroom, and none of them stops exactly at 100. */ + readonly usedPercent: number; + readonly resetsAt: string | null; +} + +/** + * What one vendor answered when asked what is left of the operator's account. A tier and an amount + * are separate fields because they are separate facts, and the same split the preflight already + * reports: a vendor that sells tiers names one and states no balance, a vendor that sells tokens + * states a balance and has no tier. Putting money in `plan` would have the page render a wallet as + * though it were a subscription the operator had bought. + */ +export interface HarnessAllowanceReading { + readonly kind: HarnessKind; + /** The plan tier the vendor named. Null for a vendor that sells no tier. */ + readonly plan: string | null; + /** What a token-billed account has left to spend. Null for a subscription. */ + readonly balance: string | null; + /** + * The vendor has stopped serving this account, said outright rather than read off a meter. A + * subscription says it through a window at its ceiling and leaves this false; a wallet has no + * window to say it with, so an empty one would otherwise read as an account with everything + * still to spend. + */ + readonly spent: boolean; + readonly windows: readonly AllowanceWindow[]; +} + +export type ReadHarnessAllowance = (signal?: AbortSignal) => Promise; diff --git a/packages/harness/src/harness-allowance/metered-allowance.test.ts b/packages/harness/src/harness-allowance/metered-allowance.test.ts new file mode 100644 index 00000000..3fcd9212 --- /dev/null +++ b/packages/harness/src/harness-allowance/metered-allowance.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; +import { readDeepseekAllowance } from "#src/harness-allowance/deepseek-allowance"; +import { readMuseAllowance } from "#src/harness-allowance/muse-allowance"; + +/** + * The two token-billed harnesses answer in money, and money used to be reported in the field a plan + * tier goes in — so the page rendered a wallet balance as though it named a plan the operator had + * bought, styled and capitalised like one. A tier and an amount are separate readings now. + */ +describe("allowance readings for a token-billed harness", () => { + it("reports a DeepSeek wallet as a balance and names no plan", async () => { + const allowance = await readDeepseekAllowance(undefined, () => + Promise.resolve({ apiKey: "sk-test", available: true, balance: "4.21 USD" }) + ); + + expect(allowance).toEqual({ + kind: HarnessKinds.DEEPSEEK, + plan: null, + balance: "4.21 USD", + spent: false, + windows: [] + }); + }); + + /** + * The same verdict the preflight refuses a run on. Dropping it left the panel calling a wallet + * available while the lab was already refusing to dispatch to it, and an operator reading that + * page had no way to tell why nothing was running. + */ + it("carries DeepSeek's own verdict that a wallet can no longer be spent", async () => { + const allowance = await readDeepseekAllowance(undefined, () => + Promise.resolve({ apiKey: "sk-test", available: false, balance: "0.00 USD" }) + ); + + expect(allowance.spent).toBe(true); + }); + + /** Meta states nothing, so the balance says that in words and no window can be capped. */ + it("reports Muse Code as a balance Meta does not publish", async () => { + const allowance = await readMuseAllowance(); + + expect(allowance.plan).toBeNull(); + expect(allowance.balance).toMatch(/no balance/iu); + expect(allowance.windows).toEqual([]); + }); +}); diff --git a/packages/harness/src/harness-allowance/muse-allowance.ts b/packages/harness/src/harness-allowance/muse-allowance.ts new file mode 100644 index 00000000..0b316871 --- /dev/null +++ b/packages/harness/src/harness-allowance/muse-allowance.ts @@ -0,0 +1,27 @@ +import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; +import { MUSE_UNPUBLISHED_BALANCE } from "#src/harness-allowance/harness-allowance.const"; +import type { HarnessAllowanceReading } from "#src/harness-allowance/harness-allowance.types"; + +/** + * Muse Code meters nothing an operator can read. There is no window that fills and resets, and unlike + * a DeepSeek wallet there is not even a number to report: Meta bills the account after the fact and + * publishes nothing the CLI can be asked for mid-run. So the balance says that in words rather than + * being left blank, which would read as a reading that failed instead of one that succeeded and found + * nothing to state. Muse sells no tier either, so it names no plan. + * + * Carrying no windows is also why no spend cap can be set against Muse. A cap is a fraction of a + * window, and there is no window here. + * + * Nothing published also means no verdict on whether the account may still be spent, so the reading + * claims none. The lab goes on dispatching to Muse and learns what Meta thinks from the run itself, + * which is what it did before there was a reading at all. + */ +export function readMuseAllowance(): Promise { + return Promise.resolve({ + kind: HarnessKinds.MUSE, + plan: null, + balance: MUSE_UNPUBLISHED_BALANCE, + spent: false, + windows: [] + }); +} diff --git a/packages/harness/src/muse-cli/muse-harness.test.ts b/packages/harness/src/muse-cli/muse-harness.test.ts index 5768714d..af878815 100644 --- a/packages/harness/src/muse-cli/muse-harness.test.ts +++ b/packages/harness/src/muse-cli/muse-harness.test.ts @@ -8,13 +8,19 @@ import { HarnessRunStatuses } from "#src/agent-harness/agent-harness.const"; import { HarnessDiagnosticLevels, HarnessEventTypes } from "#src/agent-harness/harness-event.const"; +import { + answerSchema, + harnessRequest, + lastCompleted, + removeHarnessRunDirectories +} from "#src/cli-agent-harness/harness-run.fixture"; import { captureSuccess, FakeHarnessProcessRunner, streamSuccess } from "#src/cli-execution/cli-process-runner.fixture"; +import { testEnvironment } from "#src/cli-execution/harness-environment.fixture"; import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; -import { testEnvironment } from "#src/cli-execution/subscription-environment.fixture"; import { museCredentialPath } from "#src/muse-cli/muse-account"; import type { MuseAccount } from "#src/muse-cli/muse-account.types"; import { @@ -28,12 +34,6 @@ import { } from "#src/muse-cli/muse-cli.const"; import { MuseTestValues, museRecord, museRunStream } from "#src/muse-cli/muse-cli.fixture"; import { MuseHarness } from "#src/muse-cli/muse-harness"; -import { - answerSchema, - harnessRequest, - lastCompleted, - removeHarnessRunDirectories -} from "#src/subscription-cli-harness/harness-run.fixture"; const MuseTestAccount: MuseAccount = { email: MuseTestValues.EMAIL, diff --git a/packages/harness/src/muse-cli/muse-harness.ts b/packages/harness/src/muse-cli/muse-harness.ts index ed2d9feb..d7bd4c02 100644 --- a/packages/harness/src/muse-cli/muse-harness.ts +++ b/packages/harness/src/muse-cli/muse-harness.ts @@ -12,6 +12,11 @@ import type { } from "#src/agent-harness/agent-harness.types"; import type { HarnessEvent } from "#src/agent-harness/harness-event.types"; import type { HarnessEventParser } from "#src/agent-harness/harness-event-parser.types"; +import { CliAgentHarness } from "#src/cli-agent-harness/cli-agent-harness"; +import type { + HarnessCommand, + HarnessRunPaths +} from "#src/cli-agent-harness/cli-agent-harness.types"; import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; import { HarnessCapabilityGaps } from "#src/cli-execution/harness-error.const"; import { resolveMuseAccount } from "#src/muse-cli/muse-account"; @@ -28,11 +33,6 @@ import { MuseEventParser } from "#src/muse-cli/muse-event-parser"; import type { MuseHarnessOptions } from "#src/muse-cli/muse-harness.types"; import { museRunArguments } from "#src/muse-cli/muse-run-arguments"; import { museStructuredPrompt } from "#src/muse-cli/muse-structured-response"; -import { SubscriptionCliHarness } from "#src/subscription-cli-harness/subscription-cli-harness"; -import type { - HarnessCommand, - HarnessRunPaths -} from "#src/subscription-cli-harness/subscription-cli-harness.types"; /** * Meta's Muse Code, driven headless through `muse exec`. @@ -44,7 +44,7 @@ import type { * preflight says the run is metered, the harness setup card says it before the operator picks the * harness, and no spend cap can be set against it because there is no window to take a fraction of. */ -export class MuseHarness extends SubscriptionCliHarness { +export class MuseHarness extends CliAgentHarness { readonly kind = HarnessKinds.MUSE; readonly #resolveAccount: ResolveMuseAccount; readonly #environment: Readonly; @@ -173,7 +173,7 @@ export class MuseHarness extends SubscriptionCliHarness { #unusableCredential(cause: unknown): HarnessCapabilityError { return new HarnessCapabilityError( this.kind, - HarnessCapabilityGaps.SUBSCRIPTION, + HarnessCapabilityGaps.CREDENTIAL, "Muse Code is not signed in to a Meta account the lab can name", { need: "Muse Code signed in with `muse login` against Meta's own host", diff --git a/packages/harness/src/muse-cli/muse-harness.types.ts b/packages/harness/src/muse-cli/muse-harness.types.ts index 3c905d5c..b1576904 100644 --- a/packages/harness/src/muse-cli/muse-harness.types.ts +++ b/packages/harness/src/muse-cli/muse-harness.types.ts @@ -1,6 +1,6 @@ +import type { CliHarnessOptions } from "#src/cli-agent-harness/cli-agent-harness.types"; import type { ResolveMuseAccount } from "#src/muse-cli/muse-account.types"; -import type { SubscriptionHarnessOptions } from "#src/subscription-cli-harness/subscription-cli-harness.types"; -export interface MuseHarnessOptions extends SubscriptionHarnessOptions { +export interface MuseHarnessOptions extends CliHarnessOptions { readonly resolveAccount?: ResolveMuseAccount; } diff --git a/packages/harness/src/muse-cli/muse-structured-response.ts b/packages/harness/src/muse-cli/muse-structured-response.ts index 602af201..1e96acf2 100644 --- a/packages/harness/src/muse-cli/muse-structured-response.ts +++ b/packages/harness/src/muse-cli/muse-structured-response.ts @@ -1,5 +1,5 @@ import type { z } from "zod"; -import { responseJsonSchema } from "#src/subscription-cli-harness/response-schema"; +import { responseJsonSchema } from "#src/cli-agent-harness/response-schema"; const JSON_FENCE = /^```(?:json)?\s*\n([\s\S]*?)\n?```$/; diff --git a/packages/harness/src/spent-allowance/spent-allowance.const.ts b/packages/harness/src/spent-allowance/spent-allowance.const.ts new file mode 100644 index 00000000..8f379b9e --- /dev/null +++ b/packages/harness/src/spent-allowance/spent-allowance.const.ts @@ -0,0 +1,14 @@ +/** + * A vendor that has stopped serving the account ends the run with a plain sentence on its error + * channel instead of a protocol signal. Each CLI words the refusal differently, so it is recognised + * by the vocabulary the vendors share: an exhausted limit, a throttle, or an empty prepaid balance. + * The last of those is why this is not called a subscription limit — a wallet billed by the token + * runs out in exactly the same place, and says so in the same channel. + */ +export const SPENT_ALLOWANCE_PATTERNS: readonly RegExp[] = [ + /usage limit/iu, + /rate[\s_-]?limit/iu, + /quota/iu, + /too many requests/iu, + /\b(?:out of|insufficient|purchase more) credits?\b/iu +]; diff --git a/packages/harness/src/spent-allowance/spent-allowance.test.ts b/packages/harness/src/spent-allowance/spent-allowance.test.ts new file mode 100644 index 00000000..f1b4f81c --- /dev/null +++ b/packages/harness/src/spent-allowance/spent-allowance.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; +import { HarnessCapabilityGaps, HarnessErrorCodes } from "#src/cli-execution/harness-error.const"; +import { CodexTestUsageLimitMessage } from "#src/codex-cli/codex-cli.fixture"; +import { isSpentAllowance, spentAllowanceError } from "#src/spent-allowance/spent-allowance"; + +describe("spent allowance", () => { + it("recognises the refusal a vendor sends when the account is spent", () => { + expect(isSpentAllowance(CodexTestUsageLimitMessage)).toBe(true); + expect(isSpentAllowance("Claude AI usage limit reached|1786000000")).toBe(true); + expect(isSpentAllowance("Request failed: 429 too many requests")).toBe(true); + expect(isSpentAllowance("coding plan quota exhausted")).toBe(true); + expect(isSpentAllowance("Error: insufficient credits on this account")).toBe(true); + }); + + it("leaves an ordinary run failure alone", () => { + expect(isSpentAllowance("Codex item event is missing its item payload")).toBe(false); + expect(isSpentAllowance("apply_patch failed: file not found")).toBe(false); + }); + + it("tells a subscription operator to wait, keeping the reset time the vendor named", () => { + const error = spentAllowanceError(HarnessKinds.CODEX, CodexTestUsageLimitMessage); + + expect(error.code).toBe(HarnessErrorCodes.CAPABILITY_REQUIRED); + expect(error.gap).toBe(HarnessCapabilityGaps.ALLOWANCE); + expect(error.capabilityRequest.reason).toContain("try again at Aug 9th, 2026 6:50 PM"); + expect(error.capabilityRequest.provisioningHint).toContain("reset"); + }); + + /** + * The wallet harnesses are the reason this refusal branches at all. Nothing resets for them, so + * an operator sent to wait for an allowance or to raise a plan is sent to do something that + * cannot happen — which is what the lab used to tell them. + */ + it("tells a token-billed operator to pay rather than to wait for a plan", () => { + const error = spentAllowanceError( + HarnessKinds.DEEPSEEK, + "Error: insufficient credits on this account" + ); + + expect(error.capabilityRequest.provisioningHint).toContain("Add balance"); + expect(error.capabilityRequest.provisioningHint).not.toMatch(/subscription|plan|reset/iu); + expect(error.message).not.toMatch(/subscription/iu); + expect(error.capabilityRequest.need).not.toMatch(/subscription|plan/iu); + }); + + /** The clause that told a DeepSeek operator their own billing was forbidden by the lab. */ + it("never tells an operator that usage billing is forbidden", () => { + for (const harness of [HarnessKinds.CODEX, HarnessKinds.DEEPSEEK, HarnessKinds.MUSE]) { + const error = spentAllowanceError(harness, "quota exhausted"); + + expect(error.capabilityRequest.provisioningHint).not.toMatch(/forbidden/iu); + } + }); +}); diff --git a/packages/harness/src/spent-allowance/spent-allowance.ts b/packages/harness/src/spent-allowance/spent-allowance.ts new file mode 100644 index 00000000..d54eab5a --- /dev/null +++ b/packages/harness/src/spent-allowance/spent-allowance.ts @@ -0,0 +1,73 @@ +import { AgentHarnessBilling, HARNESS_BILLING } from "@openlab/protocol/agents/agent-billing.const"; +import type { HarnessKind } from "#src/agent-harness/agent-harness.const"; +import { HarnessDiagnosticLevels, HarnessEventTypes } from "#src/agent-harness/harness-event.const"; +import type { ParsedHarnessEvent } from "#src/agent-harness/harness-event-parser.types"; +import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; +import { HarnessCapabilityGaps } from "#src/cli-execution/harness-error.const"; +import { SPENT_ALLOWANCE_PATTERNS } from "#src/spent-allowance/spent-allowance.const"; + +export function isSpentAllowance(message: string): boolean { + return SPENT_ALLOWANCE_PATTERNS.some((pattern) => pattern.test(message)); +} + +/** The vendor sentence behind an error diagnostic that reports an account it will not serve. */ +export function spentAllowanceMessage(event: ParsedHarnessEvent): string | undefined { + return event.type === HarnessEventTypes.DIAGNOSTIC && + event.level === HarnessDiagnosticLevels.ERROR && + isSpentAllowance(event.message) + ? event.message + : undefined; +} + +/** + * A spent allowance is an operator-held account problem, never a broken event stream. Reporting it + * as a capability error is what lets the daemon fall back to another harness, ask the operator for + * the one that ran out, and hibernate on the real reason instead of on a parser complaint. The + * vendor sentence is carried through verbatim because it is the part that names what ran out. + * + * What the operator is asked to do about it depends on what pays for the harness, so it is taken + * from the billing rather than assumed: a plan comes back on its own and can be waited out, and a + * wallet does not come back at all until someone puts money in it. + */ +export function spentAllowanceError( + harness: HarnessKind, + vendorMessage: string +): HarnessCapabilityError { + return HARNESS_BILLING[harness] === AgentHarnessBilling.SUBSCRIPTION + ? spentSubscriptionError(harness, vendorMessage) + : spentWalletError(harness, vendorMessage); +} + +function spentSubscriptionError( + harness: HarnessKind, + vendorMessage: string +): HarnessCapabilityError { + return new HarnessCapabilityError( + harness, + HarnessCapabilityGaps.ALLOWANCE, + `${harness} subscription allowance is spent: ${vendorMessage}`, + { + need: `Remaining allowance on the ${harness} subscription`, + reason: vendorMessage, + provisioningHint: `Wait for the ${harness} allowance to reset, raise the plan, or sign in to another subscription, then retry` + } + ); +} + +/** + * A token-billed vendor stops for two different reasons — an empty balance and a throttle — and the + * lab cannot tell them apart from the sentence it was given. Both are named rather than guessed at, + * because the vendor's own sentence is carried directly above this and already says which one it is. + */ +function spentWalletError(harness: HarnessKind, vendorMessage: string): HarnessCapabilityError { + return new HarnessCapabilityError( + harness, + HarnessCapabilityGaps.ALLOWANCE, + `${harness} is billed by the token and the vendor stopped serving the account: ${vendorMessage}`, + { + need: `A ${harness} account the vendor will still serve`, + reason: vendorMessage, + provisioningHint: `Add balance to the ${harness} account or wait out the vendor's rate limit, then retry` + } + ); +} diff --git a/packages/harness/src/subscription-allowance/deepseek-allowance.ts b/packages/harness/src/subscription-allowance/deepseek-allowance.ts deleted file mode 100644 index afd53814..00000000 --- a/packages/harness/src/subscription-allowance/deepseek-allowance.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { resolveDeepseekWallet } from "#src/deepseek-cli/deepseek-credential"; -import { allowanceDeadline } from "#src/subscription-allowance/allowance-deadline"; -import type { SubscriptionAllowance } from "#src/subscription-allowance/subscription-allowance.types"; - -/** - * DeepSeek meters nothing. There is no window that fills and resets, so this reading carries none: - * a wallet is spent until it is empty, whenever that happens. The balance is reported as the plan, - * because it is the one thing about the account that decides whether the lab can keep working, and - * the harness setup card says outright that it is money rather than a subscription. - * - * Carrying no windows also means no spend cap can be set against DeepSeek, which is the truth rather - * than an omission: a cap is a fraction of a window, and there is no window here to take a fraction - * of. - */ -export async function readDeepseekAllowance(signal?: AbortSignal): Promise { - const wallet = await resolveDeepseekWallet(allowanceDeadline(signal)); - return { - kind: HarnessKinds.DEEPSEEK, - plan: wallet.balance, - windows: [] - }; -} diff --git a/packages/harness/src/subscription-allowance/muse-allowance.ts b/packages/harness/src/subscription-allowance/muse-allowance.ts deleted file mode 100644 index 0a0a1f05..00000000 --- a/packages/harness/src/subscription-allowance/muse-allowance.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { MUSE_METERED_PLAN } from "#src/subscription-allowance/subscription-allowance.const"; -import type { SubscriptionAllowance } from "#src/subscription-allowance/subscription-allowance.types"; - -/** - * Muse Code meters nothing an operator can read. There is no window that fills and resets, and unlike - * a DeepSeek wallet there is not even a balance to report: Meta bills the account after the fact and - * publishes nothing the CLI can be asked for mid-run. So this reading states the one true thing — - * that the account is spent by the token — and carries no windows, which is also why no spend cap can - * be set against Muse. A cap is a fraction of a window, and there is no window here. - */ -export function readMuseAllowance(): Promise { - return Promise.resolve({ - kind: HarnessKinds.MUSE, - plan: MUSE_METERED_PLAN, - windows: [] - }); -} diff --git a/packages/harness/src/subscription-allowance/subscription-allowance.ts b/packages/harness/src/subscription-allowance/subscription-allowance.ts deleted file mode 100644 index 610be09d..00000000 --- a/packages/harness/src/subscription-allowance/subscription-allowance.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { type HarnessKind, HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { readClaudeAllowance } from "#src/subscription-allowance/claude-allowance"; -import { readCodexAllowance } from "#src/subscription-allowance/codex-allowance"; -import { readDeepseekAllowance } from "#src/subscription-allowance/deepseek-allowance"; -import { readGlmAllowance } from "#src/subscription-allowance/glm-allowance"; -import { readMuseAllowance } from "#src/subscription-allowance/muse-allowance"; -import type { - ReadSubscriptionAllowance, - SubscriptionAllowance -} from "#src/subscription-allowance/subscription-allowance.types"; - -const ALLOWANCE_READER: Record = { - [HarnessKinds.CLAUDE]: readClaudeAllowance, - [HarnessKinds.CODEX]: readCodexAllowance, - [HarnessKinds.GLM]: readGlmAllowance, - [HarnessKinds.DEEPSEEK]: readDeepseekAllowance, - [HarnessKinds.MUSE]: readMuseAllowance -}; - -/** - * Asks one vendor what is left of the subscription that harness runs on. Every vendor answers from - * the login the operator already made, so a reading needs no credential of the lab's own and never - * spends the allowance it is reporting on. - */ -export function readSubscriptionAllowance( - kind: HarnessKind, - signal?: AbortSignal -): Promise { - return ALLOWANCE_READER[kind](signal); -} diff --git a/packages/harness/src/subscription-allowance/subscription-allowance.types.ts b/packages/harness/src/subscription-allowance/subscription-allowance.types.ts deleted file mode 100644 index f945f54b..00000000 --- a/packages/harness/src/subscription-allowance/subscription-allowance.types.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { HarnessKind } from "#src/agent-harness/agent-harness.const"; - -/** - * One rolling window a vendor meters, stated in the vendor's own terms. Nothing here is normalised - * into a shared taxonomy: the window carries its own length so a vendor that adds a third window - * needs no new name invented for it. - */ -export interface AllowanceWindow { - readonly durationMinutes: number; - /** Vendors report consumption rather than headroom, and none of them stops exactly at 100. */ - readonly usedPercent: number; - readonly resetsAt: string | null; -} - -/** What one vendor answered when asked what is left of the operator's subscription. */ -export interface SubscriptionAllowance { - readonly kind: HarnessKind; - /** The plan tier the vendor named, which is what identifies the account that answered. */ - readonly plan: string | null; - readonly windows: readonly AllowanceWindow[]; -} - -export type ReadSubscriptionAllowance = (signal?: AbortSignal) => Promise; diff --git a/packages/harness/src/subscription-usage-limit/subscription-usage-limit.const.ts b/packages/harness/src/subscription-usage-limit/subscription-usage-limit.const.ts deleted file mode 100644 index 78bcdef1..00000000 --- a/packages/harness/src/subscription-usage-limit/subscription-usage-limit.const.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * A vendor that has served the whole subscription allowance ends the run with a plain sentence on - * its error channel instead of a protocol signal. Each CLI words the refusal differently, so a spent - * allowance is recognised by the vocabulary the vendors share: an exhausted limit, a throttle, or an - * empty prepaid balance. - */ -export const SUBSCRIPTION_USAGE_LIMIT_PATTERNS: readonly RegExp[] = [ - /usage limit/iu, - /rate[\s_-]?limit/iu, - /quota/iu, - /too many requests/iu, - /\b(?:out of|insufficient|purchase more) credits?\b/iu -]; diff --git a/packages/harness/src/subscription-usage-limit/subscription-usage-limit.test.ts b/packages/harness/src/subscription-usage-limit/subscription-usage-limit.test.ts deleted file mode 100644 index 9b9715da..00000000 --- a/packages/harness/src/subscription-usage-limit/subscription-usage-limit.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { HarnessKinds } from "#src/agent-harness/agent-harness.const"; -import { HarnessErrorCodes } from "#src/cli-execution/harness-error.const"; -import { CodexTestUsageLimitMessage } from "#src/codex-cli/codex-cli.fixture"; -import { - isSubscriptionUsageLimit, - subscriptionUsageLimitError -} from "#src/subscription-usage-limit/subscription-usage-limit"; - -describe("subscription usage limit", () => { - it("recognises the refusal a vendor sends when the allowance is spent", () => { - expect(isSubscriptionUsageLimit(CodexTestUsageLimitMessage)).toBe(true); - expect(isSubscriptionUsageLimit("Claude AI usage limit reached|1786000000")).toBe(true); - expect(isSubscriptionUsageLimit("Request failed: 429 too many requests")).toBe(true); - expect(isSubscriptionUsageLimit("coding plan quota exhausted")).toBe(true); - }); - - it("leaves an ordinary run failure alone", () => { - expect(isSubscriptionUsageLimit("Codex item event is missing its item payload")).toBe( - false - ); - expect(isSubscriptionUsageLimit("apply_patch failed: file not found")).toBe(false); - }); - - it("asks the operator for the account and keeps the reset time the vendor named", () => { - const error = subscriptionUsageLimitError(HarnessKinds.CODEX, CodexTestUsageLimitMessage); - - expect(error.code).toBe(HarnessErrorCodes.SUBSCRIPTION_AUTH_REQUIRED); - expect(error.harness).toBe(HarnessKinds.CODEX); - expect(error.capabilityRequest.reason).toContain("try again at Aug 9th, 2026 6:50 PM"); - expect(error.capabilityRequest.provisioningHint).toContain("API billing is forbidden"); - }); -}); diff --git a/packages/harness/src/subscription-usage-limit/subscription-usage-limit.ts b/packages/harness/src/subscription-usage-limit/subscription-usage-limit.ts deleted file mode 100644 index 6a9fb1fb..00000000 --- a/packages/harness/src/subscription-usage-limit/subscription-usage-limit.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { HarnessKind } from "#src/agent-harness/agent-harness.const"; -import { HarnessDiagnosticLevels, HarnessEventTypes } from "#src/agent-harness/harness-event.const"; -import type { ParsedHarnessEvent } from "#src/agent-harness/harness-event-parser.types"; -import { HarnessCapabilityError } from "#src/cli-execution/harness-error"; -import { HarnessCapabilityGaps } from "#src/cli-execution/harness-error.const"; -import { SUBSCRIPTION_USAGE_LIMIT_PATTERNS } from "#src/subscription-usage-limit/subscription-usage-limit.const"; - -export function isSubscriptionUsageLimit(message: string): boolean { - return SUBSCRIPTION_USAGE_LIMIT_PATTERNS.some((pattern) => pattern.test(message)); -} - -/** The vendor sentence behind an error diagnostic that reports a spent subscription allowance. */ -export function subscriptionUsageLimitMessage(event: ParsedHarnessEvent): string | undefined { - return event.type === HarnessEventTypes.DIAGNOSTIC && - event.level === HarnessDiagnosticLevels.ERROR && - isSubscriptionUsageLimit(event.message) - ? event.message - : undefined; -} - -/** - * A spent allowance is an operator-held account problem, never a broken event stream. Reporting it - * as a capability error is what lets the daemon fall back to another subscription, ask the operator - * for the one that ran out, and hibernate on the real reason instead of on a parser complaint. The - * vendor sentence is carried through verbatim because it names when the allowance returns. - */ -export function subscriptionUsageLimitError( - harness: HarnessKind, - vendorMessage: string -): HarnessCapabilityError { - return new HarnessCapabilityError( - harness, - HarnessCapabilityGaps.ALLOWANCE, - `${harness} subscription usage limit reached: ${vendorMessage}`, - { - need: `Remaining allowance on the ${harness} product subscription`, - reason: vendorMessage, - provisioningHint: `Wait for the ${harness} allowance to reset, raise the plan, or sign in to another subscription, then retry; usage-based API billing is forbidden` - } - ); -} diff --git a/packages/protocol/package.json b/packages/protocol/package.json index b6637a3a..53a56c61 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -78,10 +78,10 @@ "./spend-caps/spend-cap.const": "./dist/spend-caps/spend-cap.const.js", "./spend-caps/spend-cap.schema": "./dist/spend-caps/spend-cap.schema.js", "./spend-caps/spend-cap.types": "./dist/spend-caps/spend-cap.types.js", - "./subscription-allowance/subscription-allowance.const": "./dist/subscription-allowance/subscription-allowance.const.js", - "./subscription-allowance/subscription-allowance.schema": "./dist/subscription-allowance/subscription-allowance.schema.js", - "./subscription-allowance/subscription-allowance.types": "./dist/subscription-allowance/subscription-allowance.types.js", - "./subscription-allowance/window-reset": "./dist/subscription-allowance/window-reset.js", + "./harness-allowance/harness-allowance.const": "./dist/harness-allowance/harness-allowance.const.js", + "./harness-allowance/harness-allowance.schema": "./dist/harness-allowance/harness-allowance.schema.js", + "./harness-allowance/harness-allowance.types": "./dist/harness-allowance/harness-allowance.types.js", + "./harness-allowance/window-reset": "./dist/harness-allowance/window-reset.js", "./verdicts/verdict.schema": "./dist/verdicts/verdict.schema.js", "./verdicts/verdict.types": "./dist/verdicts/verdict.types.js" }, @@ -159,10 +159,10 @@ "./spend-caps/spend-cap.const": "./src/spend-caps/spend-cap.const.ts", "./spend-caps/spend-cap.schema": "./src/spend-caps/spend-cap.schema.ts", "./spend-caps/spend-cap.types": "./src/spend-caps/spend-cap.types.ts", - "./subscription-allowance/subscription-allowance.const": "./src/subscription-allowance/subscription-allowance.const.ts", - "./subscription-allowance/subscription-allowance.schema": "./src/subscription-allowance/subscription-allowance.schema.ts", - "./subscription-allowance/subscription-allowance.types": "./src/subscription-allowance/subscription-allowance.types.ts", - "./subscription-allowance/window-reset": "./src/subscription-allowance/window-reset.ts", + "./harness-allowance/harness-allowance.const": "./src/harness-allowance/harness-allowance.const.ts", + "./harness-allowance/harness-allowance.schema": "./src/harness-allowance/harness-allowance.schema.ts", + "./harness-allowance/harness-allowance.types": "./src/harness-allowance/harness-allowance.types.ts", + "./harness-allowance/window-reset": "./src/harness-allowance/window-reset.ts", "./verdicts/verdict.schema": "./src/verdicts/verdict.schema.ts", "./verdicts/verdict.types": "./src/verdicts/verdict.types.ts" }, diff --git a/packages/protocol/src/subscription-allowance/subscription-allowance.const.ts b/packages/protocol/src/harness-allowance/harness-allowance.const.ts similarity index 74% rename from packages/protocol/src/subscription-allowance/subscription-allowance.const.ts rename to packages/protocol/src/harness-allowance/harness-allowance.const.ts index 91cd6014..c39a830e 100644 --- a/packages/protocol/src/subscription-allowance/subscription-allowance.const.ts +++ b/packages/protocol/src/harness-allowance/harness-allowance.const.ts @@ -3,13 +3,13 @@ * `UNREADABLE` is deliberately not `EXHAUSTED`: a monitoring call that failed says nothing about the * allowance, and stopping the lab on it would turn a broken reading into a stopped lab. */ -export const SubscriptionAllowanceState = { +export const HarnessAllowanceState = { AVAILABLE: "available", EXHAUSTED: "exhausted", UNREADABLE: "unreadable" } as const; -export type SubscriptionAllowanceState = - (typeof SubscriptionAllowanceState)[keyof typeof SubscriptionAllowanceState]; +export type HarnessAllowanceState = + (typeof HarnessAllowanceState)[keyof typeof HarnessAllowanceState]; /** The share of a window a vendor stops serving at. */ export const ALLOWANCE_EXHAUSTED_PERCENT = 100; diff --git a/packages/protocol/src/subscription-allowance/subscription-allowance.schema.ts b/packages/protocol/src/harness-allowance/harness-allowance.schema.ts similarity index 56% rename from packages/protocol/src/subscription-allowance/subscription-allowance.schema.ts rename to packages/protocol/src/harness-allowance/harness-allowance.schema.ts index 5f91ff39..1fb58f3f 100644 --- a/packages/protocol/src/subscription-allowance/subscription-allowance.schema.ts +++ b/packages/protocol/src/harness-allowance/harness-allowance.schema.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { AgentHarnessKind } from "#src/agents/agent-execution.const"; -import { SubscriptionAllowanceState } from "#src/subscription-allowance/subscription-allowance.const"; +import { HarnessAllowanceState } from "#src/harness-allowance/harness-allowance.const"; /** * One rolling window a vendor meters. The three vendors disagree on how many windows they keep and @@ -15,21 +15,23 @@ export const AllowanceWindowSchema = z.object({ }); /** - * What one subscription had left when it was last read. `windows` is empty whenever the state is + * What one harness account had left when it was last read. `windows` is empty whenever the state is * `unreadable`, so a viewer never renders a meter built from a failed reading. */ -export const SubscriptionAllowanceSchema = z.object({ +export const HarnessAllowanceSchema = z.object({ harness: z.enum(AgentHarnessKind), - state: z.enum(SubscriptionAllowanceState), + state: z.enum(HarnessAllowanceState), + /** The plan tier the vendor named. Null for a vendor that sells no tier. */ + plan: z.string().min(1).nullable(), /** - * What the vendor said the account is worth: a plan tier from a vendor that sells tiers, and the - * balance left from one that sells tokens. Either way it is the sentence that tells the operator - * which account answered and how much of it is left. + * What a token-billed account has left to spend, in the vendor's own words. Null for a + * subscription: a plan the operator already bought has no balance to run down, and showing money + * beside one would invent a number the vendor never stated. */ - plan: z.string().min(1).nullable(), + balance: z.string().min(1).nullable(), windows: z.array(AllowanceWindowSchema), error: z.string().min(1).nullable(), read_at: z.iso.datetime() }); -export const SubscriptionAllowanceRosterSchema = z.array(SubscriptionAllowanceSchema); +export const HarnessAllowanceRosterSchema = z.array(HarnessAllowanceSchema); diff --git a/packages/protocol/src/harness-allowance/harness-allowance.types.ts b/packages/protocol/src/harness-allowance/harness-allowance.types.ts new file mode 100644 index 00000000..a5758bfc --- /dev/null +++ b/packages/protocol/src/harness-allowance/harness-allowance.types.ts @@ -0,0 +1,10 @@ +import type { z } from "zod"; +import type { + AllowanceWindowSchema, + HarnessAllowanceRosterSchema, + HarnessAllowanceSchema +} from "#src/harness-allowance/harness-allowance.schema"; + +export type AllowanceWindow = z.infer; +export type HarnessAllowance = z.infer; +export type HarnessAllowanceRoster = z.infer; diff --git a/packages/protocol/src/subscription-allowance/window-reset.test.ts b/packages/protocol/src/harness-allowance/window-reset.test.ts similarity index 84% rename from packages/protocol/src/subscription-allowance/window-reset.test.ts rename to packages/protocol/src/harness-allowance/window-reset.test.ts index 5e2d9ec4..82f0c12b 100644 --- a/packages/protocol/src/subscription-allowance/window-reset.test.ts +++ b/packages/protocol/src/harness-allowance/window-reset.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { AllowanceWindow } from "#src/subscription-allowance/subscription-allowance.types"; -import { latestWindowReset } from "#src/subscription-allowance/window-reset"; +import type { AllowanceWindow } from "#src/harness-allowance/harness-allowance.types"; +import { latestWindowReset } from "#src/harness-allowance/window-reset"; function window(resetsAt: string | null): AllowanceWindow { return { duration_minutes: 300, used_percent: 100, resets_at: resetsAt }; diff --git a/packages/protocol/src/subscription-allowance/window-reset.ts b/packages/protocol/src/harness-allowance/window-reset.ts similarity index 87% rename from packages/protocol/src/subscription-allowance/window-reset.ts rename to packages/protocol/src/harness-allowance/window-reset.ts index 8c784595..982dfaa4 100644 --- a/packages/protocol/src/subscription-allowance/window-reset.ts +++ b/packages/protocol/src/harness-allowance/window-reset.ts @@ -1,4 +1,4 @@ -import type { AllowanceWindow } from "#src/subscription-allowance/subscription-allowance.types"; +import type { AllowanceWindow } from "#src/harness-allowance/harness-allowance.types"; /** * The last of these windows to come back. Whatever stopped a subscription — the vendor's own diff --git a/packages/protocol/src/harness-readiness/harness-readiness.schema.ts b/packages/protocol/src/harness-readiness/harness-readiness.schema.ts index a48e50db..506ec5df 100644 --- a/packages/protocol/src/harness-readiness/harness-readiness.schema.ts +++ b/packages/protocol/src/harness-readiness/harness-readiness.schema.ts @@ -3,9 +3,9 @@ import { AgentHarnessKind } from "#src/agents/agent-execution.const"; import { HarnessReadinessState } from "#src/harness-readiness/harness-readiness.const"; /** - * Whether one harness can run, as of the moment the lab last ran its CLI. `cli_version` and `plan` - * are what the two checks got back, so a ready harness proves itself by naming the version that - * answered and the subscription it is signed in to rather than by asserting it is fine. + * Whether one harness can run, as of the moment the lab last ran its CLI. `cli_version`, `plan` and + * `balance` are what the two checks got back, so a ready harness proves itself by naming the version + * that answered and the account it is signed in to rather than by asserting it is fine. */ export const HarnessReadinessSchema = z.object({ harness: z.enum(AgentHarnessKind), diff --git a/packages/protocol/src/spend-caps/spend-cap.test.ts b/packages/protocol/src/spend-caps/spend-cap.test.ts index c7a2dde7..bf36362a 100644 --- a/packages/protocol/src/spend-caps/spend-cap.test.ts +++ b/packages/protocol/src/spend-caps/spend-cap.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; import { AgentHarnessKind } from "#src/agents/agent-execution.const"; -import { isSpendCapLoosened, windowSpendCap, withheldWindows } from "#src/spend-caps/spend-cap"; -import { SpendCapsSchema } from "#src/spend-caps/spend-cap.schema"; -import { SubscriptionAllowanceState } from "#src/subscription-allowance/subscription-allowance.const"; +import { HarnessAllowanceState } from "#src/harness-allowance/harness-allowance.const"; import type { AllowanceWindow, - SubscriptionAllowance -} from "#src/subscription-allowance/subscription-allowance.types"; + HarnessAllowance +} from "#src/harness-allowance/harness-allowance.types"; +import { isSpendCapLoosened, windowSpendCap, withheldWindows } from "#src/spend-caps/spend-cap"; +import { SpendCapsSchema } from "#src/spend-caps/spend-cap.schema"; const FIVE_HOURS = 300; const SEVEN_DAYS = 10_080; @@ -16,11 +16,12 @@ const CAPS = SpendCapsSchema.parse([ { harness: AgentHarnessKind.CLAUDE, window_minutes: SEVEN_DAYS, max_used_percent: 60 } ]); -function claudeAllowance(windows: readonly AllowanceWindow[]): SubscriptionAllowance { +function claudeAllowance(windows: readonly AllowanceWindow[]): HarnessAllowance { return { harness: AgentHarnessKind.CLAUDE, - state: SubscriptionAllowanceState.AVAILABLE, + state: HarnessAllowanceState.AVAILABLE, plan: "max", + balance: null, windows: [...windows], error: null, read_at: "2026-08-05T09:00:00.000Z" @@ -68,10 +69,11 @@ describe("withheldWindows", () => { }); it("withholds nothing when the vendor could not be read, so broken monitoring never parks the lab", () => { - const unreadable: SubscriptionAllowance = { + const unreadable: HarnessAllowance = { harness: AgentHarnessKind.CLAUDE, - state: SubscriptionAllowanceState.UNREADABLE, + state: HarnessAllowanceState.UNREADABLE, plan: null, + balance: null, windows: [], error: "No Keychain entry", read_at: "2026-08-05T09:00:00.000Z" diff --git a/packages/protocol/src/spend-caps/spend-cap.ts b/packages/protocol/src/spend-caps/spend-cap.ts index 48b85d66..136e2ce1 100644 --- a/packages/protocol/src/spend-caps/spend-cap.ts +++ b/packages/protocol/src/spend-caps/spend-cap.ts @@ -1,10 +1,10 @@ import type { AgentHarnessKind } from "#src/agents/agent-execution.const"; -import { NO_SPEND_CAP_PERCENT } from "#src/spend-caps/spend-cap.const"; -import type { SpendCaps } from "#src/spend-caps/spend-cap.types"; import type { AllowanceWindow, - SubscriptionAllowance -} from "#src/subscription-allowance/subscription-allowance.types"; + HarnessAllowance +} from "#src/harness-allowance/harness-allowance.types"; +import { NO_SPEND_CAP_PERCENT } from "#src/spend-caps/spend-cap.const"; +import type { SpendCaps } from "#src/spend-caps/spend-cap.types"; /** How far into one window the lab may spend: the whole of it until an operator asks for less. */ export function windowSpendCap( @@ -47,10 +47,7 @@ export function isSpendCapLoosened(before: SpendCaps, after: SpendCaps): boolean * subscription: the lab keeps dispatching and learns the limit from the vendor, as it did before * caps existed. */ -export function withheldWindows( - allowance: SubscriptionAllowance, - caps: SpendCaps -): AllowanceWindow[] { +export function withheldWindows(allowance: HarnessAllowance, caps: SpendCaps): AllowanceWindow[] { return allowance.windows.filter((window) => { const cap = windowSpendCap(caps, allowance.harness, window.duration_minutes); return isCapped(cap) && window.used_percent >= cap; diff --git a/packages/protocol/src/subscription-allowance/subscription-allowance.types.ts b/packages/protocol/src/subscription-allowance/subscription-allowance.types.ts deleted file mode 100644 index afd9ce9d..00000000 --- a/packages/protocol/src/subscription-allowance/subscription-allowance.types.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { z } from "zod"; -import type { - AllowanceWindowSchema, - SubscriptionAllowanceRosterSchema, - SubscriptionAllowanceSchema -} from "#src/subscription-allowance/subscription-allowance.schema"; - -export type AllowanceWindow = z.infer; -export type SubscriptionAllowance = z.infer; -export type SubscriptionAllowanceRoster = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 831066c9..574dacd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -224,6 +224,9 @@ importers: apps/landing: dependencies: + '@openlab/protocol': + specifier: workspace:* + version: link:../../packages/protocol astro: specifier: 7.1.6 version: 7.1.6(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)(@types/node@24.13.3)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) @@ -287,6 +290,9 @@ importers: packages/harness: dependencies: + '@openlab/protocol': + specifier: workspace:* + version: link:../protocol execa: specifier: 10.0.1 version: 10.0.1