diff --git a/AGENTS.md b/AGENTS.md index fe7dba5..98c0117 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,6 +151,7 @@ comments, never another contributor's branch. Published tags are immutable. | `plugins/code/contract.ts` | Code action schemas and the policy-only typed client | | `plugins/code/context.ts` | Minimal guest context, named refusals and canonical digests | | `plugins/code/state.ts` | Container-scoped CAS state, explicit legacy adoption, the named schema-2-to-3 native migration and lazy schema-3-to-4 reads | +| `plugins/code/checks.ts` | A person's kept model checks, by exact account pool, and their standing approval of what checks spend | | `plugins/code/server.ts` | Governed Code policy handlers; no OMP proxy | | `plugins/code/workflow.ts` | React-free ordinary Code/OMP/native-deployment workflow used by web and headless clients | | `plugins/code/service-{setup,policies}.ts` | Optional external suggestion classifier only | @@ -168,7 +169,7 @@ comments, never another contributor's branch. Published tags are immutable. 1. **Manifold is the application.** Identity, grants/consent, durable storage and migrations, native resources, jobs, locations, terminals and traces stay native. Code never adds another ACL, inventory, job store, terminal transport or lifecycle supervisor. 2. **OMP owns all agent runtime behavior.** The OMP plugin alone owns broker/gateway placement, sign-in, credential storage/refresh, observations and controls, workspace operations, inventory/benchmark and session preparation. Code calls its public typed doors as the principal; it never proxies, aliases or copies them. -3. **Code state is container-scoped policy.** Canonical schema version 4 has accounts, catalog draft/active documents with their verification provenance (`null` when unverified), and selection only. Machine id is an execution choice, not a storage key. Mutations use exact native CAS. Schema version 2 is transformed by the named guest migration; schema version 3 reads as schema 4 with unverified catalogs and is persisted as schema 4 by the next CAS write, because a bulk pass would exceed the host's migration staging bound; schema version 1 is adopted only when a caller explicitly names its legacy machine and no canonical record exists. +3. **Workspace profiles are container-scoped; model checks belong to the person.** A workspace's canonical record, schema version 4, has accounts, catalog draft/active documents with their verification provenance (`null` when unverified), and selection only. A verified list a check stages is also kept as the checking person's check of its exact account pool and budget, beside that person's standing approval of what checks may spend. Both are keyed by the caller's principal, kept only for a person whose sign-in reaches the workspace root, and never read or written for anyone else. A workspace whose saved choices select a kept pool adopts the check into its own record instead of checking again, so `runSession` callers still read a workspace. Machine id is an execution choice, not a storage key. Mutations use exact native CAS. Schema version 2 is transformed by the named guest migration; schema version 3 reads as schema 4 with unverified catalogs and is persisted as schema 4 by the next CAS write, because a bulk pass would exceed the host's migration staging bound; schema version 1 is adopted only when a caller explicitly names its legacy machine and no canonical record exists. 4. **No account broadening.** Saved choices identify concrete OMP service scope plus OAuth identity or positive credential slot. Missing/stale/changed observations refuse. An operational ownership transfer may rebind only after proving the same provider, credential id and concrete identity/slot; never alias the old scope or match ambiguous email. 5. **Every effect is re-reviewed at its owner.** Code composition, OMP defaults/accounts/destination, native deployment and service policy revisions are independent. The headless workflow re-observes them immediately before effects. A checkbox, installation, retained job or terminal placement is not authority or provider success. 6. **No source staging.** Pack from immutable in-memory generated metadata and worker bytes through Manifold's `compilePlugin`; never copy source into a replaceable temporary tree. The generic development/verification order comes from finished bundle manifests, not directory names or caller summaries. diff --git a/README.md b/README.md index 4401023..9f6b9d2 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,9 @@ quota preservation and Code-owned upstream fallback repair are not planned. or wheel, each model shown by its short alias per tier, with **Defaults** and **Revert** in its head and a readout line that says what a pointed word does, including to quota where it strands a role or leads on a strained pool. A lane for - a signed-in family the model list lacks is struck, and pressing it starts **Verify - models**. Cost and speed meters sit above a launch row that names the next step - (Verify models, Save & launch, Launch) beside a dropdown of the machines it can run + a signed-in family the model list lacks is struck, and pressing it checks the models, + which finds them. Cost and speed meters sit above a launch row that names the next step + (Launch, Save & launch, Review) beside a dropdown of the machines it can run on; a machine without OMP is refused like an offline one. When the step is refused it stays focusable and the launch line names what failed (the accounts, the workspace profile, OMP missing from the machine) with a fix. **Routing** beside it lists every @@ -56,12 +56,22 @@ quota preservation and Code-owned upstream fallback repair are not planned. **Shortcuts**, with a short key line that a touch screen does not show. Launch is task-less: the task is typed in the session. Empty workspaces derive a render-only starter from OMP's passive bundled model metadata, so exploring needs no models, - credentials or runtime setup; saving starts with **Verify models**. Stored active - policy always wins. Models (the list as one ladder per provider, its verification - and charge, a staged list, import and export), Setup (one checklist of what the - machine needs, with the profile's facts and the classifier) and Options (profile + credentials or runtime setup. Stored active policy always wins. Models (the list as + one ladder per provider, its check and charge, a staged list, import and export), + Setup (one checklist of what the machine needs, with the profile's facts, the + standing approval of model checks and the classifier) and Options (profile switches, skills and automation for one launch) open as sheets. An unsaved profile survives a reload in the tab. +- **Model checks:** a launch needs a model list checked against the account pool, + and Launch runs that check itself when the list in use cannot serve it (a first + launch, a model OMP no longer lists, or a lead the accounts no longer serve), then + goes on to the review and the launch. The first check asks the person to approve its + cost, once: later checks within twice that charge run without asking, and Setup can + withdraw the approval. A finished check is kept as that person's check of the exact + account pool, so another workspace whose choices select the same pool adopts it on its + first launch, without spending. When accounts change, a check renews a list that passed one on its + own while the panel is open, within the approval, and Launch keeps working with the last + list that passed meanwhile. - **Agent launch:** the launch is still one press. Where an Agent Run can carry it, Code registers one Manifold Agent per sponsor and workspace profile with OMP's TUI harness, updates it when the profile changes, and launches a Run whose terminal is diff --git a/docs/configuration.md b/docs/configuration.md index dce3b1c..2852136 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,7 +6,8 @@ resources, jobs, locations and terminals. The independent `atyrode.omp` plugin owns account and usage observations, broker/sign-in custody, gateway policy, workspace operations, inventory, benchmark, defaults and reviewed sessions. Code owns catalogs, selections, account-pool choices, suggestions and their -container-scoped compare-and-set. The +container-scoped compare-and-set, and each person's own model checks and the standing +approval of what checks spend. The [architecture ledger](manifold-transition.md#6-transition-steps) records acceptance; the [plugin guide](../plugins/README.md) covers development. @@ -36,7 +37,9 @@ Container read/write authority is checked at every action. Native `ctx.storage.compareAndSet` commits the exact previous bytes, so concurrent edits refuse with `code_stale_preferences`. `preferences_changed` invalidates observations. A local React draft stays visible after a remote edit but is not -an authoritative store. +an authoritative store. A person's checks and approval are not shared configuration: +they are the caller's own (see the doors below), and a workspace holds a copy of a +check only once it adopts it. ## Code policy doors @@ -46,11 +49,12 @@ All Code actions are `atyrode.code.`: | --------------------- | ---------------------------------------------------------------------------- | | Configuration | `readConfiguration`, `initializeConfiguration`, `select`, `changeAccounts` | | Catalog authoring | `stageCatalog`, `reviewCatalog`, `promoteCatalog`, `discardCatalog` | +| A person's checks | `readChecks`, `approveChecks`, `adoptCheck` | | Pure OMP input policy | `composeProbe`, `draftInventory`, `deriveCatalog`, `composeSession` | | External classifier | `readServiceConfiguration`, `reviewServices`, `configureServices`, `suggest` | -`initializeConfiguration`, `stageCatalog`, `promoteCatalog`, `discardCatalog`, `select` -and `changeAccounts` take `expectedRevision`. A catalog review takes +`initializeConfiguration`, `stageCatalog`, `promoteCatalog`, `discardCatalog`, `select`, +`adoptCheck` and `changeAccounts` take `expectedRevision`. A catalog review takes `{ containerId, expectedRevision, source: "active" | "draft" }` and returns the exact catalog digest, compiled route/estimate review and `reviewDigest`. Promotion supplies that digest against the same source and revision. Discarding clears @@ -58,6 +62,38 @@ the staged list at the revision it was seen in and refuses `code_catalog_missing when none is staged; the active list, the saved team and the account choices stay as they are. +A person's model checks and their standing approval are the caller's own, keyed by +their principal and kept only for a person whose sign-in reaches the workspace root +(Code asks `containers:read` there); nobody reads or writes another person's, and an +agent, a service, or a token confined to one container or by an authority ceiling keeps +none. `stageCatalog` with a `verification` also keeps the staged list as the checking +person's check of its exact account pool and the budget it was derived under +(`verification.budget`, required), at `checks///`, +unless they already keep one whose benchmark completed no earlier, or another of their +checks of it is kept first; it stays kept whatever then happens to the stage or its +promotion, since it is still a true measurement, and the 16 most recently kept are kept. +Every stored check and approval names its principal, and a read of one naming anyone +else, or filed under another key, refuses `code_invalid_configuration`. +`readChecks({})` answers `{ keeps, revision, approvedRequests, approvedAt, checks }`: +whether the caller keeps checks at all, and each check's pool, budget, list digest, +verification provenance, when it was kept, and every model it ladders as `provider/id`; a +caller that keeps none reads `keeps: false` and none, and the panel then promises no +later check anything when it asks for a charge. +`approveChecks({ expectedRevision, requests })` records, at the approval's exact +revision, the largest charge in benchmark requests the person approved checks to spend +without asking; `requests: 0` withdraws it, a moved revision refuses +`code_stale_preferences`, a caller that is not a person `code_person_required` and a +confined sign-in `code_checks_confined`. Both ask no container capability and are +container-graded, so a token confined to one container reaches them and is answered as +keeping none. `adoptCheck({ containerId, expectedRevision, accounts, poolIdentityDigest, +budget, selection })` puts the caller's check of the workspace's present pool and that +budget in use: Code recomputes the pool from the observation and the saved choices and +refuses `code_accounts_changed` unless it is the one named, refuses `code_check_missing` +when the caller keeps no such check, `code_invalid_provenance` when the kept list names a +provider the pool does not include, and `code_check_current` when the workspace already +holds a list of that pool whose benchmark completed no earlier. It saves `selection` +(else the saved one) narrowed to the check's list, and leaves a staged list as it is. + `CatalogDocumentSchema` is `{ schemaVersion: 1, models: [...] }`. Each model has an explicit key, provider/id/API identity, tier, quota bucket, costs, performance, context, thinking levels and image support. Capabilities are 1–4. Tier 0 @@ -140,12 +176,24 @@ An unresolved catalog keeps Models repair reachable; the main view then says the model list is unavailable, with Retry and Models, whatever else the launch line says. The starter is render-only. Nothing writes it as the workspace's catalog except a -model verification, `workflow.verifyModels(target, { expectedRevision, budget })`: -the one way a Code catalog comes to name models, because only a probe shows which -this operator can call. Preparing reads the workspace at `expectedRevision` -(initializing an absent one), runs OMP's inventory with the pool Code composes from -the saved account choices, and returns the charge, the number of tiny benchmark -requests per provider, having probed and spent nothing. A catalog routes only its +model verification (the panel's **model check**), +`workflow.verifyModels(target, { expectedRevision, budget, selection, adoptOnly })`: the +one way a Code catalog comes to name models, because only a probe shows which this +operator can call. Preparing reads the workspace at `expectedRevision` (initializing an +absent one). When the caller keeps a check of the workspace's present pool, derived under +`budget`, whose benchmark completed later than that of the list the workspace holds for +that pool, or the workspace holds none for it, preparing puts that check in use +(`adoptCheck`, with `selection` narrowed to it) and ends there, spending nothing: +`{ kind: "adopted", verified }`, whose excluded models are unknown. Otherwise it runs +OMP's inventory with the pool Code composes from the saved account choices and returns +`{ kind: "charge", charge, approvedRequests, confirm }`: the charge, the number of tiny +benchmark requests per provider, and the caller's standing approval as read while +preparing, having probed and spent nothing. An `adoptOnly` run instead ends with +`{ kind: "none" }`, having started and written nothing, and never initializes an absent +workspace. A `beforeWrite` option is awaited before the run first adopts or stages, so a +caller can hold those writes, and a confirmation passed `unasked: true` (nobody pressed +for it) reads the standing approval again before its first benchmark request and stops +with `code_approval_withdrawn` unless it still covers the charge. A catalog routes only its rungs, so the charge probes every eligible model of a family Code requires (OpenAI, Anthropic), whose ladder must survive a model that does not answer, and only the rungs any other family's listing ladders (DeepSeek, OpenRouter, any other provider); @@ -164,16 +212,18 @@ that does not answer the charge exactly, and nothing laddered at all spends: one benchmark job per provider, then derive, stage, review, promote and save the selection narrowed to what the verified catalog hosts. An `inconclusive` model may be left out only where nothing already chosen needs it. When it is in the list in use as a -verification with this exact account pool made it, or belongs to a family the wanted lane -names (one provider, or OpenAI and Anthropic for the mixed lane) that the new list would not -have at all, the run stops at derive (`code_models_unanswered`, naming the models) before the -selection is narrowed, with the list and the saved selection unchanged, since an outage or a -rate limit reads the same as a model the account cannot use. A list verified with other -accounts, or never verified, holds no such claim. Every step re-observes the +verification with this exact account pool made it, or in the caller's own check of that +pool, or belongs to a family the wanted lane names (one provider, or OpenAI and Anthropic +for the mixed lane) that the new list would not have at all, the run stops at derive +(`code_models_unanswered`, naming the models) before the selection is narrowed, with the +list and the saved selection unchanged, since an outage or a rate limit reads the same as +a model the account cannot use. A list verified with other accounts, or never verified, +holds no such claim. Every step re-observes the revision, the exact account pool, OMP defaults and OMP itself, and stops, naming itself, when one moved, on a refusal or on cancellation. A stop never undoes: probe jobs stay in OMP's history and a staged catalog stays staged. A confirmation is used once, and a failed -or uncertain one is never replayed. The promoted catalog records its verification +or uncertain one is never replayed. Staging keeps the list as the checking person's +check of its pool and budget (see the doors above), and the promoted catalog records its verification provenance: the OMP its inventory ran under, as content identities — the artifact the inventory job ran from (`inventoryArtifactSha256`) and the revision of OMP's bundled model catalog when the inventory started (`catalogRevision`), the list the inventory picks its @@ -195,10 +245,10 @@ unverified. An identity not yet observed, or whose read failed, claims no change destination's failure keeps the launch refused, a failed model list read is named in the launch line with Retry and Models, and a failed read of what a moved catalog publishes names nothing and leaves a dropped model to the session door, which re-checks that every routed -model is still published (`code_model_unpublished`). A current verification can still be -renewed: Verify needs only its own preconditions +model is still published (`code_model_unpublished`). A list in use that holds can still be +checked again: a check needs only its own preconditions (write access, the machine, readable accounts that include one, discovery and benchmark -readiness), and its charge still waits on Confirm. A model that draws a +readiness), and its charge asks unless the person's standing approval covers it. A model that draws a quota of its own (any OMP class but chat, Spark's for one, as each inventory row states) is left off the derived ladder and listed among the last verification's excluded models as `separate_quota` with OMP's class for it (`quotaTier`), since Code spends no such quota. @@ -328,8 +378,9 @@ routing or usage is hidden, **Show routing** and **Show usage** stand in this he whose lanes the included accounts can run stays, struck, with the domain's reason. A family someone has signed in for but the model list has no model of keeps its lead, struck, with "No models in your model list"; - pressing it starts **Verify models** (through its own gate, spending nothing before - Confirm charge) and the readout says it finds the models the accounts reach. + pressing it checks the models (through the check's own gate, spending nothing before + Approve unless the person approved checks) and the readout says it finds the models the + accounts reach. - **Model** is the capability tier (fast, normal, smart, elite), each tier read as the model alias its `default` role would lead on in the profile that choosing it forms, with the tier's word under the alias, so a tier a short family fills reads as the real @@ -396,7 +447,7 @@ lit to the level in the lane's accent, and the level's word while pointed. Cost role-weighted relative list-price index, lowest to highest, not billing; subscriptions spend quota windows, which usage shows. Speed comes from measured throughput and first-token time, and only when every lead model has them: otherwise its glyphs stay -unlit and the readout says "unmeasured · verifying models measures it", rather than +unlit and the readout says "unmeasured · a model check measures it", rather than showing the index's middle level. ### The launch @@ -405,10 +456,11 @@ The launch row reads `↵ on `: the **launch** button, the m dropdown, and the **launch line** (below) beside them. The launch row follows the generator's rows, and the launch line sits beside the launch, or under it when it has no room beside it, and grows downward, so the launch never moves under the pointer while its -line changes. The step is the label of the next step in lower case: **verify models**, -**review**, **save**, **save & review**, **save & launch**, **launch**, **launch -anyway** or **review in Models**, and **checking…**, **verifying…**, **saving…**, -**reviewing…**, **launching…**, **resuming…** or **working…** while one runs. +line changes. The step is the label of the next step in lower case: **launch**, +**save & launch**, **review**, **save**, **save & review**, **launch anyway** or +**review in Models**, and **checking models…**, **saving…**, **reviewing…**, +**launching…**, **resuming…** or **working…** while one runs. There is no separate verify +step: when the list in use cannot serve a launch, the launch checks the models first. **The machine.** The dropdown shows the machine's name, a neutral dot that shows whether it is online and a chevron. It opens a list of the machine roster above the @@ -416,7 +468,7 @@ launch row; click, or `w`, opens it with focus in the list. `↑` `↓`, `Home`, `↵` or `Space` choose; `Esc`, `Tab` or a press outside closes it and returns focus to the dropdown. While open the list owns its keys, so the panel's keys do nothing. Choosing goes through the edit-machine gate: while a step runs or a charge waits the -dropdown refuses with the gate's reason and leaves the Tab order, so Confirm charge is +dropdown refuses with the gate's reason and leaves the Tab order, so Approve is the next Tab stop after the launch. An offline or access-revoked machine is struck and refuses with its reason, and so is a machine OMP is not installed on ("no OMP here"), which can run nothing. Code checks every online machine for OMP, and a browser starts @@ -428,18 +480,19 @@ every online machine has answered. The readout says what the pointed machine is the team, and is not recorded in recent profiles. **The verb.** The launch is one control whose `data-state` is `ready`, `busy`, -`waiting` (a charge waits on its own Confirm) or `refused`. It is `aria-disabled` +`waiting` (a check's charge waits on its own Approve) or `refused`. It is `aria-disabled` rather than disabled and stays focusable, so a step changing its state never drops focus, and a launch-line control that leaves once pressed hands focus to the launch; pressing a refused one shakes it, says the reason in the readout and announces it. A read-only workspace (no `containers:write`) is stated in neutral grey, "Read-only workspace" (its readout adds "changes stay a local preview"). Write access is the caps -alone: without a canvas beside the panel, verifying, saving and account edits still +alone: without a canvas beside the panel, checking models, saving and account edits still work, and only launch and resume refuse, in neutral grey, "Open Code beside the workspace canvas to launch". **Launch anyway** is offered when a role's pools are out, never for a lead no included account serves, which save, review and launch all refuse. -**Review in Models** opens Models for a staged-only workspace. Verify is refused up -front when the account observation cannot be read or includes no account. An edited +**Review in Models** opens Models for a staged-only workspace. A launch that must check +first is refused up front when the account observation cannot be read or includes no +account. An edited team saves first; while the launch is pointed or pressed the readout says what the save changes ("2 changes to the profile: thinking high → max, advisor glance → off"), and **Revert** discards the edit. **Save & launch** is offered when the projection rests @@ -461,13 +514,15 @@ says the same ("live dials in Sessions" or the reason). Setup's sessions row, an **The launch line** sits beside the launch, or under it where there is no room beside it, and keeps to facts and one-press fixes, each -a button; its asides ("changes stay a local preview", "nothing is spent until you -confirm", and, while a staged model list waits beside the active one, "a staged model -list waits in Models") are said in the readout while the launch is pointed or pressed. It is written in this -precedence: a verification running or its charge, a step in flight, the verb's refusal -with its fix, a staged model list the verb opens, a review that differs from what was -projected, a failure, the last outcome, and roles with no route with the one change -that routes them (said once verifying is no longer the next step). A model list that +a button; its asides ("changes stay a local preview", "asked once: later checks run on +their own", "checking models in the background", and, while a staged model list waits +beside the active one, "a staged model list waits in Models") are said in the readout +while the launch is pointed or pressed. It is written in this +precedence: a check the person started (or one the launch waits on) running or its +charge, a step in flight, the verb's refusal with its fix, a staged model list the verb +opens, a review that differs from what was projected or a check that changed the +profile, a failure, the last outcome, and roles with no route with the one change that +routes them (said once no check precedes the launch). A model list that cannot be read joins the line from the verb's refusal on, with its Retry and Models. The line names what failed rather than a general refusal: @@ -484,8 +539,8 @@ The line names what failed rather than a general refusal: with **Retry**; "The workspace profile changed elsewhere" with **Use theirs**. - **OMP.** "OMP isn't on " when the destination refuses OMP's actions outright, in place of the readiness and permission refusals it would otherwise cause, - with **Use ** for another online machine where OMP answers. "Verification - readiness unknown on " is kept for a read that really failed. + with **Use ** for another online machine where OMP answers. "Model checks + unknown on " is kept for a read that really failed. - **Machine.** " is offline", "access revoked", "is unavailable", "The chosen machine is not in your machine list" or "Machine list unreadable", each with **Use ** or **Retry** where one applies. @@ -497,27 +552,66 @@ The line names what failed rather than a general refusal: A fix that opens a place answers to that place's key too. -**Verification and its charge.** **Verify models** runs OMP's inventory ("Checking -which models your accounts can reach"), then states the charge: "Verifying spends N tiny -requests", per provider, with **Confirm charge** and cancel. A charge of nothing says -"Nothing to verify through these accounts" and cannot be confirmed. Confirm is a Tab +**Model checks and their charge.** A launch needs a model list checked against the +account pool (see the starter and verification above). The list in use serves a launch +when its check holds, or when it passed one and only the accounts changed since without +leaving a lead no included account serves; a background check then renews it while +the launch stays ready (below). Otherwise (no list yet, an imported list, a routed model +OMP no longer lists, or a lead the changed accounts no longer serve) the step is a check +the launch runs first, under the **launch** (or **save & launch** when the rows hold an +edit) label, and the status sentence says why: "Launch first checks which models your +accounts can use.", "Accounts changed since the models were checked; Launch checks them +again first." or "OMP no longer lists a model this profile uses; Launch checks the models +again first." The press reads **checking models…**: it puts the person's own newer +check of the pool in use when they keep one, spending nothing, or runs OMP's inventory +("Checking which models your accounts can use") and states the charge: "Checking models +spends N tiny requests", per provider. A charge the person's standing approval covers +(up to twice the largest charge they approved) is spent at once; any other waits with +**approve** and cancel, the aside saying it is asked once. A charge of nothing says +"Nothing to check through these accounts" and cannot be approved. Approve is a Tab stop after the launch, is never focused for the person, and spends only the very charge -it showed: never one of zero requests, never before the Verify press has stayed in its +it showed: never one of zero requests, never before the press has stayed in its checking state for 700 ms, and only on a deliberate single press, never the second -click of a double-click or a held key. Progress reads "N of M requests". A verification -that stopped or was cancelled says so until the next one starts; Verify models again is -its retry. - -Once the saved, verified team meets every launch precondition and its review inputs -(options, accounts, revisions, destination) have held still briefly, the workbench -requests the launch review itself, so Launch is one press. It never does so for an -unsaved edit, a starter or a staged catalog, and at most once per review scope: a -refused review or launch waits for an explicit Review or a changed input. - -One gate decides every action: the launch, saving, verifying, confirming the charge, +click of a double-click or a held key. It records the standing approval (the larger of +the one held and this charge) before spending; one that cannot be recorded still spends +this charge, and the next check asks again. Progress reads "Checking models with your +accounts · N of M requests". When the check is done the press goes on: it reviews the +saved team and launches it, unless the check narrowed a setting of the profile it was +pressed on, which stops the press with "The model check changed the profile" and the +settings it moved, as a choice. A check that stopped or was cancelled says so ("Model +check stopped while : ", "Model check cancelled") until the next one +starts; the launch is its retry. + +**Background checks.** The panel starts a check on its own when the workspace has been +set up, the list in use no longer holds, no staged list waits, the list in use is not an +imported one, the rows hold no edit, nothing else is in flight and a check could run +here, and either the person keeps a newer check of the pool and budget to adopt, or the +list passed a check and the person approved checks. A workspace never set up is left to +its first Launch press, which adopts a kept check of its pool without asking or +spending. A background check never asks, and spends only to renew a list that passed a +check, within the approval as read again just before spending: any other charge ends it +with nothing spent, and for a list that never passed a check it only adopts, starting no +inventory, so a first check that spends is always the person's own press. It holds its +first write until nothing the person started is in flight. It never takes the verb or +the line beside a launch the list in use can serve, and a stop is said in Models only. +After any check stops, is cancelled or ends unspent, nothing starts on its own again +while the panel stays open until a check the person starts completes. An edit of the +rows cancels one that has not started writing; while it writes the model list the gate +holds edits as a step in progress. **ask first** in Setup withdraws the approval: a check +the person did not approve with a press then spends nothing more, asking instead (or, in +the background, ending unspent) if it is still listing models, and stopping if it is +measuring them; one already past measuring keeps what it paid for. + +Once the saved team on a list that serves a launch meets every launch precondition and +its review inputs (options, accounts, revisions, destination) have held still briefly, +the workbench requests the launch review itself, so Launch is one press. It never does +so for an unsaved edit, a starter or a staged catalog, and at most once per review +scope: a refused review or launch waits for an explicit Review or a changed input. + +One gate decides every action: the launch, saving, checking models, approving a charge, resuming with or without the profile, opening a running session, and every edit of the team (including a recalled profile and a profile switch), the machine or the account -pool. Nothing starts while a step runs, and only Confirm while a charge waits; edits +pool. Nothing starts while a step runs, and only Approve while a charge waits; edits wait too, and a row still answers but refuses each choice with the gate's reason. A chain asks the gate again before each later step (save, then review once the save is observed; prepare, then open the terminal), so a team, machine, pool or authority @@ -781,14 +875,14 @@ one the family offers names the rung the model row lands on instead, while one a family's top (an elite it lacks) says only that it has no model, as the model row refuses it. A family Code requires that lacks a rung of its own at fast, normal or smart reads **· short** beside its name. The head says -how many models are in use and when they were verified, with **OMP updated since** when +how many models are in use and when they were checked, with **OMP updated since** when OMP's runtime or catalog moved after that, or that accounts changed or OMP dropped a routed -model since they were verified, that a staged list waits beside them, that they are -verifying, or that they are unverified or the bundled starter, with **import** +model since they were checked, that a staged list waits beside them, that they are being +checked, or that they are not checked or the bundled starter, with **import** (`i`) and **export** (`x`) as two quiet words. Beside the list is what the step is about: the charge per provider and the models not probed while -verifying, a staged list's changes (field by field, a model added or dropped, the old -value struck), or, after a verification in this panel, the models it left out, each +checking, a staged list's changes (field by field, a model added or dropped, the old +value struck), or, after a check measured in this panel, the models it left out, each with its reason in a word: a model the accounts' plan, their settings or this client excludes (`client_blocked`) reads **not served**, one no account finds (`not_found`) **not reachable**, one whose check got no clear answer (`inconclusive`) **no answer**, a model in @@ -796,29 +890,31 @@ OMP's `spark` quota class **retired** and any other class of its own **own quota prices, speeds, thinking ranges and reasons appear only in the readout line while a cell, a provider or a left-out model is pointed or focused. -One next action sits where the launch sits and follows the step: **verify models**, -**checking models…** (drawn with the checking hold the charge keeps), **confirm charge** -(a single deliberate press of the exact charge shown, as in the launch line), **verifying…** -with the requests measured, then **back to code** with **verify again** beside it, the -same Verify a current verification can always renew, its charge confirmed the same way; -a verification again that stopped reads **verification stopped** beside it, with its +One next action sits where the launch sits and follows the step: **check models**, +**checking models…** (drawn with the checking hold the charge keeps), **approve** +(a single deliberate press of the exact charge shown, as in the launch line, recording +the standing approval), **checking…** with the requests measured, then **back to code** +with **check again** beside it, the same check a list that holds can always renew, which +spends without asking within the standing approval; a check again that stopped, one the +panel ran on its own included, reads **check stopped** beside it, with its reason in the readout, since what it would have changed (the list, or for a stop while -saving the profile, the team) stays as it was. +saving the profile, the team) stays as it was. An adopted check measured nothing here, +so nothing reads as left out after it. A staged list offers **use staged list**, which reviews the staged list at its revision and promotes exactly that review with the list's own default team, and **discard**. An unsaved edit made on the list a write would replace holds it, saying what the write does to that list (putting a staged list in use resets the profile to the staged list's default), with **save** (an edit of the team in use, kept on the list in use) and **discard edit** beside it. -Verifying refused for want of +Checking refused for want of discovery offers **enable in setup**; another refusal names itself with its fix (show accounts, retry, open Setup, use theirs). Esc cancels a run that can still be stopped, and otherwise returns to Code. Import takes a pasted or dropped Code model list, checks that it compiles, and stages it beside the list in use, initializing an absent workspace -first; nothing is verified or put in use until **use staged list**, and a list put in -use that way is unverified. An import over a staged list replaces it, a verified one +first; nothing is checked or put in use until **use staged list**, and a list put in +use that way is not checked. An import over a staged list replaces it, a checked one included, and says so in its dialog and in the readout once staged. Export copies the list in use. The per-field editor, the -separate discovery and benchmark controls and job recovery are gone: verification runs +separate discovery and benchmark controls and job recovery are gone: a check runs discovery and benchmark as one step, and OMP's own history keeps every job. **Setup** (`u`) is one checklist for the chosen machine: machine, OMP, connection, @@ -831,8 +927,9 @@ folders row's **existing** and **new** route), each review opening the native pe review of what it allows; once every row is ready, **back to code**. Folders count as prepared only by an OMP job matching the machine's current operation, installation, artifact and bindings. Beside the checklist are the profile's facts (revision, the -models in use and when verified, what the last verification left out, the source and -the workspace) and the optional classifier: **suggestions** off or ollama, with an +models in use and when checked, what the last check left out, the source, the standing +approval of checks with **ask first** to withdraw it, and the workspace) and the optional +classifier: **suggestions** off or ollama, with an origin and a model, reviewed then applied through `reviewServices` and `configureServices` as the next action; only the instance owner configures it. Destination changes still invalidate native reviews and clear destination-specific @@ -858,9 +955,10 @@ Manifold action transport as the web. It composes `createCodeClient` with exact pins, then prepares the reviewed job. It never falls back from failed validation to creation. - **Inventory and benchmark:** read OMP defaults and accounts, compose the Code - pool, start OMP's job, then read OMP's typed retained receipt. Benchmark binds - the inventory job; derived catalog data is staged only through a later - explicit Code CAS. + pool and read the caller's own checks; a newer kept check of that pool and budget is + adopted instead, with no job. Otherwise start OMP's job, then read OMP's typed + retained receipt. Benchmark binds the inventory job; derived catalog data is staged + only through a later explicit Code CAS, which keeps it as the caller's check. - **Session:** compose Code policy and read OMP defaults, request OMP's native review, then re-compose/re-read before prepare. Changed Code digest or defaults revision refuses. Only OMP's returned destination, review digest and diff --git a/docs/manifold-transition.md b/docs/manifold-transition.md index 04e8930..f413f59 100644 --- a/docs/manifold-transition.md +++ b/docs/manifold-transition.md @@ -34,7 +34,7 @@ and [status.md](status.md) records caveats. Section 6 is the sole progress ledge | Catalogs, ladders, selection, routing, estimates, account-choice and usage projection | Code's typed `plugins/domain/` modules; no recovery from terminal output | | Human and headless access | React surfaces and `createCodeWorkflowClient` use the same ordinary Manifold action transport across Code and OMP | | Fleet, identity, grants and consent | Manifold's canonical principal/machine/container/resource scopes; no Code inventory, ACL or label-based retargeting | -| Durable product state and concurrency | Manifold plugin storage/migrations; Code owns one container record committed by native CAS | +| Durable product state and concurrency | Manifold plugin storage/migrations; Code owns one record per container, plus each person's own model checks and check approval keyed by principal, all committed by native CAS | | Broker, gateway, provider observations and credential controls | Independent `atyrode.omp` root/accounts/gateway plugins; Code receives typed permitted results only | | Artifacts, bindings and installation | Manifold native resources and exact reviewed revisions; OMP alone declares its runtime artifacts | | Jobs, cancellation, retained results and provenance | Manifold's native execution/history, invoked through OMP doors; no Code job database, proxy or second ledger | diff --git a/docs/status.md b/docs/status.md index e4982cd..741ffab 100644 --- a/docs/status.md +++ b/docs/status.md @@ -65,10 +65,12 @@ files, service state, credentials or backups. - The first workbench derives a frozen local starter from OMP's passive bundled SDK model metadata after a successful absent or initialized-empty configuration read. The generator's rows and routing need no account or runtime setup. The - starter is render-only: saving it is the model verification (OMP inventory - through the saved account pool, a charge the operator confirms, per-provider - benchmarks, then the derived catalog staged, reviewed, promoted and saved with - the selection). Stored active policy wins and unrecorded historical provenance + starter is render-only: saving it is the model check the first Launch press runs + (OMP inventory through the saved account pool, a charge the operator approves once + per person, per-provider benchmarks, then the derived catalog staged, which keeps it + as the person's check of that pool and budget, reviewed, promoted and saved with the + selection), or the person's kept check of that pool, adopted without spending. Stored active policy + wins and unrecorded historical provenance stays unknown. Read-only native panels remain locally explorable, not writable. Disposable presentation/CAS proof is not native consent, configured accounts, provider execution, a release or authenticated preview acceptance. @@ -215,8 +217,8 @@ files, service state, credentials or backups. still publishes every routed model, leaves it current and is noted in Models; it goes stale when a moved catalog no longer publishes a routed model, or the saved choices select another pool, with no Code rebuild. A verification recorded before these identities, without them, - reads as unverified and asks to be verified again, and a current one can be renewed on - purpose. Unit tests prove the rule against a synthetic destination and catalog, and the + reads as unverified, so the next launch checks the models first, and a current one can be + renewed on purpose. Unit tests prove the rule against a synthetic destination and catalog, and the disposable browser against the real bundled catalog: it moves a verified list's runtime artifact, then serves a synthetic republish of that catalog without the list's default lead. Applied to the preview workspace's live reads (destination pins, catalog revision and account diff --git a/plugins/code/checks.ts b/plugins/code/checks.ts new file mode 100644 index 0000000..2463a97 --- /dev/null +++ b/plugins/code/checks.ts @@ -0,0 +1,158 @@ +import { z } from "zod"; +import { epochMilliseconds, probeAddress } from "@atyrode/manifold-omp"; +import { selectedAccountPool } from "../domain/accounts.ts"; +import { compileCatalog } from "../domain/catalog.ts"; +import { CatalogDocumentSchema, DomainError, SelectionSchema, type CatalogDocument, type Selection } from "../domain/contracts.ts"; +import { clampSelection } from "../domain/routing.ts"; +import { ChecksSchema, MAX_CHECKS, VerificationProvenanceSchema, digest, id, revision, type ActionInput, type Checks, + type Configuration, type ModelCheck, type VerificationProvenance } from "./contract.ts"; +import { CodeRefusal, digestOf, type CodeContext } from "./context.ts"; +import { commitConfiguration, expectRevision, poolIdentity, readConfiguration, requireConfiguration } from "./state.ts"; + +/* + A PERSON'S MODEL CHECKS. Which models answer through an account pool is a fact about those + accounts, not about the workspace a check happened to run in, so a list a verification stages is + also kept for the person who checked it, under the exact pool it was verified with and the budget + it was derived under, and any workspace whose saved choices select that pool adopts it instead of + spending another check. The person's standing approval of what checks spend lives beside them. + Both are keyed by the caller's own principal: nobody reads or writes another person's, and an + agent, a service or a token confined to one container keeps none. Workspaces keep their own + copies, so a `runSession` caller still reads a workspace. +*/ + +type Budget = Selection["budget"]; +const StoredCheckSchema = z.strictObject({ + schemaVersion: z.literal(1), principalId: id, poolIdentityDigest: digest, budget: SelectionSchema.shape.budget, + document: CatalogDocumentSchema, digest, provenance: VerificationProvenanceSchema, recordedAt: epochMilliseconds, +}); +type StoredCheck = z.infer; +const StoredApprovalSchema = z.strictObject({ + schemaVersion: z.literal(1), principalId: id, revision: revision.positive(), + requests: ChecksSchema.shape.approvedRequests, approvedAt: epochMilliseconds, +}); +/** Plugin storage holds at most this many bytes per value (plugin-kit `MAX_STORAGE_VALUE_BYTES`). */ +const MAX_VALUE_BYTES = 64 << 10; + +/** Why a caller keeps no checks: not a person, or a person whose sign-in reaches less than the whole workspace tree. */ +type Keeper = { person: string } | { refused: "person_required" | "checks_confined" }; +/** + * The caller's records are filed under a digest of their principal, cut to 40 hex characters so a + * check's key (`checks///`) stays within the 128 characters plugin storage + * keys may have; every record also names its principal, which every read compares. Only a person + * whose sign-in reaches the workspace root keeps checks, asked where an unconfined credential is + * anchored: a token confined to one container, or by an authority ceiling, keeps none, since a + * check and the approval to spend on checks are the person's own, never one container's. + */ +async function keeperOf(ctx: CodeContext): Promise { + if (ctx.auth.principal.kind !== "human") return { refused: "person_required" }; + if (ctx.auth.containerScope !== null || !(await ctx.auth.allows("containers:read"))) return { refused: "checks_confined" }; + return { person: digestOf({ principalId: ctx.auth.principal.id }).slice(0, 40) }; +} +async function personOf(ctx: CodeContext): Promise { + const keeper = await keeperOf(ctx); + return "person" in keeper ? keeper.person : null; +} +const checkKey = (person: string, budget: Budget, pool: string) => `checks/${person}/${budget}/${pool}`; +const approvalKey = (person: string) => `approvals/${person}`; + +/** A stored record that names another principal, or another key than its own, is corruption, never someone's data. */ +function bound(ctx: CodeContext, record: T): T { + if (record.principalId !== ctx.auth.principal.id) throw new CodeRefusal("invalid_configuration"); + return record; +} +async function readApproval(ctx: CodeContext, person: string) { + const raw = await ctx.storage.get(approvalKey(person)); + return raw === null ? null : { raw, approval: bound(ctx, StoredApprovalSchema.parse(JSON.parse(raw))) }; +} +function parseCheck(ctx: CodeContext, person: string, key: string, raw: string): StoredCheck { + const check = bound(ctx, StoredCheckSchema.parse(JSON.parse(raw))); + if (key !== checkKey(person, check.budget, check.poolIdentityDigest)) throw new CodeRefusal("invalid_configuration"); + return check; +} +async function readCheck(ctx: CodeContext, person: string, budget: Budget, pool: string) { + const key = checkKey(person, budget, pool), raw = await ctx.storage.get(key); + return raw === null ? null : { raw, check: parseCheck(ctx, person, key, raw) }; +} +async function storedChecks(ctx: CodeContext, person: string): Promise { + const checks: StoredCheck[] = []; + for (const key of await ctx.storage.keys(`checks/${person}/`)) { + const raw = await ctx.storage.get(key); + if (raw !== null) checks.push(parseCheck(ctx, person, key, raw)); + } + return checks.sort((left, right) => right.recordedAt - left.recordedAt); +} +/** Every model a list ladders, as `provider/id`. Spark's retired rung (tier 0) is never routed, so no later check needs to keep it. */ +function checkedModels(document: CatalogDocument): string[] { + return [...new Set(document.models.filter(model => model.tier !== 0).map(probeAddress))].sort(); +} +function summary(check: StoredCheck): ModelCheck { + return { poolIdentityDigest: check.poolIdentityDigest, budget: check.budget, digest: check.digest, provenance: check.provenance, + recordedAt: check.recordedAt, models: checkedModels(check.document) }; +} + +export async function readChecks(ctx: CodeContext): Promise { + const person = await personOf(ctx); + if (person === null) return { keeps: false, revision: 0, approvedRequests: 0, approvedAt: null, checks: [] }; + const [held, checks] = await Promise.all([readApproval(ctx, person), storedChecks(ctx, person)]); + return { keeps: true, revision: held?.approval.revision ?? 0, approvedRequests: held?.approval.requests ?? 0, approvedAt: held?.approval.approvedAt ?? null, + checks: checks.slice(0, MAX_CHECKS).map(summary) }; +} + +/** Records how much checks may spend without asking, at the approval's exact revision; `requests: 0` withdraws it. */ +export async function approveChecks(ctx: CodeContext, args: ActionInput<"approveChecks">): Promise { + const keeper = await keeperOf(ctx); + if (!("person" in keeper)) throw new CodeRefusal(keeper.refused); + const { person } = keeper; + const held = await readApproval(ctx, person); + if ((held?.approval.revision ?? 0) !== args.expectedRevision) throw new CodeRefusal("stale_preferences"); + const next = StoredApprovalSchema.parse({ schemaVersion: 1, principalId: ctx.auth.principal.id, revision: args.expectedRevision + 1, + requests: args.requests, approvedAt: ctx.now() }); + if (!(await ctx.storage.compareAndSet(approvalKey(person), held?.raw ?? null, JSON.stringify(next)))) throw new CodeRefusal("stale_preferences"); + return readChecks(ctx); +} + +/** + * Keeps a verified list being staged as the checking person's check of its pool and budget, unless + * they already keep one whose benchmark completed no earlier. Only the most recent `MAX_CHECKS` are + * kept. A caller that keeps no checks records nothing, and neither does a write that loses a race + * with another of the person's checks: the stage itself is never refused for it. + */ +export async function recordCheck(ctx: CodeContext, list: { document: CatalogDocument; digest: string; provenance: VerificationProvenance }, + budget: Budget): Promise { + const person = await personOf(ctx); + if (person === null) return; + const pool = list.provenance.poolIdentityDigest, key = checkKey(person, budget, pool); + const held = await readCheck(ctx, person, budget, pool); + if (held !== null && held.check.provenance.benchmarkCompletedAt >= list.provenance.benchmarkCompletedAt) return; + const encoded = JSON.stringify(StoredCheckSchema.parse({ schemaVersion: 1, principalId: ctx.auth.principal.id, poolIdentityDigest: pool, budget, + document: list.document, digest: list.digest, provenance: list.provenance, recordedAt: ctx.now() })); + if (Buffer.byteLength(encoded) > MAX_VALUE_BYTES) throw new CodeRefusal("input_too_large"); + if (!(await ctx.storage.compareAndSet(key, held?.raw ?? null, encoded))) return; + for (const stale of (await storedChecks(ctx, person)).slice(MAX_CHECKS)) await ctx.storage.delete(checkKey(person, stale.budget, stale.poolIdentityDigest)); +} + +/** + * Puts the caller's check of the workspace's present pool, derived under `budget`, in use: the pool + * is recomputed from the observation and the saved choices and must be the one the caller found a + * check for. A list the workspace already holds for that pool, measured no earlier, refuses + * `check_current`; the workspace's staged list stays as it is. + */ +export async function adoptCheck(ctx: CodeContext, args: ActionInput<"adoptCheck">): Promise { + const previous = await readConfiguration(ctx, args, true); expectRevision(previous, args.expectedRevision); + const record = requireConfiguration(previous); + if (args.accounts.observedAt !== null && args.accounts.observedAt > ctx.now()) throw new DomainError("invalid_accounts"); + const pool = poolIdentity(selectedAccountPool(args.accounts, record.accounts)); + if (pool.providers.length === 0) throw new CodeRefusal("account_unavailable"); + if (pool.poolIdentityDigest !== args.poolIdentityDigest) throw new CodeRefusal("accounts_changed"); + const person = await personOf(ctx); + const held = person === null ? null : await readCheck(ctx, person, args.budget, pool.poolIdentityDigest); + if (held === null) throw new CodeRefusal("check_missing"); + const { check } = held, inUse = record.active?.provenance; + // The key binds the check to this pool; a list naming a provider the pool does not cover is still never put in use. + if (check.document.models.some(model => !pool.providers.includes(model.provider))) throw new CodeRefusal("invalid_provenance"); + if (inUse && inUse.poolIdentityDigest === check.poolIdentityDigest && inUse.benchmarkCompletedAt >= check.provenance.benchmarkCompletedAt) + throw new CodeRefusal("check_current"); + const selection = clampSelection(compileCatalog(check.document), args.selection ?? record.selection); + return commitConfiguration(ctx, previous, { ...record, + active: { document: check.document, digest: check.digest, provenance: check.provenance }, selection }); +} diff --git a/plugins/code/contract.ts b/plugins/code/contract.ts index d703db7..ce3487f 100644 --- a/plugins/code/contract.ts +++ b/plugins/code/contract.ts @@ -81,7 +81,39 @@ export const CatalogVerificationSchema = z.strictObject({ benchmarkCompletedAt: epochMilliseconds, accounts: AccountsObservationSchema, poolIdentityDigest: digest, + // The budget the list was derived under, which the person's kept check of the pool is filed by. + budget: SelectionSchema.shape.budget, }); +/** How many pools a person's checks are kept for: the most recently recorded ones. A pool not kept is checked again when next used. */ +export const MAX_CHECKS = 16; +/** The largest charge, in benchmark requests, a person can approve checks to run without asking for. */ +export const MAX_APPROVED_REQUESTS = 10_000; +/** + * ONE OF A PERSON'S MODEL CHECKS, as its summary: a verified list a verification staged, kept for + * the person who checked it under the exact account pool it was verified with + * (`poolIdentityDigest`) and the budget it was derived under, so that any workspace whose saved + * choices select that pool, at that budget, adopts it instead of spending another check. `digest` + * is the list's, `recordedAt` when it was kept, and `models` every model it ladders, as + * `provider/id`: what a later check of the same pool may not drop merely because the model gave no + * clear answer. + */ +export const ModelCheckSchema = z.strictObject({ + poolIdentityDigest: digest, budget: SelectionSchema.shape.budget, digest, provenance: VerificationProvenanceSchema, recordedAt: epochMilliseconds, + models: z.array(z.string().min(3).max(512)).max(512), +}); +export type ModelCheck = z.infer; +/** + * A PERSON'S CHECKS AND THEIR STANDING APPROVAL. `keeps` says whether the caller keeps any at all: + * false for an agent, a service or a confined sign-in, whose every check asks. `approvedRequests` + * is the largest charge they approved for checks that then run without asking (0: every check + * asks), `revision` the approval's own compare-and-set revision (0 before they ever answered), and + * `checks` the kept checks, newest first. + */ +export const ChecksSchema = z.strictObject({ + keeps: z.boolean(), revision, approvedRequests: z.number().int().nonnegative().max(MAX_APPROVED_REQUESTS), approvedAt: epochMilliseconds.nullable(), + checks: z.array(ModelCheckSchema).max(MAX_CHECKS), +}); +export type Checks = z.infer; /** The prompt bound is OMP's, in bytes, because the prompt reaches the machine as one entry * of the job input map the hub bounds (`PROMPT_MAX_BYTES`, 44 KiB). Code takes the schema * itself rather than the number, so the two can never disagree. */ @@ -218,6 +250,14 @@ export const rootActionSchemas = { // Clears the staged list, at the revision it was seen in; the list in use and the saved team stay as they are. discardCatalog: { input: RevisionWorkspaceSchema, result: ConfigurationSchema }, select: { input: RevisionWorkspaceSchema.extend({ selection: SelectionSchema }), result: ConfigurationSchema }, + // The caller's own checks and approval; nobody reads or writes another person's. + readChecks: { input: z.strictObject({}), result: ChecksSchema }, + // `requests: 0` withdraws the approval, so every later check asks again. + approveChecks: { input: z.strictObject({ expectedRevision: revision, requests: ChecksSchema.shape.approvedRequests }), result: ChecksSchema }, + // Puts the caller's check of the workspace's present pool, derived under `budget`, in use, with `selection` (else the saved + // one) narrowed to it. `poolIdentityDigest` is the pool the caller found a check for; Code recomputes the pool and refuses any other. + adoptCheck: { input: RevisionWorkspaceSchema.extend({ accounts: AccountsObservationSchema, poolIdentityDigest: digest, + budget: SelectionSchema.shape.budget, selection: SelectionSchema.nullable() }), result: ConfigurationSchema }, changeAccounts: { input: RevisionWorkspaceSchema.extend({ change: AccountChoiceChangeSchema }), result: ConfigurationSchema }, // `poolIdentityDigest` names this exact pool, independent of the order OMP lists accounts in: // it is what a verified catalog records and what `stageCatalog` checks a verification against. diff --git a/plugins/code/generator/generator-pane.tsx b/plugins/code/generator/generator-pane.tsx index 1195a72..f649088 100644 --- a/plugins/code/generator/generator-pane.tsx +++ b/plugins/code/generator/generator-pane.tsx @@ -2,6 +2,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSP import type { MachineSummary } from "@manifold/protocol"; import type { OmpPresence } from "../machine-web.ts"; import { prefersReducedMotion } from "@manifold/ui"; +import type { CompiledCatalog } from "../../domain/catalog.ts"; import type { Selection } from "../../domain/contracts.ts"; import type { QuotaPool } from "../../domain/quota.ts"; import { defaultSelection, type Review } from "../../domain/routing.ts"; @@ -11,6 +12,8 @@ import { displayAliases } from "./aliases.ts"; import type { ListFailure } from "./board-model.ts"; import { rescue } from "./consequences.ts"; import { previewSelection } from "./dial-space.ts"; +import { listServes } from "./launch-step.ts"; +import type { VerificationFailure } from "./model-verification.ts"; import { DialRow, Glyph, toneColor } from "./dial-row.tsx"; import { MachinePicker, type MachinePickerHandle } from "./machine-picker.tsx"; import { strandsNote } from "./earlier-model.ts"; @@ -20,7 +23,7 @@ import { Profile, rippleTeam } from "./routing-pane.tsx"; import { generatorRows, ROW_EXTRAS, type GeneratorRow, type RowId, type RowWord } from "./rows-model.ts"; import { changeSentence, commitKind, estimateReadouts, fixView, grounded, launchLine, launchReadout, laneFix, poolCounts, projectionOf, quotaParts, reviewDifferences, - machineSlot, reviewMatches, sameTeam, standstill, statementSlots, teamEdits, verbView, + machineSlot, reviewMatches, sameTeam, standstill, statementSlots, teamEdits, verbView, WORD_NAMES, type EstimateReadout, type Projection, type SlotOption, type StatementContext, type StatusAction, type StatusFix, type StatusLine, type Vocabulary, } from "./statement-model.ts"; import type { WorkbenchModel } from "./workbench-model.ts"; @@ -37,11 +40,11 @@ const ENTER = "M13.25 2.75v5.5a1 1 0 0 1-1 1H3.5M6.75 6 3.5 9.25l3.25 3.25"; const DOLLAR = "M10.6 4.6c-.55-.85-1.45-1.3-2.6-1.3-1.5 0-2.55.8-2.55 1.95 0 2.85 5.6 1.6 5.6 4.65 0 1.25-1.15 2.1-2.85 2.1-1.2 0-2.2-.5-2.8-1.4M8 1.75v12.5"; const CHEVRONS = "M5 5.25 7.5 8 5 10.75M8.75 5.25 11.25 8l-2.5 2.75"; const CHECK = "M3 8.5 6.5 12 13 4.5"; -/** Where a verification stopped, in the panel's words rather than the workflow's step names. */ +/** Where a model check stopped, in the panel's words rather than the workflow's step names. */ const STEP_WORDS: Readonly> = { - observe: "reading the accounts", initialize: "setting up the workspace", inventory: "checking models", draft: "preparing the charge", - benchmark: "measuring models", derive: "building the model list", stage: "saving the model list", review: "reviewing the model list", - promote: "putting the model list in use", select: "saving the profile", + observe: "reading the accounts", initialize: "setting up the workspace", adopt: "using your earlier check", inventory: "listing models", + draft: "preparing the charge", benchmark: "measuring models", derive: "building the model list", stage: "saving the model list", + review: "reviewing the model list", promote: "putting the model list in use", select: "saving the profile", }; /** What the cost meter is, said in the readout while it is pointed. */ const COST_NOTE = "a relative list-price index; subscriptions spend quota windows, which usage shows"; @@ -119,7 +122,9 @@ type Said = { }; type Hovered = { readonly kind: "word"; readonly row: RowId; readonly key: string } | { readonly kind: "launch" } | { readonly kind: "meter"; readonly name: "cost" | "speed" } | { readonly kind: "machine"; readonly value: string }; -type Scrub = { readonly row: RowId; readonly origin: Review | null; readonly refused: RowWord | null }; +// A scrub's origin is a review of the catalog in use when the scrub started (`catalog`): a change sentence compares it only with +// a review of that same catalog, since another list (a model check put one in use meanwhile) need not hold the origin's models. +type Scrub = { readonly row: RowId; readonly origin: Review | null; readonly catalog: CompiledCatalog | null; readonly refused: RowWord | null }; /** * GENERATOR: one row of plain option words per dial and extra, a readout line that says @@ -139,9 +144,14 @@ export function GeneratorPane(props: GeneratorPaneProps) { const [keySaid, setKeySaid] = useState(null); const [scrub, setScrub] = useState(null); const [launching, setLaunching] = useState(false); - // A Save & launch press that stopped at its review: the review, and what it shows unlike the projection. - const [stopped, setStopped] = useState<{ readonly review: object; readonly differs: readonly string[] } | null>(null); - const chain = useRef<{ readonly projection: Projection; readonly review: Review } | null>(null); + // A Save & launch press that stopped at its review: the review, and what it shows unlike the projection. A Launch press + // whose check narrowed the profile stops the same way, naming the settings the check moved. + const [stopped, setStopped] = useState<{ readonly review: object; readonly differs: readonly string[]; readonly by: "review" | "check" } | null>(null); + // The press in progress: a Save & launch on its projection, or a Launch that checks the models first, on the profile it was + // pressed on, the last check failure and message before the press, and whether it has asked for its review yet. + const chain = useRef<{ readonly kind: "save"; readonly projection: Projection; readonly review: Review } + | { readonly kind: "check"; readonly wanted: Selection | null; readonly failure: VerificationFailure | null; readonly message: unknown; readonly reviewed: boolean } + | null>(null); const keyTimer = useRef(0), scrubTimer = useRef(0); const { compiled: catalog, selection, controlsReview, served, verification } = model; @@ -176,7 +186,8 @@ export function GeneratorPane(props: GeneratorPaneProps) { const verb = verbView({ step: model.step, verdict: model.verb, busy: model.busy, inFlight: model.inFlight, chaining: model.chaining, unsaved: model.unsaved, - draft: model.localDraft !== null, phase: verification.phase, verification: verification.status, + draft: model.localDraft !== null, edited: model.edited, phase: verification.phase, mode: verification.mode, + serves: listServes({ verification, unservedLead: model.unservedLead }), // Launch anyway is for roles whose pools are out; a lead no account serves is the door's refusal, never "anyway". stranded: quota?.stop?.waits.some(pool => pool.verdict.kind === "blocked" || pool.verdict.kind === "maxed") ?? false, grounded: quota?.grounded ?? false, placeable: model.placeable, launching, @@ -192,17 +203,20 @@ export function GeneratorPane(props: GeneratorPaneProps) { const other = others.find(entry => presence?.get(entry.id) === "ok") ?? (presence ? null : others[0] ?? null); const charge = verification.phase === "charge" ? verification.charge : null; const progress = verification.progress?.providers.reduce((sum, entry) => ({ done: sum.done + entry.done, total: sum.total + entry.total }), { done: 0, total: 0 }) ?? null; - // A verification that stopped says so until the next one starts; the verb, Verify models again, is its retry. - const stoppedVerifying = verification.failure && verification.phase === null ? { - text: verification.failure.cancelled ? "Verification cancelled" : `Verification stopped while ${STEP_WORDS[verification.failure.step]}: ${verification.failure.reason}`, + // A check that stopped says so until the next one starts; the verb, Launch again, is its retry. One the panel ran on its own + // stopped beside a launch it never held up, so it is said in Models only. + const stoppedVerifying = verification.failure && verification.failure.mode !== "background" && verification.phase === null ? { + text: verification.failure.cancelled ? "Model check cancelled" : `Model check stopped while ${STEP_WORDS[verification.failure.step]}: ${verification.failure.reason}`, failed: !verification.failure.cancelled, } : null; + const shownStop = stopped !== null && stopped.review === model.launchReview ? stopped : null; const line = launchLine({ - verb, phase: verification.phase, progress, charge, inFlight: model.inFlight, busy: model.busy, chaining: model.chaining, launching, + verb, phase: verification.phase, mode: verification.mode, progress, charge, keepsChecks: verification.keepsChecks !== false, inFlight: model.inFlight, + busy: model.busy, chaining: model.chaining, launching, machine: machine && { name: machine.name, online: machine.online, revoked: machine.revoked === true }, machineChosen: model.machineId !== "", rosterUnread: rosterError, otherMachine: other && { id: other.id, name: other.name }, message: model.message ?? stoppedVerifying, outcome: model.outcome, stop: quota?.stop ?? null, fix: quota?.fix ?? null, - differs: stopped !== null && stopped.review === model.launchReview ? stopped.differs : null, + differs: shownStop?.by === "review" ? shownStop.differs : null, narrowed: shownStop?.by === "check" ? shownStop.differs : null, laneFix: verb.refusal?.code === "no-account" && slots && selection ? laneFix(slots.lane, selection) : null, nobodyServes: served !== null && served.size === 0, listFailure, ompMissing: model.ompMissing || presence?.get(model.machineId) === "absent", accountsProblem: model.accountsProblem, configurationFailed: model.configurationFailed, @@ -228,7 +242,7 @@ export function GeneratorPane(props: GeneratorPaneProps) { function wordSaid(row: GeneratorRow, word: RowWord): Said { const value = row.kind === "switch" ? `${row.label} ${word.text}` : word.text; const lead = word.sub !== null ? [word.sub] : []; - if (!word.available) return { value, text: [...lead, word.reason ?? "", word.verifies ? "a press verifies models, which finds them" : ""].filter(Boolean).join(" · "), warn: true, color: null }; + if (!word.available) return { value, text: [...lead, word.reason ?? "", word.verifies ? "a press checks the models, which finds them" : ""].filter(Boolean).join(" · "), warn: true, color: null }; const strain = quotaParts(word.quota, vocab); return { value, text: [...lead, word.says, ...strain.map(entry => entry.text)].filter(Boolean).join(" · "), @@ -246,7 +260,7 @@ export function GeneratorPane(props: GeneratorPaneProps) { if (!row) return null; if (scrub.refused) return wordSaid(row, scrub.refused); const now = chosenSaid(row); - if (!now || !catalog || !controlsReview || !scrub.origin || sameTeam(scrub.origin.selection, controlsReview.selection)) return now; + if (!now || !catalog || !controlsReview || !scrub.origin || scrub.catalog !== catalog || sameTeam(scrub.origin.selection, controlsReview.selection)) return now; const lead = row.words.find(word => word.selected)?.sub; return { ...now, text: [lead, changeSentence(catalog, scrub.origin, controlsReview, familyWord)].filter(Boolean).join(" · "), warn: false }; } @@ -261,7 +275,7 @@ export function GeneratorPane(props: GeneratorPaneProps) { const readout = estimates?.[hovered.name]; if (!readout) return null; return { value: hovered.name, text: hovered.name === "cost" ? `${readout.word} · ${COST_NOTE}` - : readout.level === null ? "unmeasured · verifying models measures it" : `${readout.word} · from measured throughput and first-token time`, warn: false, color: null }; + : readout.level === null ? "unmeasured · a model check measures it" : `${readout.word} · from measured throughput and first-token time`, warn: false, color: null }; } const row = rowOf(hovered.row), word = row?.words.find(entry => entry.key === hovered.key) ?? (row?.only?.word.key === hovered.key ? row.only.word : undefined); return row && word ? wordSaid(row, word) : null; @@ -298,12 +312,13 @@ export function GeneratorPane(props: GeneratorPaneProps) { return; } if (via === "scrub") { setScrub(current => current && { ...current, refused: word }); return; } - // A lane whose family the model list lacks is one verification away: a press starts it, through its own gate (it spends nothing before Confirm charge). + // A lane whose family the model list lacks is one check away: a press starts it, through its own gate (it spends nothing + // before Approve, unless the person approved checks). const verify = model.gate("verify"); if (word.verifies && verify.open) { model.actions.verify(); - say({ value: "verify models", text: `finds the models your accounts reach · ${word.reason ?? ""}`, warn: false, color: null }); - announce(`Verifying models: ${word.reason ?? ""}`); + say({ value: "check models", text: `finds the models your accounts reach · ${word.reason ?? ""}`, warn: false, color: null }); + announce(`Checking models: ${word.reason ?? ""}`); return; } // A press holds the scrub's readout while the pointer is down and a moment after: it says why, as a scrub onto the word does. @@ -367,10 +382,11 @@ export function GeneratorPane(props: GeneratorPaneProps) { } } - // ------------------------------------------------------------ the launch: Save & launch as one gesture + // ------------------------------------------------------------ the launch: Save & launch, and a Launch that checks first, as one gesture function act() { setStopped(null); - if (verb.launches && shown) { chain.current = { projection: projectionOf(shown, served), review: shown }; setLaunching(true); } + if (verb.checks) { chain.current = { kind: "check", wanted: selection, failure: verification.failure, message: model.message, reviewed: false }; setLaunching(true); } + else if (verb.launches && shown) { chain.current = { kind: "save", projection: projectionOf(shown, served), review: shown }; setLaunching(true); } model.actions.next(); } /** The press: a refused one shakes and says why; one that runs plays its charge and check, and the team lights up. */ @@ -400,19 +416,44 @@ export function GeneratorPane(props: GeneratorPaneProps) { button.dataset.pressing = ""; window.setTimeout(() => { delete button.dataset.pressing; fire(); }, 160); } - // A Save & launch press goes on from its review to the launch only when the review shows what was - // projected; `next` asks the gate again before launching. A chain that ends anywhere else stops, - // and a review that differs is said as what differs, for the person to choose. + // A Launch that checks first goes on once its check is done: it reviews the saved team once, then launches it, unless the + // check narrowed the profile to what answered, which is said for the person to choose. A check that stopped, a failed or + // unread workspace profile, a failed review or one that left the step at review ends the press, never retries it. + // A Save & launch press goes on from its review to the launch only when the review shows what was projected. `next` asks + // the gate again before each step. A chain that ends anywhere else stops, and a review that differs is said as what + // differs, for the person to choose. useLayoutEffect(() => { - const projected = chain.current; - if (!projected || model.busy || model.chaining) return; - chain.current = null; - setLaunching(false); + const pressed = chain.current; + if (!pressed) return; + const end = () => { chain.current = null; setLaunching(false); }; + if (pressed.kind === "check") { + if (verification.phase !== null) return; + if (verification.failure !== null && verification.failure !== pressed.failure) { end(); return; } + if (model.configurationFailed || (model.message?.failed && model.message !== pressed.message)) { end(); return; } + if (model.busy || model.chaining || !model.configurationCurrent) return; + if (model.step.step === "review") { + if (pressed.reviewed || !model.verb.open) { end(); return; } + chain.current = { ...pressed, reviewed: true }; + model.actions.next(); + return; + } + end(); + const review = model.launchReview; + if (model.step.step !== "launch" || !review) return; + const moved = pressed.wanted && model.record?.selection ? teamEdits(pressed.wanted, model.record.selection, familyWord) : []; + if (moved.length === 0) { model.actions.next(); return; } + const differs = moved.map(edit => `${WORD_NAMES[edit.word].toLowerCase()} ${edit.from} → ${edit.to}`); + setStopped({ review, differs, by: "check" }); + announce(`The model check changed the profile: ${differs.join(", ")}. Launch to go on with it, or change a setting first.`); + return; + } + if (model.busy || model.chaining) return; + end(); const review = model.launchReview; if (model.step.step !== "launch" || !review || !catalog) return; - if (reviewMatches(projected.projection, review.composition)) { model.actions.next(); return; } - const differs = reviewDifferences(catalog, projected.review, projected.projection.providers, review.composition, vocab); - setStopped({ review, differs }); + if (reviewMatches(pressed.projection, review.composition)) { model.actions.next(); return; } + const differs = reviewDifferences(catalog, pressed.review, pressed.projection.providers, review.composition, vocab); + setStopped({ review, differs, by: "review" }); announce(`The review differs: ${differs.join("; ")}. Launch to use the reviewed pool, or change a setting first.`); }); @@ -469,7 +510,7 @@ export function GeneratorPane(props: GeneratorPaneProps) { else if (model.message?.failed && model.message !== last.message) announce(model.message.text); else if (stoppedVerifying && verification.failure !== last.failure) announce(stoppedVerifying.text); if (charge && charge !== last.charge) { - announce(charge.requests > 0 ? `Verifying spends ${charge.requests} tiny requests. Confirm charge is the next control.` : "Nothing to verify through these accounts."); + announce(charge.requests > 0 ? `Checking models spends ${charge.requests} tiny requests. Approve is the next control.` : "Nothing to check through these accounts."); } seen.current = { outcome: model.outcome, message: model.message, charge, failure: verification.failure }; }, [model.outcome, model.message, charge, verification.failure]); @@ -492,7 +533,7 @@ export function GeneratorPane(props: GeneratorPaneProps) { onFocusChange={on => { setFocused(current => on ? row.id : current === row.id ? null : current); if (on) setCursor(row.id); }} onScrub={phase => { window.clearTimeout(scrubTimer.current); - if (phase === "start") setScrub({ row: row.id, origin: controlsReview, refused: null }); + if (phase === "start") setScrub({ row: row.id, origin: controlsReview, catalog, refused: null }); else scrubTimer.current = window.setTimeout(() => setScrub(null), SCRUB_SAID_MS); }} onMove={delta => { const next = rows[index + delta]; if (next) focusRow(next.id); }} />) @@ -561,9 +602,9 @@ function Meter({ name, glyph, readout, onPoint }: { name: string; glyph: string; } /** - * The line beside the launch and its machine: Confirm charge first when it carries it, then its - * facts, then its fixes and cancel as cues; a fix that opens a place answers to that place's key too. - * Its asides are the readout's, said while the launch is pointed. Confirm charge is the next Tab stop + * The line beside the launch and its machine: Approve first when it carries a check's charge, then + * its facts, then its fixes and cancel as cues; a fix that opens a place answers to that place's key + * too. Its asides are the readout's, said while the launch is pointed. Approve is the next Tab stop * after the launch: while a charge waits the machine cannot change, and its word leaves the Tab order. */ function LaunchLine({ line, canConfirm, onConfirm, onCancel, onFix }: { @@ -572,9 +613,10 @@ function LaunchLine({ line, canConfirm, onConfirm, onCancel, onFix }: { const parts = line.parts.filter(entry => entry.tone !== "meta"); const confirm = line.actions.find(action => action.kind === "confirm"); return <> - {confirm && } + onKeyDown={event => { if (event.repeat && (event.key === "Enter" || event.key === " ")) event.preventDefault(); }}>approve} {parts.length > 0 && {parts.map((entry, index) => {entry.text})} } diff --git a/plugins/code/generator/launch-step.ts b/plugins/code/generator/launch-step.ts index 24cb1c9..a69b59b 100644 --- a/plugins/code/generator/launch-step.ts +++ b/plugins/code/generator/launch-step.ts @@ -1,7 +1,7 @@ import type { Selection } from "../../domain/contracts.ts"; import type { ServiceGap } from "../../domain/routing.ts"; import type { Configuration, Target } from "../contract.ts"; -import type { VerificationStatus } from "./verification.ts"; +import type { VerificationMode, VerificationStatus } from "./verification.ts"; /** Where the profile the controls show comes from: the bundled render-only preview, the shared * active catalog, or the shared staged one. */ @@ -112,8 +112,14 @@ export type LaunchFacts = { localReview: object | null; skillProblems: readonly string[]; queries: { setup: { error: string | null } }; - /** `ready`: discovery and benchmark are ready on the destination. */ - verification: { status: VerificationStatus; ready: boolean }; + /** + * The model check. `status`: the list in use's standing, `verifying` while a check runs; `held`: + * that standing set aside the check in flight; `passed`: the list in use records a verification; + * `mode`: who started the check in flight; `ready`: discovery and benchmark are ready on the destination. + */ + verification: { status: VerificationStatus; held: VerificationStatus; passed: boolean; mode: VerificationMode | null; ready: boolean }; + /** A lead no included account serves, when the pool is known (routing.ts `routeService`): the session door refuses it. */ + unservedLead: ServiceGap | null; }; export type LaunchBlockerCode = | "configuration" | "staged" | "verifying" | "verify-status" | "verify-permissions" | "unsaved" | "read-only" | "placement" | "conflict" @@ -129,8 +135,8 @@ export type LaunchStep = const blockerText: Readonly> = { configuration: "The workspace profile needs a fresh read.", staged: "A staged model list waits in Models.", - verifying: "Verifying models with your accounts…", - "verify-status": "Verification readiness is unknown on this machine.", + verifying: "Checking models with your accounts…", + "verify-status": "Whether models can be checked on this machine is unknown.", "verify-permissions": "Discovery is not enabled on this machine.", unsaved: "Save the profile first.", "read-only": "Edit access needed.", @@ -148,11 +154,11 @@ const blockerAction: Readonly, string>> = { - unverified: "Verify models with your accounts before saving or launching.", - "accounts-changed": "Accounts changed since models were verified. Verify again.", - unpublished: "OMP no longer lists a model this profile uses. Verify again.", + unverified: "Launch first checks which models your accounts can use.", + "accounts-changed": "Accounts changed since the models were checked; Launch checks them again first.", + unpublished: "OMP no longer lists a model this profile uses; Launch checks the models again first.", }; function blockerReason(code: LaunchBlockerCode): LaunchBlocker { const action = blockerAction[code]; @@ -176,9 +182,19 @@ function verifyPreconditions(facts: LaunchFacts): LaunchBlockerCode | null { if (!facts.verification.ready) return "verify-permissions"; return null; } -/** Verification as the next step: null once it is current, else `verify` when it may run or what blocks it. */ +/** + * Whether the list in use serves a launch as it is: its check holds, or it passed one and only the + * accounts changed since, without leaving a lead that no included account serves. Such a list keeps + * serving launches while a check renews it on its own; a routed model OMP no longer lists, or an + * unserved lead, is refused by the session door, so those are checked again first. + */ +export function listServes(facts: Pick): boolean { + const { held, passed } = facts.verification; + return held === "current" || (passed && held === "accounts-changed" && facts.unservedLead === null); +} +/** A check as the next step: null while the list in use serves a launch, else `verify` when one may run or what blocks it. */ function verifyGate(facts: LaunchFacts): LaunchBlockerCode | "verify" | null { - if (facts.verification.status === "current") return null; + if (listServes(facts)) return null; if (facts.verification.status === "verifying") return "verifying"; return verifyPreconditions(facts) ?? "verify"; } @@ -209,17 +225,19 @@ export function saveGate(facts: LaunchFacts): LaunchBlockerCode | null { /** * The next step the primary launch control should take, and why it cannot when blocked. * - * Precedence: configuration not current → staged catalog previewed → verification not current - * → unsaved team (or no saved active catalog) → read-only → no canvas to place a terminal → + * Precedence: configuration not current → staged catalog previewed → list in use cannot serve a + * launch → unsaved team (or no saved active catalog) → read-only → no canvas to place a terminal → * runtime unavailable → sessions unavailable → launch permission not ready → no local model * review → skill problems → `launch` when the review is current, else `review`. * - * Verification precedes saving and reviewing: whatever team is shown, its first save and every - * launch need a catalog verified with the present accounts, whose routed models OMP still - * publishes. While it is not current the step is `verify`, or the first failing verification - * precondition in its order (verifying → read-only → conflict → runtime unavailable → accounts - * unreadable or none included → destination status unavailable → discovery/benchmark - * permission). Below that the precedence is unchanged. + * A check precedes saving and reviewing when the list in use cannot serve a launch + * (`listServes`): a first launch, an imported list, a routed model OMP no longer lists, or a lead + * the changed accounts no longer serve. The step is then `verify`, which the Launch press runs + * before going on to the launch, or the first failing check precondition in its order (checking → + * read-only → conflict → runtime unavailable → accounts unreadable or none included → destination + * status unavailable → discovery/benchmark permission). A list that passed a check keeps serving + * while only the accounts changed, and a check renewing it runs on its own. Below that the + * precedence is unchanged. * * The unsaved branch folds in the Save control: `save` when a save of the local edit may proceed * (`saveGate`), otherwise the first failing Save precondition (read-only → conflict → models), @@ -247,8 +265,8 @@ export function nextLaunchStep(facts: LaunchFacts): LaunchStep { export function launchStatusText(facts: LaunchFacts): string { if (!facts.configurationCurrent) return blockerText.configuration; if (facts.record?.draft && !facts.record.active) return blockerText.staged; - const verify = verifyGate(facts), status = facts.verification.status; - if (verify === "verify" && status !== "current" && status !== "verifying") return verifyText[status]; + const verify = verifyGate(facts), held = facts.verification.held; + if (verify === "verify" && held !== "current" && held !== "verifying") return verifyText[held]; if (verify !== null && verify !== "verify") return blockerText[verify]; const gate = launchGate(facts); return gate ? blockerText[gate] : facts.previewCurrent ? "Ready to open a terminal." : "Review before opening a terminal."; @@ -256,12 +274,11 @@ export function launchStatusText(facts: LaunchFacts): string { /** What the gate reads beyond the launch facts. */ export type GateFacts = LaunchFacts & { - /** A step is in flight: a save, review, launch or resume, a chain between its steps, a verification checking or spending, or Models writing the model list. */ + /** A step is in flight: a save, review, launch or resume, a chain between its steps, a check the person started, a background + * check writing the model list, or Models writing it. */ running: boolean; - /** The charge a prepared verification shows and waits on, until it is confirmed or cancelled. */ + /** The charge a prepared check shows and waits on, until it is approved or cancelled. */ charge: { readonly requests: number } | null; - /** A lead no included account serves, when the pool is known (routing.ts `routeService`): the session door refuses it. */ - unservedLead: ServiceGap | null; /** The saved session chosen to resume; empty when none is. */ savedSessionId: string; /** The saved team approves plans automatically, which resuming with the team refuses (`omp_resume_plan_unsupported`). */ @@ -282,8 +299,8 @@ export type GateVerdict = { open: true } | { open: false; refusal: GateRefusal } const OPEN: GateVerdict = { open: true }; const refusalText: Readonly, string>> = { running: "Wait for the step in progress.", - charge: "Confirm or cancel the verification charge first.", - "no-charge": "Nothing to verify through these accounts.", + charge: "Approve or cancel the model check first.", + "no-charge": "Nothing to check through these accounts.", "no-account": "No included account serves a provider this profile leads on.", "no-session": "Choose a saved session to resume.", plans: "Resuming with the current profile cannot approve plans automatically.", @@ -304,14 +321,15 @@ function stopped(code: LaunchBlockerCode): GateVerdict { * depends on asks it, and a chain asks again before each of its steps with its own step set aside * (`running: false`), so a premise that changed mid-flight stops the chain with the refusal. * - * Nothing starts while a step runs, and nothing but its Confirm while a verification charge waits; + * Nothing starts while a step runs, and nothing but its Approve while a check's charge waits; * edits of the team, session options, machine or accounts wait too, so a step never completes on a - * premise changed under it. Confirm needs a charge of at least one request. The verb's steps follow - * `nextLaunchStep`, except that a verification is judged by its own preconditions and a save by - * `saveGate`. A verification that is current may still be renewed on purpose — what OMP or the - * accounts can reach changes in ways no observation proves — and its charge still waits on its own - * Confirm. Save, review and launch also refuse a lead no included account serves, as the session - * door does, so a team the door refuses is never saved on the way to a launch it cannot reach. + * premise changed under it. Approve needs a charge of at least one request. The verb's steps follow + * `nextLaunchStep`, except that a check is judged by its own preconditions and a save by + * `saveGate`. A list in use that holds may still be checked again on purpose — what OMP or the + * accounts can reach changes in ways no observation proves. Save and resuming with the team stand + * on a list in use that serves a launch (`listServes`). Save, review and launch also refuse a lead + * no included account serves, as the session door does, so a team the door refuses is never saved + * on the way to a launch it cannot reach. * Resuming needs write access, a canvas to place the terminal, the machine and a chosen session; * with the team, the team must be what a launch would compose. Opening a running terminal and * editing the team, options or machine need nothing more; editing accounts needs write access. @@ -319,7 +337,7 @@ function stopped(code: LaunchBlockerCode): GateVerdict { export function actionGate(facts: GateFacts, intent: WorkbenchIntent): GateVerdict { if (facts.running) return refuse("running"); if (intent === "confirm") { - if (!facts.charge) return { open: false, refusal: { code: "not-next", text: "No verification charge is waiting." } }; + if (!facts.charge) return { open: false, refusal: { code: "not-next", text: "No model check is waiting for approval." } }; return facts.charge.requests > 0 ? OPEN : refuse("no-charge"); } if (facts.charge) return refuse("charge"); @@ -334,7 +352,7 @@ export function actionGate(facts: GateFacts, intent: WorkbenchIntent): GateVerdi } case "save": { if (!facts.configurationCurrent) return stopped("configuration"); - if (facts.verification.status !== "current") return closed(nextLaunchStep(facts), facts); + if (!listServes(facts)) return closed(nextLaunchStep(facts), facts); const save = saveGate(facts); if (save !== null) return stopped(save); return facts.unservedLead ? refuse("no-account", facts.unservedLead) : OPEN; @@ -352,7 +370,7 @@ export function actionGate(facts: GateFacts, intent: WorkbenchIntent): GateVerdi if (facts.skillProblems.length) return stopped("skills"); if (intent === "resume") return OPEN; if (!facts.configurationCurrent) return stopped("configuration"); - if (facts.verification.status !== "current") return closed(nextLaunchStep(facts), facts); + if (!listServes(facts)) return closed(nextLaunchStep(facts), facts); if (facts.unsaved || !facts.record?.active) return stopped("unsaved"); if (!facts.localReview) return stopped("models"); if (facts.unservedLead) return refuse("no-account", facts.unservedLead); diff --git a/plugins/code/generator/model-verification.ts b/plugins/code/generator/model-verification.ts index c2a4cac..a17667a 100644 --- a/plugins/code/generator/model-verification.ts +++ b/plugins/code/generator/model-verification.ts @@ -4,12 +4,13 @@ import { BENCHMARK_OPERATION_ID, INVENTORY_OPERATION_ID, type ActionResult as Om import type { Selection } from "../../domain/contracts.ts"; import { unpublishedModels, type ModelAddress } from "../../domain/routing.ts"; import type { Exclusion } from "../../domain/probe.ts"; -import type { Configuration, Target, VerificationProvenance } from "../contract.ts"; +import { CODE_PLUGIN_ID, type Configuration, type Target, type VerificationProvenance } from "../contract.ts"; import { VerificationError, WorkflowError, inventoryArtifact, type ChargeReview, type PendingVerification, type VerificationEvidence, - type VerificationProgress, type VerificationStep } from "../workflow.ts"; + type VerificationProgress, type VerificationStep, type VerifiedModels } from "../workflow.ts"; import { canWriteCodeWorkspace, codeOperationFailure, codeWorkflow, useOmpQuery, useWorkflowQuery } from "../machine-web.ts"; import { operationReady } from "../permission-plan.ts"; -import { CHECKING_HOLD_MS, confirmsCharge, ownInitialization, verificationState, type ConfirmActivation, type VerificationState } from "./verification.ts"; +import { adoptsOnly, backgroundCheckDue, CHECKING_HOLD_MS, confirmsCharge, ownInitialization, spendsWithoutAsking, verificationState, type ConfirmActivation, + type VerificationMode, type VerificationState, type VerificationStatus } from "./verification.ts"; export type ModelVerificationInput = { host: HostServices; @@ -19,7 +20,7 @@ export type ModelVerificationInput = { /** The canonical revision a verification starts from: the shown profile's base. */ revision: number; configurationCurrent: boolean; - /** The selection the operator sees; a confirmed verification saves it, narrowed to what the verified catalog hosts. */ + /** The selection the operator sees; a check they start saves it, narrowed to what the verified catalog hosts. */ selection: Selection | null; /** The models the shown team's routes name, leads and fallbacks; null while no team is formed. */ routed: readonly ModelAddress[] | null; @@ -29,25 +30,40 @@ export type ModelVerificationInput = { catalogRevision: string | null; writable: boolean; available: boolean; - /** The verified, saved configuration, as the final CAS acknowledged it. */ - onVerified: (configuration: Configuration) => void; + /** The rows hold an edit not yet saved. No check starts on its own then, since a check saves the profile it checks. */ + edited: boolean; + /** Nothing else is in flight (a save, review, launch, resume or Models write): a check starts on its own only then. */ + idle: boolean; + /** + * The verified, saved configuration, as the final CAS acknowledged it, who started the check, and + * whether it adopted the person's kept check rather than measuring anything here. + */ + onVerified: (configuration: Configuration, mode: VerificationMode, adopted: boolean) => void; /** * A run that found the workspace without shared choices created them (`from` → `to`) and then * stopped or was cancelled: the panel's own write, which the shown first-use profile may follow. */ onInitialized: (from: number, to: number) => void; }; -/** `inventory`: OMP's inventory is running. `charge`: the charge awaits confirmation; nothing has - * been spent. `benchmark`: confirmed — probing, then deriving and saving (`progress.step` says which). */ +/** `inventory`: OMP's inventory is running. `charge`: the charge awaits the person's approval; nothing has + * been spent. `benchmark`: approved — probing, then deriving and saving (`progress.step` says which). */ export type VerificationPhase = "inventory" | "charge" | "benchmark"; -/** Why the last run stopped. `reason` is for a person; `evidence` names the jobs and the last - * revision it left behind, all still inspectable. */ -export type VerificationFailure = { step: VerificationStep; reason: string; cancelled: boolean; evidence: VerificationEvidence | null }; +/** Why the last run stopped, and who started it. `reason` is for a person; `evidence` names the jobs and + * the last revision it left behind, all still inspectable. */ +export type VerificationFailure = { step: VerificationStep; reason: string; cancelled: boolean; evidence: VerificationEvidence | null; mode: VerificationMode }; export type ModelVerification = VerificationState & { + /** The standing of the list in use, set aside any check in flight: never `verifying`. */ + held: VerificationStatus; + /** The list in use records a verification, whatever its standing now: a list that passed a check once. */ + passed: boolean; phase: VerificationPhase | null; progress: VerificationProgress | null; /** The charge under review or being spent; null outside a run. */ charge: ChargeReview | null; + /** Who started the run in flight; null when none runs. */ + mode: VerificationMode | null; + /** The run in flight is writing the workspace's choices or model list, which nothing else may touch meanwhile. */ + writing: boolean; /** Why each offered model is absent from the catalog the last completed run saved. */ exclusions: Exclusion[] | null; provenance: VerificationProvenance | null; @@ -55,35 +71,60 @@ export type ModelVerification = VerificationState & { /** Discovery and benchmark are ready on the destination. */ ready: boolean; canPrepare: boolean; - /** A charge of at least one request waits for its Confirm. */ + /** A charge of at least one request waits for the person's approval. */ canConfirm: boolean; - /** Initialize if needed and run OMP's inventory, ending at the charge. Spends no benchmark request. */ - prepare: () => Promise; - /** Spend the charge `shown`: benchmark per provider, derive, stage, review, promote, save the selection. Ignored - * unless `shown` is the very charge waiting, of at least one request, and the activation a deliberate single - * press after the checking hold (`confirmsCharge`). */ + /** The largest charge, in requests, the person approved checks to spend without asking: 0 when every check asks, null until read. */ + approvedRequests: number | null; + /** The person keeps checks and their approval at all (`readChecks().keeps`); null until read. */ + keepsChecks: boolean | null; + /** + * Initialize if needed, then put the person's own newer check of this pool in use, or run OMP's + * inventory and answer the charge. A charge the standing approval covers is spent at once; any + * other waits for `confirm`. A background run never waits: it spends only a covered charge that + * renews a list which passed a check, and otherwise ends there with nothing spent. + */ + prepare: (mode: VerificationMode) => Promise; + /** + * Approve the waiting charge `shown`, which records the person's standing approval so later checks + * within it ask nothing (workflow.ts `approvalCovers`), then spend it: benchmark per provider, + * derive, stage, review, promote, save the selection. Ignored unless `shown` is the very charge + * waiting, of at least one request, and the activation a deliberate single press after the checking + * hold (`confirmsCharge`). + */ confirm: (activation: ConfirmActivation, shown: ChargeReview) => Promise; /** Stop: a pending charge is discarded, and the probe job in flight is cancelled at its native owner. */ cancel: () => void; + /** Withdraw the standing approval, so every later check asks again. */ + withdraw: () => Promise; }; -type Run = { phase: VerificationPhase; progress: VerificationProgress | null; charge: ChargeReview | null }; +type ChargedVerification = Extract; +type Run = { phase: VerificationPhase; progress: VerificationProgress | null; charge: ChargeReview | null; mode: VerificationMode }; /** - * The run in flight: how to stop it, whether it still holds, when its Verify press started it, its - * charge while unconfirmed, the revision it started from, whether the workspace had no choices then, - * and the revision its answered charge was made at. + * The run in flight: who started it, how to stop it, whether it still holds, when its press started + * it, its charge while unapproved, the revision it started from, whether the workspace had no choices + * then, the revision its answered charge was made at, whether the person approved its charge with + * their own press, and whether they withdrew the standing approval while it ran. */ type Session = { - controller: AbortController; isCurrent: () => boolean; preparedAt: number; pending: PendingVerification | null; - from: number; absent: boolean; charged: number | null; + mode: VerificationMode; controller: AbortController; isCurrent: () => boolean; preparedAt: number; pending: ChargedVerification | null; + from: number; absent: boolean; charged: number | null; approvedByPress: boolean; withdrawn: boolean; + /** The step the run last reported, as it happens rather than as last rendered. */ + step: VerificationStep | null; + /** The list in use passed a check when the run started: only such a list may a background run spend to renew. */ + renews: boolean; }; +/** The steps at which a check writes the workspace's choices or model list. */ +const WRITE_STEPS: ReadonlySet = new Set(["initialize", "adopt", "stage", "review", "promote", "select"]); /** - * The first-use and re-verification flow of `createCodeWorkflowClient().verifyModels`, for the - * workbench. Mounting observes only — OMP's account observation and Code's pool composition, the - * destination and model catalog the workbench already reads, and, once that catalog has moved, - * what it publishes for the routed providers, to compare with the recorded verification — and - * nothing runs until `prepare`. A run is bound to the destination, principal, container and write - * authority it started under; losing any of them stops it, which cancels a probe job still spending. + * The model checks of `createCodeWorkflowClient().verifyModels`, for the workbench. Mounting + * observes — OMP's account observation and Code's pool composition, the person's own checks and + * approval, the destination and model catalog the workbench already reads, and, once that catalog + * has moved, what it publishes for the routed providers — and starts a background check only when + * the list in use no longer holds and either the person keeps a check of the pool to adopt, or the + * list passed a check and the standing approval can pay to renew it. A run is bound to the + * destination, principal, container and write authority it started under; losing any of them stops + * it, which cancels a probe job still spending. */ export function useModelVerification(input: ModelVerificationInput): ModelVerification { const { host, target, record, revision, configurationCurrent, setup, catalogRevision, routed, writable, available } = input; @@ -93,11 +134,19 @@ export function useModelVerification(input: ModelVerificationInput): ModelVerifi const pool = useWorkflowQuery(host, `verification-pool:${JSON.stringify([host.containerId, record?.revision ?? null, record?.accounts ?? null])}`, record !== null && host.containerId !== null, async () => ({ pool: await codeWorkflow(host).observeVerification(host.containerId!, record!.revision, record!.accounts) })); + // The person's own checks and standing approval. A caller that keeps none (a token confined to one container) reads empty, + // so every check it starts asks, as before checks were kept. + const kept = useWorkflowQuery(host, `${CODE_PLUGIN_ID}.readChecks`, host.containerId !== null, () => codeWorkflow(host).readChecks()); + const approvedRequests = kept.data?.approvedRequests ?? null; const [run, setRun] = useState(null); const [failure, setFailure] = useState(null); const [exclusions, setExclusions] = useState(null); const active = useRef(null); const mounted = useRef(false); + // After any check stops, is cancelled or ends unspent, nothing starts on its own again until the person starts a check + // that completes: a retry behind their back could spend again, its own writes can change what it would renew, and the + // person has the launch to try again. + const quiet = useRef(false); useEffect(() => { mounted.current = true; return () => { mounted.current = false; active.current?.controller.abort(); }; @@ -114,45 +163,125 @@ export function useModelVerification(input: ModelVerificationInput): ModelVerifi // The OMP a verification recorded is compared by content, the artifact the destination's inventory // operation is installed from and the bundled model catalog's revision, so an update is seen // whatever version it reports and without a new Code build. - const state = verificationState(provenance, { inventoryArtifactSha256: setup === null ? null : inventoryArtifact(setup), catalogRevision, - unpublished, pool: pool.data === null ? null : pool.data.pool ?? "none" }, run !== null); + const held = verificationState(provenance, { inventoryArtifactSha256: setup === null ? null : inventoryArtifact(setup), catalogRevision, + unpublished, pool: pool.data === null ? null : pool.data.pool ?? "none" }, false); + const state: VerificationState = run === null ? held : { status: "verifying", changes: held.changes }; const ready = operationReady(setup, INVENTORY_OPERATION_ID) && operationReady(setup, BENCHMARK_OPERATION_ID); const canPrepare = run === null && writable && available && configurationCurrent && ready && target !== null && host.containerId !== null; + // The person's kept check of this pool and budget, newer than the list in use, which the workspace could adopt without spending. + const poolDigest = pool.data?.pool?.poolIdentityDigest ?? null, budget = record?.selection?.budget ?? "any"; + const adoptable = kept.data !== null && poolDigest !== null && kept.data.checks.some(check => check.poolIdentityDigest === poolDigest && + check.budget === budget && (provenance === null || provenance.poolIdentityDigest !== poolDigest || + provenance.benchmarkCompletedAt < check.provenance.benchmarkCompletedAt)); + const due = backgroundCheckDue({ held: held.status, initialized: record !== null, staged: record?.draft != null, + imported: record?.active != null && provenance === null, passed: provenance !== null, adoptable, approvedRequests, + ready: canPrepare && input.idle && !input.edited, quiet: quiet.current }); + useEffect(() => { + if (due) void prepare("background"); + }, [due]); + // A background run yields to an edit of the rows before it writes anything: the check it would save is no longer the one + // shown. While it writes, the gate holds edits instead (`writing`), so nothing lands between its writes. + const writing = run !== null && run.progress !== null && WRITE_STEPS.has(run.progress.step); + useEffect(() => { + if (input.edited && run?.mode === "background" && !writing) cancel(); + }, [input.edited, run?.mode, writing]); + const owns = (session: Session) => mounted.current && active.current === session; + const selectionFor = (mode: VerificationMode) => mode === "background" ? latest.current.record?.selection ?? null : latest.current.selection; function stopped(session: Session, reason: unknown) { if (!owns(session)) return; active.current = null; + quiet.current = true; setRun(null); setFailure(reason instanceof VerificationError - ? { step: reason.step, reason: codeOperationFailure(new WorkflowError(reason.reason)), cancelled: reason.reason.startsWith("code_verification_cancelled"), evidence: reason.evidence } - : { step: "observe", reason: codeOperationFailure(reason), cancelled: false, evidence: null }); + ? { step: reason.step, reason: codeOperationFailure(new WorkflowError(reason.reason)), cancelled: reason.reason.startsWith("code_verification_cancelled"), + evidence: reason.evidence, mode: session.mode } + : { step: "observe", reason: codeOperationFailure(reason), cancelled: false, evidence: null, mode: session.mode }); const made = ownInitialization(session.absent, session.charged, reason instanceof VerificationError ? reason.evidence?.revision ?? null : null); if (made !== null) latest.current.onInitialized(session.from, made); } + // An adopted check says nothing of what it left out, so the reasons are then unknown rather than none. + function finished(session: Session, verified: VerifiedModels, adopted: boolean) { + if (!owns(session)) return; + active.current = null; + quiet.current = false; + setRun(null); setExclusions(adopted ? null : verified.exclusions); + kept.refresh(); + latest.current.onVerified(verified.configuration, session.mode, adopted); + } + /** Ends a run that started nothing to spend or was not allowed to spend, with nothing spent, keeping the initialization it made. */ + function unspent(session: Session) { + active.current = null; + quiet.current = true; + setRun(null); + const made = ownInitialization(session.absent, session.charged, null); + if (made !== null) latest.current.onInitialized(session.from, made); + } const progressTo = (session: Session) => (progress: VerificationProgress) => { + session.step = progress.step; if (owns(session)) setRun(previous => previous && { ...previous, progress }); }; - async function prepare() { + /** + * Spends the charge. One the person did not approve with their press is spent unasked, which the + * workflow spends only within the standing approval as it reads it again. + */ + async function spend(session: Session, pending: ChargedVerification) { + session.pending = null; + setRun(previous => previous && { ...previous, phase: "benchmark", charge: pending.charge }); + try { + finished(session, await pending.confirm(selectionFor(session.mode), { signal: session.controller.signal, isCurrent: session.isCurrent, + onProgress: progressTo(session), beforeWrite: holdFor(session), unasked: !session.approvedByPress }), false); + } catch (reason) { stopped(session, reason); } + } + /** + * A background run holds its first write until nothing the person started is in flight, so it never + * changes the model list under a save, review, launch or resume; an edit meanwhile cancels it. A run + * the person started holds nothing: every other step waits for it instead. + */ + function holdFor(session: Session): () => Promise { + return () => new Promise(resolve => { + const signal = session.controller.signal; + const tick = () => { if (session.mode !== "background" || signal.aborted || latest.current.idle) resolve(); else timer = setTimeout(tick, 250); }; + let timer = setTimeout(tick, 0); + signal.addEventListener("abort", () => { clearTimeout(timer); resolve(); }, { once: true }); + }); + } + async function prepare(mode: VerificationMode) { if (!canPrepare || !target) return; const started = { host, target }, controller = new AbortController(); - const session: Session = { controller, pending: null, preparedAt: Date.now(), from: revision, absent: record === null, charged: null, isCurrent: () => { - const now = latest.current; - return mounted.current && !controller.signal.aborted && now.host.client === started.host.client && - now.host.principal.id === started.host.principal.id && now.host.containerId === started.host.containerId && - now.target?.machineId === started.target.machineId && now.available && canWriteCodeWorkspace(now.host); - } }; + const session: Session = { mode, controller, pending: null, preparedAt: Date.now(), from: revision, absent: record === null, charged: null, + approvedByPress: false, withdrawn: false, step: null, renews: provenance !== null, isCurrent: () => { + const now = latest.current; + return mounted.current && !controller.signal.aborted && now.host.client === started.host.client && + now.host.principal.id === started.host.principal.id && now.host.containerId === started.host.containerId && + now.target?.machineId === started.target.machineId && now.available && canWriteCodeWorkspace(now.host); + } }; active.current = session; - setFailure(null); setExclusions(null); - setRun({ phase: "inventory", progress: null, charge: null }); + // A background run leaves on show what the last measured check left out, until it measures something itself. + setFailure(null); + if (mode !== "background") setExclusions(null); + setRun({ phase: "inventory", progress: null, charge: null, mode }); try { - // The client is ungated: a stop must still reach the native job owner to cancel the job. - const pending = await codeWorkflow(host).verifyModels(target, { expectedRevision: revision, budget: input.selection?.budget ?? "any" }, - { signal: controller.signal, isCurrent: session.isCurrent, onProgress: progressTo(session) }); + // The client is ungated: a stop must still reach the native job owner to cancel the job. A background run that may not + // spend only adopts: it starts no inventory, and writes nothing, unless the person keeps a check. + const selection = selectionFor(mode); + const pending = await codeWorkflow(host).verifyModels(target, { expectedRevision: revision, budget: selection?.budget ?? "any", selection, + adoptOnly: adoptsOnly(mode, session.renews, approvedRequests ?? 0) }, + { signal: controller.signal, isCurrent: session.isCurrent, onProgress: progressTo(session), beforeWrite: holdFor(session) }); if (!owns(session)) return; - session.pending = pending; + if (pending.kind === "none") { unspent(session); return; } + if (pending.kind === "adopted") { finished(session, pending.verified, true); return; } session.charged = pending.charge.revision; - // A fast inventory must not put Confirm under the second click of a double-click on Verify: + // An approval withdrawn while the inventory ran is honoured here, and one withdrawn after by the workflow's own read. + if (!session.withdrawn && spendsWithoutAsking(mode, session.renews, pending.approvedRequests, pending.charge.requests)) { + await spend(session, pending); + return; + } + // A background run never asks: any charge it may not spend ends it here, with nothing spent. + if (mode === "background") { unspent(session); return; } + session.pending = pending; + // A fast inventory must not put Approve under the second click of a double-click on Launch: // "Checking models…" stays until the hold has passed, and a cancel meanwhile settles the run. const hold = session.preparedAt + CHECKING_HOLD_MS - Date.now(); if (hold > 0) { @@ -162,23 +291,23 @@ export function useModelVerification(input: ModelVerificationInput): ModelVerifi await promise; } if (!owns(session) || controller.signal.aborted) return; - setRun(previous => ({ phase: "charge", progress: previous?.progress ?? null, charge: pending.charge })); + setRun(previous => ({ phase: "charge", progress: previous?.progress ?? null, charge: pending.charge, mode })); } catch (reason) { stopped(session, reason); } } async function confirm(activation: ConfirmActivation, shown: ChargeReview) { const session = active.current, pending = session?.pending; if (!session || !pending || run?.phase !== "charge" || !confirmsCharge(activation, shown, { charge: pending.charge, preparedAt: session.preparedAt }, Date.now())) return; - session.pending = null; + session.pending = null; session.approvedByPress = true; setRun(previous => previous && { ...previous, phase: "benchmark" }); + // The press approves this charge whatever happens to the standing approval: one that cannot be recorded (a sign-in that + // keeps no checks) only means the next check asks again. try { - const verified = await pending.confirm(latest.current.selection, - { signal: session.controller.signal, isCurrent: session.isCurrent, onProgress: progressTo(session) }); - if (!owns(session)) return; - active.current = null; - setRun(null); setExclusions(verified.exclusions); - latest.current.onVerified(verified.configuration); - } catch (reason) { stopped(session, reason); } + const workflow = codeWorkflow(host), now = await workflow.readChecks(); + await workflow.approveChecks(now.revision, Math.max(now.approvedRequests, pending.charge.requests)); + kept.refresh(); + } catch { /* The next check asks again; this one was approved by the press. */ } + await spend(session, pending); } function cancel() { const session = active.current; @@ -187,15 +316,31 @@ export function useModelVerification(input: ModelVerificationInput): ModelVerifi // Waiting on the operator, nothing is in flight to report the stop, so it is settled here. if (session.pending) { active.current = null; + quiet.current = true; setRun(null); - setFailure({ step: "draft", reason: "Verification cancelled before any benchmark request.", cancelled: true, evidence: null }); + setFailure({ step: "draft", reason: "Model check cancelled before any benchmark request.", cancelled: true, evidence: null, mode: session.mode }); const made = ownInitialization(session.absent, session.charged, null); if (made !== null) latest.current.onInitialized(session.from, made); } } + /** + * Withdraws the standing approval. A check the person did not approve with a press then spends + * nothing more: one still listing models asks instead (or, in the background, ends unspent), and + * one measuring under the approval is stopped. A check already past measuring keeps what it paid for. + */ + async function withdraw() { + const session = active.current; + if (session && !session.approvedByPress) { + session.withdrawn = true; + if (session.charged !== null && session.pending === null && session.step === "benchmark") cancel(); + } + const workflow = codeWorkflow(host), now = await workflow.readChecks(); + await workflow.approveChecks(now.revision, 0); + kept.refresh(); + } return { - ...state, phase: run?.phase ?? null, progress: run?.progress ?? null, charge: run?.charge ?? null, - exclusions, provenance, failure, ready, canPrepare, canConfirm: run?.phase === "charge" && (run.charge?.requests ?? 0) > 0, - prepare, confirm, cancel, + ...state, held: held.status, passed: provenance !== null, phase: run?.phase ?? null, progress: run?.progress ?? null, charge: run?.charge ?? null, + mode: run?.mode ?? null, writing, exclusions, provenance, failure, ready, canPrepare, canConfirm: run?.phase === "charge" && (run.charge?.requests ?? 0) > 0, + approvedRequests, keepsChecks: kept.data?.keeps ?? null, prepare, confirm, cancel, withdraw, }; } diff --git a/plugins/code/generator/models-sheet.tsx b/plugins/code/generator/models-sheet.tsx index d881ebe..440fa07 100644 --- a/plugins/code/generator/models-sheet.tsx +++ b/plugins/code/generator/models-sheet.tsx @@ -101,7 +101,7 @@ export function ModelsSheet({ host, model, machine, onBack, backRef, onPlace }: useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const edits = model.gate("edit-team"); const writeRefusal = !model.writable ? "Edit access needed." : !model.configurationCurrent ? "The workspace profile needs a fresh read." - : running ? "A verification is running." : working ? "Wait for the change in progress." : !edits.open ? edits.refusal.text : null; + : running ? "A model check is running." : working ? "Wait for the change in progress." : !edits.open ? edits.refusal.text : null; // An unsaved edit made on the list a write would replace holds that write until it is saved or discarded (launch-step.ts // `editHoldsCatalogWrite`), saying what the write does to that list and what each fix does with the edit. A list put in // use starts from its own default team (state.ts `catalogReview`), so a saved edit stays with the list in use. Only an @@ -157,7 +157,7 @@ export function ModelsSheet({ host, model, machine, onBack, backRef, onPlace }: wrote(revision, promoted); // The staged list goes in use with its own default team; a saved team it does not repeat is said to be gone. const reset = saved !== null && promoted.selection !== null && !sameTeam(promoted.selection, saved); - return { value: "in use", text: ["the staged list replaced the one in use", ...reset ? ["the profile is the new list's default now"] : [], ...verified ? [] : ["verify it next"]].join("; ") }; + return { value: "in use", text: ["the staged list replaced the one in use", ...reset ? ["the profile is the new list's default now"] : [], ...verified ? [] : ["check it next"]].join("; ") }; }, setFailure); } function discard() { @@ -178,10 +178,10 @@ export function ModelsSheet({ host, model, machine, onBack, backRef, onPlace }: const importRefusal = writeRefusal; // Import opens over an edit it would replace; the dialog says so and offers to discard the edit. const stageHold = writeRefusal ? null : editHold("stage"); - // An import over a staged list replaces it, a verified one with its verification. + // An import over a staged list replaces it, a checked one with its check. const importEffect = !draft ? "It is staged beside the list in use; nothing changes until you use it." - : `It replaces the ${draft.provenance ? "verified " : ""}staged list; ${active ? "the list in use changes only when you use it." : "nothing is in use until you use it."}`; - const exportRefusal = running ? "A verification is running." : !(active?.document ?? inUseDocument) ? "There is no list to export." : null; + : `It replaces the ${draft.provenance ? "checked " : ""}staged list; ${active ? "the list in use changes only when you use it." : "nothing is in use until you use it."}`; + const exportRefusal = running ? "A model check is running." : !(active?.document ?? inUseDocument) ? "There is no list to export." : null; function openImport() { setPaste(""); setImportError(null); dialog.current?.showModal(); @@ -203,7 +203,7 @@ export function ModelsSheet({ host, model, machine, onBack, backRef, onPlace }: wrote(initialized.revision, staged); dialog.current?.close(); const beside = `${count(added, "change")} beside the list in use`; - return { value: "staged", text: replacing ? `${beside}; it replaced the ${replacing.provenance ? "verified " : ""}list staged before` : beside }; + return { value: "staged", text: replacing ? `${beside}; it replaced the ${replacing.provenance ? "checked " : ""}list staged before` : beside }; }, setImportError); } async function exportList() { @@ -234,7 +234,7 @@ export function ModelsSheet({ host, model, machine, onBack, backRef, onPlace }: const refused: readonly Part[] = failure ? [["not changed", "attention"]] : []; const charged = verification.charge?.requests ?? 0; const measured = progress.reduce((sum, entry) => sum + entry.done, 0), measuring = progress.reduce((sum, entry) => sum + entry.total, 0) || charged; - const busy = (): string => "A verification is running; cancel stops it."; + const busy = (): string => "A model check is running; cancel stops it."; function verifyRefused(refusal: GateRefusal): Primary { const setup: GoFix = { label: "open setup", run: () => onPlace("setup") }; const accounts: GoFix = { label: "show accounts", run: () => onPlace("accounts") }; @@ -245,34 +245,35 @@ export function ModelsSheet({ host, model, machine, onBack, backRef, onPlace }: : code === "unavailable" ? [!machine ? "No machine chosen" : machine.online ? `${machine.name} is unavailable` : `${machine.name} is offline`, [setup]] : code === "accounts" ? ["Accounts unreadable", [accounts]] : code === "no-accounts" ? ["No account is included", [accounts]] - : code === "verify-status" ? [`Verification readiness unknown on ${where}`, [retry]] + : code === "verify-status" ? [`Model checks unknown on ${where}`, [retry]] : code === "configuration" ? ["The workspace profile needs a fresh read", [retry]] : code === "conflict" ? ["The workspace profile changed elsewhere", [{ label: "use theirs", run: () => model.actions.discardChanges() }]] : [refusal.text.replace(/\.$/, ""), []]; - return { label: "verify models", disabled: true, parts: [[text, "attention"]], fixes, - said: { value: "verify models", text: refusal.text, warn: true }, press: () => refusal.text }; + return { label: "check models", disabled: true, parts: [[text, "attention"]], fixes, + said: { value: "check models", text: refusal.text, warn: true }, press: () => refusal.text }; } function primary(): Primary { switch (phase) { case "inventory": return { label: "checking models…", busy: true, parts: [[`on ${where}`, null]], fixes: [cancel], said: { value: "checking models", text: "OMP reads the models your accounts reach; nothing is spent" }, press: busy }; case "charge": { - const charge = verification.charge; - return { label: "confirm charge", disabled: !verification.canConfirm, parts: [[count(charged, "tiny request"), "strong"], ["nothing spent yet", null]], fixes: [cancel], - said: { value: "confirm charge", text: `${count(charged, "request")}, one per model your accounts offer; measures speed, then puts the list in use` }, + const charge = verification.charge, standing = verification.keepsChecks !== false; + return { label: "approve", disabled: !verification.canConfirm, parts: [[count(charged, "tiny request"), "strong"], ...standing ? [["asked once", null] as const] : []], + fixes: [cancel], + said: { value: "approve", text: `${count(charged, "request")}, one per model your accounts offer${standing ? "; later checks within it run on their own" : ""}` }, press: detail => { const gate = model.gate("confirm"); - if (!charge || !gate.open) return gate.open ? "Nothing to confirm yet." : gate.refusal.text; + if (!charge || !gate.open) return gate.open ? "Nothing to approve yet." : gate.refusal.text; // Only a deliberate single press spends (verification.ts `confirmsCharge`): never the second click of a double click. - if (detail > 1) return "A double press confirms nothing; press once."; + if (detail > 1) return "A double press approves nothing; press once."; model.actions.confirmCharge({ detail, repeat: false }, charge); return null; } }; } - case "benchmark": return { label: "verifying…", busy: true, parts: [["measuring models", null], [`${measured} of ${measuring}`, "strong"]], fixes: [cancel], - said: { value: "verifying", text: "cancel stops the probe; what it measured so far stays in OMP's history" }, press: busy }; - case "finishing": return { label: "verifying…", busy: true, parts: [["putting the model list in use", null]], fixes: [], - said: { value: "verifying", text: "saving the verified list and the profile" }, press: () => "The verified list is being saved." }; + case "benchmark": return { label: "checking…", busy: true, parts: [["measuring models", null], [`${measured} of ${measuring}`, "strong"]], fixes: [cancel], + said: { value: "checking", text: "cancel stops the probe; what it measured so far stays in OMP's history" }, press: busy }; + case "finishing": return { label: "checking…", busy: true, parts: [["putting the model list in use", null]], fixes: [], + said: { value: "checking", text: "saving the checked list and the profile" }, press: () => "The checked list is being saved." }; case "staged": { // An unsaved edit made on the list this replaces holds it, with what lifts the hold beside it. const held = writeRefusal ? null : editHold("use"); @@ -281,35 +282,36 @@ export function ModelsSheet({ host, model, machine, onBack, backRef, onPlace }: return { label: "use staged list", busy: working !== null, disabled: writeRefusal !== null, parts: [...refused, [count(changes.length, "change"), "strong"], ["staged", null]], fixes: writeRefusal ? [] : [{ label: "discard", run: discard }], said: failure ? { value: "use staged list", text: failure, warn: true } : writeRefusal ? { value: "use staged list", text: writeRefusal, warn: true } - : { value: "use staged list", text: draft?.provenance ? "replaces the list in use with this verified one" : "replaces the list in use; it needs verifying after" }, + : { value: "use staged list", text: draft?.provenance ? "replaces the list in use with this checked one" : "replaces the list in use; it needs checking after" }, press: () => { if (writeRefusal) return writeRefusal; useStaged(); return null; } }; } case "verified": { - // The head already says when the list was verified; beside the action, only its alternative: verifying again, through - // the same gate as a first verification, whose charge still waits on its own confirmation. A verification again that - // stopped says so, as a first one does: what it would have changed (the list, or for a stop while saving the profile, + // The head already says when the list was checked; beside the action, only its alternative: checking again, through + // the same gate as a first check, whose charge asks unless the person approved checks. A check again that stopped, + // the panel's own included, says so: what it would have changed (the list, or for a stop while saving the profile, // the team) stays as it was, which the head alone cannot show. const stopped = verification.failure; - const note: readonly Part[] = stopped ? [[stopped.cancelled ? "verification cancelled" : "verification stopped", stopped.cancelled ? null : "attention"]] : []; - return { label: "back to code", parts: [...note, ...refused], fixes: verifyGate.open ? [{ label: "verify again", run: () => model.actions.verify() }] : [], + const note: readonly Part[] = stopped ? [[stopped.cancelled ? "check cancelled" : "check stopped", stopped.cancelled ? null : "attention"]] : []; + return { label: "back to code", parts: [...note, ...refused], fixes: verifyGate.open ? [{ label: "check again", run: () => model.actions.verify() }] : [], said: failure ? { value: "back to code", text: failure, warn: true } : stopped && !stopped.cancelled ? { value: "back to code", text: stopped.reason, warn: true } : { value: "back to code", text: "the generator routes on this list" }, press: () => { leave(onBack); return null; } }; } - case "refused": return { label: "enable in setup", parts: [["Discovery is not enabled", "attention"], ["verifying needs it", null]], fixes: [], + case "refused": return { label: "enable in setup", parts: [["Discovery is not enabled", "attention"], ["checking models needs it", null]], fixes: [], said: { value: "enable in setup", text: `Setup turns on model discovery for ${where}` }, press: () => { leave(() => onPlace("setup")); return null; } }; case "unverified": { if (!verifyGate.open) return verifyRefused(verifyGate.refusal); const stopped = verification.failure; - const note: readonly Part[] = stopped ? [[stopped.cancelled ? "verification cancelled" : "verification stopped", stopped.cancelled ? null : "attention"]] : []; + const note: readonly Part[] = stopped ? [[stopped.cancelled ? "check cancelled" : "check stopped", stopped.cancelled ? null : "attention"]] : []; // A model OMP dropped is named as the list shows it; several are counted. const dropped = verification.changes.unpublished, named = inUse.find(row => `${row.provider}/${row.id}` === dropped[0])?.alias ?? dropped[0]; const tail: Part = verification.status === "accounts-changed" ? ["accounts changed", "warn"] - : verification.status === "unpublished" ? [`OMP dropped ${dropped.length === 1 ? named : count(dropped.length, "model")}`, "warn"] : ["unverified", "warn"]; - return { label: "verify models", parts: [...note, ...refused, tail], fixes: [], - said: stopped && !stopped.cancelled ? { value: "verify models", text: stopped.reason, warn: true } - : { value: "verify models", text: "finds the models your accounts reach and measures them; nothing is spent before you confirm" }, + : verification.status === "unpublished" ? [`OMP dropped ${dropped.length === 1 ? named : count(dropped.length, "model")}`, "warn"] : ["not checked", "warn"]; + return { label: "check models", parts: [...note, ...refused, tail], fixes: [], + said: stopped && !stopped.cancelled ? { value: "check models", text: stopped.reason, warn: true } + : { value: "check models", text: (verification.approvedRequests ?? 0) > 0 ? "finds and measures the models your accounts reach, within what you approved" + : "finds and measures the models your accounts reach; nothing is spent before you approve" }, press: () => { model.actions.verify(); return null; } }; } } @@ -347,7 +349,7 @@ export function ModelsSheet({ host, model, machine, onBack, backRef, onPlace }: const rowChanges = changed(row); if (rowChanges.length) return { value: row.alias, color: "var(--tui-acc)", text: `${rowChanges.map(change => `${change.field} ${change.from ?? "—"} → ${change.to ?? "—"}`).join(" · ")} · staged` }; - const speed = row.tps === null ? owed.has(row.provider) ? "measuring…" : "unmeasured · verifying measures it" + const speed = row.tps === null ? owed.has(row.provider) ? "measuring…" : "unmeasured · a check measures it" : `${Math.round(row.tps)} tok/s${row.ttft === null ? "" : ` · first token ${(row.ttft / 1000).toFixed(1)} s`}`; return { value: row.alias, color: hue(row.family), text: `${row.provider}/${row.id} · ${speed} · ${money(row.pin)} in, ${money(row.pout)} out per M · ${contextWords(row.ctx)} · ${thinkingRange(row.levels)}` }; @@ -419,13 +421,13 @@ export function ModelsSheet({ host, model, machine, onBack, backRef, onPlace }: // ------------------------------------------------------------ the sheet const verifiedAt = verification.provenance?.benchmarkCompletedAt ?? null; - // An OMP update the routes survived leaves the list verified; it is said beside the date, since verifying again is the way to measure under it. + // An OMP update the routes survived leaves the list checked; it is said beside the date, since checking again is the way to measure under it. const ompUpdated = verification.changes.runtimeChanged || verification.changes.catalogChanged; - const [tail, tone]: Part = running ? ["verifying", null] : phase === "staged" ? [active ? "staged list beside it" : "staged, none in use", "warn"] - : verification.status === "current" && verifiedAt !== null ? [`verified ${since(Date.now() - verifiedAt)}${ompUpdated ? " · OMP updated since" : ""}`, null] - : verification.status === "accounts-changed" ? ["accounts changed since verified", "warn"] - : verification.status === "unpublished" ? [`OMP dropped ${count(verification.changes.unpublished.length, "model")} since verified`, "warn"] - : !active ? ["starter · unverified", "warn"] : ["unverified", "warn"]; + const [tail, tone]: Part = running ? ["checking", null] : phase === "staged" ? [active ? "staged list beside it" : "staged, none in use", "warn"] + : verification.status === "current" && verifiedAt !== null ? [`checked ${since(Date.now() - verifiedAt)}${ompUpdated ? " · OMP updated since" : ""}`, null] + : verification.status === "accounts-changed" ? ["accounts changed since checked", "warn"] + : verification.status === "unpublished" ? [`OMP dropped ${count(verification.changes.unpublished.length, "model")} since checked`, "warn"] + : !active ? ["starter · not checked", "warn"] : ["not checked", "warn"]; function cell(family: string, tier: number) { const row = at(family, tier), id = `${family}:${tier}`, tierWord = TIERS[tier - 1]!; const bound = readout.bind(`cell:${id}`); diff --git a/plugins/code/generator/setup-sheet.tsx b/plugins/code/generator/setup-sheet.tsx index ea38fce..4248413 100644 --- a/plugins/code/generator/setup-sheet.tsx +++ b/plugins/code/generator/setup-sheet.tsx @@ -30,7 +30,7 @@ const FOLDER_ROUTES: Readonly> = { existing: VALIDATE /** What each row means, as the readout says it while the row is pointed or focused. */ const ABOUT: Readonly, string>> = { connection: "the model gateway connects OMP to your account broker; it moves no credentials and starts no request", - discovery: "lets Code read the models your accounts reach and measure them; verifying models needs it", + discovery: "lets Code read the models your accounts reach and measure them; checking models needs it", sessions: "lets Code open OMP sessions here", }; const FOLDERS: Readonly> = { @@ -231,7 +231,7 @@ export function SetupSheet({ host, model, target, machine, machines, rosterError press: () => { refresh(); return null; } }; } case "connection": return reviewing("gateway", "review connection", `on ${where}`); - case "discovery": return reviewing("benchmark", "enable discovery", "verifying needs it"); + case "discovery": return reviewing("benchmark", "enable discovery", "checking models needs it"); case "sessions": return reviewing("session", "enable sessions", "launching needs it"); case "folders": { // A route its permission does not allow yet is reviewed first; an allowed one runs as a native job. @@ -253,7 +253,7 @@ export function SetupSheet({ host, model, target, machine, machines, rosterError said: { value: "apply classifier", text: change === "off" ? "Code stops sending task descriptions" : `Code sends task descriptions to ${classifier.origin.trim()}` }, press: () => { applyClassifier(); return null; } }; } - return { label: "back to code", parts: [[`ready on ${where}`, "done"]], fixes: [], said: { value: "back to code", text: `verify and launch on ${where}` }, + return { label: "back to code", parts: [[`ready on ${where}`, "done"]], fixes: [], said: { value: "back to code", text: `launch on ${where}` }, press: () => { onBack(); return null; } }; } const next = primary(); @@ -306,8 +306,15 @@ export function SetupSheet({ host, model, target, machine, machines, rosterError // ------------------------------------------------------------ the profile's facts const record = model.record, active = record?.active ?? null; const inUse = useMemo(() => active ? modelRows(active.document).length : 0, [active]); - const verifiedAt = model.verification.status === "current" ? model.verification.provenance?.benchmarkCompletedAt ?? null : null; + const verifiedAt = model.verification.held === "current" ? model.verification.provenance?.benchmarkCompletedAt ?? null : null; const leftOut = model.verification.exclusions; + // What checks may spend on their own (model-verification.ts `approvedRequests`): up to twice the largest approved charge; + // null until the approval has been read, when nothing is said rather than a guess. + const approved = model.verification.approvedRequests; + async function askFirst() { + try { await model.verification.withdraw(); readout.say({ value: "checks", text: "every later check asks before it spends" }); } + catch (reason) { readout.say({ value: "checks", text: `${codeOperationFailure(reason)} Checks still run on their own.`, warn: true }); } + } const ompVersion = model.queries.metadata.data?.ompVersion ?? null; return
@@ -353,10 +360,14 @@ export function SetupSheet({ host, model, target, machine, machines, rosterError
{record?.revision ?? model.observed?.revision ?? "not read"}{!model.configurationCurrent && · last known}
models
- {active && · {verifiedAt !== null ? `verified ${since(Date.now() - verifiedAt)}` : "unverified"}}
+ {active && · {verifiedAt !== null ? `checked ${since(Date.now() - verifiedAt)}` : "not checked"}} {leftOut && <>
left out
}
source
{active ? "stored list" : record?.draft ? "staged list" : "bundled starter"}{ompVersion && · OMP {ompVersion} bundled list}
+ {approved !== null && <>
checks
+
{approved > 0 + ? <>on their own up to {approved * 2} requests · + : "ask before spending"}
}
workspace
{host.containerId}
diff --git a/plugins/code/generator/sheets-model.ts b/plugins/code/generator/sheets-model.ts index 5470917..1305e5f 100644 --- a/plugins/code/generator/sheets-model.ts +++ b/plugins/code/generator/sheets-model.ts @@ -93,7 +93,7 @@ const EXCLUSION_WORDS: Readonly> = {}): VerbView => - ({ label, state, refusal: null, saves: false, launches: false, opens: null, ...extra }); - if (facts.phase === "inventory") return view("Checking…", "busy"); - if (facts.phase === "benchmark") return view("Verifying…", "busy"); - if (facts.phase === "charge") return view("Verify models", "waiting"); + const view = (label: string, state: VerbView["state"], extra: Partial> = {}): VerbView => + ({ label, state, refusal: null, saves: false, launches: false, checks: false, opens: null, ...extra }); + const launch = facts.edited ? "Save & launch" : "Launch"; + if (facts.mode !== null && facts.mode !== "background") { + if (facts.phase === "inventory" || facts.phase === "benchmark") return view("Checking models…", "busy"); + if (facts.phase === "charge") return view(facts.mode === "launch" ? launch : "Check models", "waiting"); + } if (facts.busy) return view(facts.inFlight ? RUNNING_LABELS[facts.inFlight] : "Working…", "busy"); if (facts.chaining) return view("Reviewing…", "busy"); if (facts.launching) return view("Launching…", "busy"); if (facts.step.step === "blocked" && facts.step.reason.code === "staged") return view("Review in Models", "ready", { opens: "models" }); const step = facts.step.step; - const saves = step === "save" || (step === "blocked" && facts.draft && facts.unsaved && facts.verification === "current"); + const checks = step === "verify"; + const saves = step === "save" || (step === "blocked" && facts.draft && facts.unsaved && facts.serves); const launches = saves && facts.grounded && facts.placeable; const anyway = facts.stranded && (facts.verdict.open || facts.verdict.refusal.code !== "no-account"); - const label = step === "verify" ? "Verify models" + const label = checks ? launch : step === "review" ? "Review" : step === "save" ? (!facts.placeable ? "Save" : facts.grounded ? (anyway ? "Launch anyway" : "Save & launch") : "Save & review") : step === "launch" ? (anyway ? "Launch anyway" : "Launch") - : facts.verification === "verifying" ? "Verifying…" - : facts.verification !== "current" ? "Verify models" + : facts.step.step === "blocked" && facts.step.reason.code === "verifying" ? "Checking models…" + : !facts.serves ? launch : saves ? (facts.placeable ? "Save & launch" : "Save") : "Launch"; - if (facts.verdict.open) return view(label, "ready", { saves, launches }); - return { ...view(label, facts.verdict.refusal.code === "running" ? "busy" : "refused", { saves, launches }), refusal: facts.verdict.refusal }; + if (facts.verdict.open) return view(label, "ready", { saves, launches, checks }); + const waits = facts.verdict.refusal.code === "running" || facts.verdict.refusal.code === "verifying"; + return { ...view(label, waits ? "busy" : "refused", { saves, launches, checks }), refusal: facts.verdict.refusal }; } /** What the panel projected for a launch: each role's served lead, and the providers its account pool serves (null: unknown). */ @@ -667,7 +680,8 @@ export type StatusFix = | { readonly kind: "refresh" } | { readonly kind: "discard" }; export type StatusAction = - | { readonly kind: "confirm"; readonly requests: number } + // `standing`: approving also lets later checks within it run without asking, which only a person who keeps checks gets. + | { readonly kind: "confirm"; readonly requests: number; readonly standing: boolean } | { readonly kind: "cancel" } | { readonly kind: "fix"; readonly key: string; readonly label: string; readonly fix: StatusFix }; export type StatusLine = { readonly parts: readonly StatusPart[]; readonly actions: readonly StatusAction[] }; @@ -683,9 +697,13 @@ export function teamEdits(saved: Selection, selection: Selection, familyWord: (f export type StatusFacts = { readonly verb: VerbView; readonly phase: VerificationPhase | null; + /** Who started the check in flight; null when none runs. */ + readonly mode: VerificationMode | null; /** Benchmark requests done and in all, while verifying. */ readonly progress: { readonly done: number; readonly total: number } | null; readonly charge: { readonly requests: number; readonly providers: readonly { readonly provider: string; readonly requests: number }[] } | null; + /** The person keeps checks and their approval (`readChecks().keeps`): only then does approving once spare later checks the asking. */ + readonly keepsChecks: boolean; readonly inFlight: VerbFacts["inFlight"]; readonly busy: boolean; readonly chaining: boolean; @@ -711,6 +729,8 @@ export type StatusFacts = { readonly fix: FixView | null; /** A Save & launch press stopped at a review that differs from the projection: what differs, in the statement's words. */ readonly differs: readonly string[] | null; + /** A Launch press whose check narrowed the profile to what answered stopped before launching: the settings it moved. */ + readonly narrowed: readonly string[] | null; /** For an unserved lead: the nearest lane the accounts serve. */ readonly laneFix: SlotOption | null; /** No included account serves anything (the pool is known and empty). */ @@ -790,8 +810,8 @@ function refusalLines(refusal: GateRefusal, facts: StatusFacts, vocab: Vocabular case "unsaved": return attention("No model list in use", [fix("Open Models", { kind: "open", place: "models" }, "unsaved")]); case "conflict": return attention("The workspace profile changed elsewhere", [fix("Use theirs", { kind: "discard" }, "conflict")]); case "models": return attention("These choices need a model review", [fix("Open Models", { kind: "open", place: "models" }, "models")]); - case "verify-status": return attention(`Verification readiness unknown on ${where}`, [fix("Retry", { kind: "refresh" }, "verify-status")]); - case "verify-permissions": return attention("Discovery is not enabled", [fix("Enable in Setup", { kind: "open", place: "setup" }, "verify-permissions")], "verifying needs it"); + case "verify-status": return attention(`Model checks unknown on ${where}`, [fix("Retry", { kind: "refresh" }, "verify-status")]); + case "verify-permissions": return attention("Discovery is not enabled", [fix("Enable in Setup", { kind: "open", place: "setup" }, "verify-permissions")], "checking models needs it"); case "sessions": return attention(`Sessions unavailable on ${where}`, [fix("Open Setup", { kind: "open", place: "setup" }, "sessions")]); case "permissions": return attention(`Sessions not enabled on ${where}`, [fix("Enable in Setup", { kind: "open", place: "setup" }, "permissions")]); case "skills": return attention("Skill choices need attention", [fix("Open options", { kind: "open", place: "options" }, "skills")]); @@ -831,18 +851,22 @@ export function launchLine(facts: StatusFacts, vocab: Vocabulary): StatusLine | function pressLines(facts: StatusFacts, vocab: Vocabulary): [StatusLine, StatusLine] | null { const cancel: StatusAction = { kind: "cancel" }; - if (facts.phase === "inventory") return [line([part("Checking which models your accounts can reach", "busy")]), line([], [cancel])]; - if (facts.phase === "charge" && facts.charge) { + // A check the person started, or one the panel started that the launch waits on, is said with its progress. One running + // beside a launch the list in use can serve stays out of the way, said only while the launch is pointed. + const shown = facts.mode !== null && (facts.mode !== "background" || facts.verb.refusal?.code === "verifying"); + if (shown && facts.phase === "inventory") return [line([part("Checking which models your accounts can use", "busy")]), line([], [cancel])]; + if (shown && facts.phase === "charge" && facts.charge) { const { requests, providers } = facts.charge; - if (requests === 0) return [line([part("Nothing to verify through these accounts")]), line([], [cancel])]; + if (requests === 0) return [line([part("Nothing to check through these accounts")]), line([], [cancel])]; const spend = providers.filter(entry => entry.requests > 0) .map(entry => `${vocab.family(providerPolicy(entry.provider).family)} ${entry.requests}`).join(" · "); - return [line([part(`Verifying spends ${requests} tiny ${requests === 1 ? "request" : "requests"}`), part(spend)]), - line([part("nothing is spent until you confirm", "meta")], [{ kind: "confirm", requests }, cancel])]; + return [line([part(`Checking models spends ${requests} tiny ${requests === 1 ? "request" : "requests"}`), part(spend)]), + line(facts.keepsChecks ? [part("asked once: later checks run on their own", "meta")] : [], + [{ kind: "confirm", requests, standing: facts.keepsChecks }, cancel])]; } - if (facts.phase === "benchmark") { + if (shown && facts.phase === "benchmark") { const progress = facts.progress ? [part(`${facts.progress.done} of ${facts.progress.total} requests`, "meta")] : []; - return [line([part("Verifying models with your accounts", "busy"), ...progress]), line([], [cancel])]; + return [line([part("Checking models with your accounts", "busy"), ...progress]), line([], [cancel])]; } if (facts.busy || facts.chaining || facts.launching) { const where = facts.machine?.name ?? "the machine"; @@ -860,9 +884,11 @@ function baseLines(facts: StatusFacts, vocab: Vocabulary): [StatusLine, StatusLi const { verb } = facts; if (verb.state === "refused" && verb.refusal) return refusalLines(verb.refusal, facts, vocab); if (verb.opens === "models") return [line([part("A staged model list waits in Models")]), line([part(`${verb.label.toLowerCase()} opens it; the profile stays as it is`, "meta")])]; - // A stop is a choice, not a failure: what the review shows instead, and both ways on. + // A stop is a choice, not a failure: what the review shows instead, or what the check moved, and both ways on. if (facts.differs) return [line([part("The review differs", "warn"), ...facts.differs.map(text => part(text))]), line([part("launch to use the reviewed pool, or change a setting first", "meta")])]; + if (facts.narrowed) return [line([part("The model check changed the profile", "warn"), ...facts.narrowed.map(text => part(text))]), + line([part("launch to go on with it, or change a setting first", "meta")])]; if (facts.message?.failed) return [line([part(facts.message.text, "attention")]), EMPTY]; if (facts.outcome?.kind === "resumed") return [line([part(`Resumed on ${facts.outcome.machine}`, "done")]), EMPTY]; if (facts.outcome) { @@ -872,8 +898,9 @@ function baseLines(facts: StatusFacts, vocab: Vocabulary): [StatusLine, StatusLi dials === "harness" ? [{ kind: "fix", key: "dials-setup", label: "Enable in Setup", fix: { kind: "open", place: "setup" } }] : []), EMPTY]; } if (facts.message) return [line([part(facts.message.text, "done")]), EMPTY]; - // Verifying comes first: the verb's own label says so, and a stop judged on unverified models would be premature. - if (verb.label === "Verify models" || !facts.stop) return null; + // A press that checks first says so with its own label, and a stop judged on models not checked yet would be premature. + if (verb.checks) return null; + if (!facts.stop) return facts.mode === "background" && facts.phase !== null ? [line([part("checking models in the background", "meta")]), EMPTY] : null; const head = [part(stopHead(facts.stop, vocab), "attention"), part(`${roleList(facts.stop.roles)} ${facts.stop.roles.length === 1 ? "has" : "have"} no route`)]; return facts.fix ? [line(head), line([], [{ kind: "fix", key: "rescue", label: `${facts.fix.label} ${facts.fix.result}`, fix: { kind: "team", selection: facts.fix.selection, review: facts.fix.review } }])] diff --git a/plugins/code/generator/verification.ts b/plugins/code/generator/verification.ts index f979fe3..67759e6 100644 --- a/plugins/code/generator/verification.ts +++ b/plugins/code/generator/verification.ts @@ -1,22 +1,22 @@ import type { VerificationProvenance } from "../contract.ts"; +import { approvalCovers } from "../workflow.ts"; /** - * Whether the active catalog's verification still describes the world a launch would meet. + * Whether the active catalog's model check still describes the world a launch would meet. * - * `unverified`: no verification is recorded — no active catalog, or one authored, imported or - * kept from before verification recorded the OMP it ran against. `verifying`: a verification is - * in flight, including while its charge awaits confirmation. `unpublished`: OMP's model catalog - * moved and no longer publishes a model the profile routes to, which the session door refuses + * `unverified`: no check is recorded — no active catalog, or one authored, imported or kept from + * before checks recorded the OMP they ran against. `verifying`: a check is in flight, including + * while its charge awaits approval. `unpublished`: OMP's model catalog moved and no longer + * publishes a model the profile routes to, which the session door refuses * (`code_model_unpublished`). `accounts-changed`: the saved choices now select another pool — a * provider added or removed, or the same providers through different accounts. `current`: none * of those. * - * An OMP update alone leaves a verification current, noted in `changes`: the destination's - * inventory installed from another artifact, or another catalog revision that still publishes - * every routed model. Neither shows that the models these accounts reached have moved, and an - * update would otherwise ask every workspace to spend a verification before its next launch. The - * session door still re-checks every routed model at launch, and Verify renews a current - * verification on purpose. + * An OMP update alone leaves a check current, noted in `changes`: the destination's inventory + * installed from another artifact, or another catalog revision that still publishes every routed + * model. Neither shows that the models these accounts reached have moved, and an update would + * otherwise ask every workspace to spend a check before its next launch. The session door still + * re-checks every routed model at launch, and a list that holds can be checked again on purpose. */ export type VerificationStatus = "unverified" | "verifying" | "current" | "accounts-changed" | "unpublished"; /** The present a recorded verification is compared with. A null field has not been observed. */ @@ -87,24 +87,78 @@ export function ownInitialization(absent: boolean, charged: number | null, evide } /** - * The least time the Verify press stays "Checking models…" before its charge can be confirmed. - * Longer than the usual double-click interval (500 ms by default on Windows, shorter elsewhere), so - * the second click of a double-click on Verify lands on a busy control rather than on Confirm. + * The least time the press that prepared a charge stays "Checking models…" before the charge can be + * approved. Longer than the usual double-click interval (500 ms by default on Windows, shorter + * elsewhere), so the second click of a double-click on Launch lands on a busy control rather than + * on Approve. */ export const CHECKING_HOLD_MS = 700; -/** How a Confirm was activated: `detail` is the pointer's click count, 0 from the keyboard; `repeat` is a held key. */ +/** How an Approve was activated: `detail` is the pointer's click count, 0 from the keyboard; `repeat` is a held key. */ export type ConfirmActivation = { readonly detail: number; readonly repeat: boolean }; -/** A charge as the verification holds it, waiting: what it charges, and when the Verify press that prepared it came. */ +/** A charge as the check holds it, waiting: what it charges, and when the press that prepared it came. */ export type PreparedCharge = { readonly charge: { readonly requests: number }; readonly preparedAt: number }; /** * Whether this activation may spend the prepared charge. Only the very charge the control showed * (`shown`, the object it rendered), and only if it charges at least one request: a charge that was - * replaced, or one of nothing, is never confirmed. Only a deliberate single press counts: never the + * replaced, or one of nothing, is never approved. Only a deliberate single press counts: never the * second click of a multi-click, never key repeat, and never before the checking hold has passed - * since the Verify press that prepared the charge. + * since the press that prepared the charge. */ export function confirmsCharge(activation: ConfirmActivation, shown: object | null, prepared: PreparedCharge | null, nowMs: number): boolean { return prepared !== null && shown === prepared.charge && prepared.charge.requests > 0 && activation.detail <= 1 && !activation.repeat && nowMs - prepared.preparedAt >= CHECKING_HOLD_MS; } + +/** + * Who started a check. `launch`: the Launch press, which goes on to the launch once the check is + * done. `manual`: Models. `background`: the panel itself, once the list in use no longer holds; it + * never asks the person anything, and never holds up a launch the list in use can still serve. + */ +export type VerificationMode = "launch" | "manual" | "background"; +/** + * Whether a prepared charge is spent without asking: only within the person's standing approval + * (`approvalCovers`), as freshly read, and in the background only to renew a list that passed a + * check (`renews`), so a first check that spends is always the person's own press. + */ +export function spendsWithoutAsking(mode: VerificationMode, renews: boolean, approvedRequests: number, requests: number): boolean { + return approvalCovers(approvedRequests, requests) && (mode !== "background" || renews); +} +/** + * Whether a check may only adopt the person's kept check, starting no inventory: a background run + * that may not spend, because the list never passed a check or nothing is approved, would list + * models for a charge it can never spend. + */ +export function adoptsOnly(mode: VerificationMode, renews: boolean, approvedRequests: number): boolean { + return mode === "background" && !(renews && approvedRequests > 0); +} +/** What decides whether the panel starts a check on its own (`backgroundCheckDue`). */ +export type BackgroundFacts = { + /** The list in use's standing, set aside any check in flight. */ + readonly held: VerificationStatus; + /** The workspace has a Code record; one never set up is left to the person's first launch. */ + readonly initialized: boolean; + readonly staged: boolean; + /** A list is in use without a recorded check: imported, the person's to decide on. */ + readonly imported: boolean; + /** The list in use passed a check. */ + readonly passed: boolean; + /** The person keeps a check of the present pool and budget newer than the list in use. */ + readonly adoptable: boolean; + /** The person's standing approval, null until read. */ + readonly approvedRequests: number | null; + /** A check could run here now, nothing else is in flight, and the rows hold no edit. */ + readonly ready: boolean; + /** A check stopped, was cancelled or ended unspent since the last one the person started completed. */ + readonly quiet: boolean; +}; +/** + * Whether the panel should start a check on its own: only to renew a list in use that no longer + * holds, never over a staged or imported list or in a workspace never set up, and only when it can + * adopt the person's kept check or the list passed a check and the person approved checks. + */ +export function backgroundCheckDue(facts: BackgroundFacts): boolean { + if (facts.held === "current" || !facts.initialized || facts.staged || facts.imported || !facts.ready || facts.quiet) return false; + if (facts.approvedRequests === null) return false; + return facts.adoptable || (facts.passed && facts.approvedRequests > 0); +} diff --git a/plugins/code/generator/web.tsx b/plugins/code/generator/web.tsx index 74ef3e1..def8eb6 100644 --- a/plugins/code/generator/web.tsx +++ b/plugins/code/generator/web.tsx @@ -222,7 +222,7 @@ const TAB_KEYS = ["Escape", "a", "e", "p", "s"] as const; /** Every key the panel answers, grouped by the view it acts in (panel-keys.ts); the key line names only the most used. */ const SHORTCUTS: readonly { readonly title: string; readonly keys: readonly (readonly [key: string, does: string])[] }[] = [ { title: "Generator", keys: [["↑ ↓", "Move between the rows"], ["← →", "Change the row's value"], ["Home End", "The row's first or last value"], - ["Space", "Turn fallbacks on or off"], ["⏎", "The launch: verify, save, review or launch"], ["Mod ⏎", "The launch, from anywhere in the view"], + ["Space", "Turn fallbacks on or off"], ["⏎", "The launch: save, review or launch, checking the models first when needed"], ["Mod ⏎", "The launch, from anywhere in the view"], ["d", "Defaults"], ["z", "Back to the saved profile"], ["f", "Show or hide the fallback chains, here or in Routing"], ["w", "Choose the machine"], ["m", "Models"], ["u", "Setup"], ["o", "Session options"]] }, { title: "Views", keys: [["a", "Accounts, or back to the generator"], ["e", "Sessions, or back to the generator"], diff --git a/plugins/code/generator/workbench-model.ts b/plugins/code/generator/workbench-model.ts index 87bd08e..34ceab4 100644 --- a/plugins/code/generator/workbench-model.ts +++ b/plugins/code/generator/workbench-model.ts @@ -366,13 +366,15 @@ export function useWorkbench({ host, target, machine, rosterError, available }: : accountsFailed ? { kind: "failed", text: accounts.error! } : judgedAccounts !== null && judgedAccounts.status !== "fresh" ? { kind: "stale" } : { kind: "choices" }; const launchReady = operationReady(setup.data, LAUNCH_OPERATION_ID); const harnessReady = operationReady(setup.data, HARNESS_OPERATION_ID); - // A confirmed verification is itself the save of the shown selection: the bundled preview's - // dials, or the active profile's, narrowed to what the verified catalog hosts. + // A check the person starts is itself the save of the shown selection: the bundled preview's dials, or the active + // profile's, narrowed to what the verified catalog hosts. A background check saves the saved selection, and only + // starts while the rows hold no edit and nothing else is in flight. const verification = useModelVerification({ host, target, record, revision: profile?.revision ?? observed?.revision ?? 0, configurationCurrent, selection, routed, setup: setup.data, catalogRevision: metadata.data?.revision ?? null, writable, available, - onVerified: saved => { + edited, idle: !busy && !chaining && !writingCatalog, + onVerified: (saved, mode, adopted) => { setSavedPolicy(saved); setDials(null); setPreview(null); - setMessage({ text: "Models verified with your accounts, and the profile saved for the workspace.", failed: false }); + if (mode === "manual") setMessage({ text: adopted ? "Your earlier model check is in use here." : "Models checked with your accounts, and the profile saved for the workspace.", failed: false }); }, // A stopped first verification leaves the choices it created and nothing else (launch-step.ts `followInitialization`). onInitialized: (from, to) => setDials(previous => followInitialization(previous, from, to)) }); @@ -389,7 +391,7 @@ export function useWorkbench({ host, target, machine, rosterError, available }: const reviewKey = JSON.stringify([generation, authority.current.epoch, draftGeneration.current.epoch, sourceGeneration.current.epoch, machineId, available, rosterError !== null, configurationCurrent, record?.revision, record?.active?.digest, record?.draft?.digest, record?.selection, defaults.data?.revision, defaults.error !== null, skillCatalog.data?.revision, skillCatalog.error !== null, - setup.error !== null, setup.data === null, accountObservation, skillChoice, automation, savedSessionId, verification.status]); + setup.error !== null, setup.data === null, accountObservation, skillChoice, automation, savedSessionId, verification.held]); const reviewScope = useRef({ key: reviewKey, epoch: 0 }); if (reviewScope.current.key !== reviewKey) reviewScope.current = { key: reviewKey, epoch: reviewScope.current.epoch + 1 }; // Exact CAS arbitrates shared revision changes. Observing this save's own @@ -415,7 +417,10 @@ export function useWorkbench({ host, target, machine, rosterError, available }: const shownReview = previewCurrent ? preview.composition.review : localReview; // The session door's rule on the team the verb acts on: a lead nobody serves refuses, fallbacks are pruned. const unservedLead = served && compiled && shownReview ? routeService(compiled, shownReview.routes, provider => served.has(provider)).lead : null; - const verifyRunning = verification.phase === "inventory" || verification.phase === "benchmark"; + // A check the person started holds every other step; a background one only while it writes the model list, so it never + // holds up a launch the list in use can serve. + const verifyRunning = (verification.phase === "inventory" || verification.phase === "benchmark") && + (verification.mode !== "background" || verification.writing); const facts: GateFacts = { configurationCurrent, profile, localDraft: dials, record, unsaved, stale, writable, placeable, available, accounts: accountState, launchReady, previewCurrent, localReview, skillProblems, queries: { setup }, verification, running: busy || chaining || verifyRunning || writingCatalog, charge: verification.phase === "charge" ? verification.charge : null, @@ -586,13 +591,13 @@ export function useWorkbench({ host, target, machine, rosterError, available }: launched(reviewed, machine.name, blocker, "Terminal opened. Follow the session in OMP. Optional choices were cleared for the next independent launch."); } function verify() { - if (allowed("verify")) void verification.prepare(); + if (allowed("verify")) void verification.prepare("manual"); } - /** The verb: whatever the next step is, if the gate lets it start. */ + /** The verb: whatever the next step is, if the gate lets it start. A check it starts is the Launch press's own, which goes on to the launch. */ function next() { if (pending.current || step.step === "blocked" || !verb.open) return; switch (step.step) { - case "verify": verify(); return; + case "verify": void verification.prepare("launch"); return; case "save": setChaining(true); void save(); return; case "review": void review(); return; case "launch": void launch(); return; @@ -682,7 +687,7 @@ export function useWorkbench({ host, target, machine, rosterError, available }: }, [premise]); const profileState: ProfileState = stale ? "conflict" : unsaved ? "local" : "saved"; const stateLabel = !configurationCurrent ? "Shared choices unavailable" : stale ? "Shared profile changed" : - profile?.source === "starter" ? "Bundled preview · verify to save" : profile?.source === "draft" ? "Staged catalog preview" : dials ? "Local changes" : "Saved profile"; + profile?.source === "starter" ? "Bundled preview · checked on the first launch" : profile?.source === "draft" ? "Staged catalog preview" : dials ? "Local changes" : "Saved profile"; const usage: BoardUsage = { view: reading.value, current: !(reading.cached || historicalChoices || configuration.error !== null || accounts.error !== null), nowMs, accounts: choices && { diff --git a/plugins/code/server.ts b/plugins/code/server.ts index 029e5b5..856307a 100644 --- a/plugins/code/server.ts +++ b/plugins/code/server.ts @@ -15,12 +15,17 @@ import { catalogReview, commitConfiguration, configurationMigration, expectRevis readConfiguration, requireConfiguration, verificationProvenance } from "./state.ts"; import { cancelSession, composeSession, followSession, listProfiles, readSession, runSession } from "./session.ts"; import { configureServices, currentSuggestionService, readServiceConfiguration, reviewServices } from "./service-setup.ts"; +import { adoptCheck, approveChecks, readChecks, recordCheck } from "./checks.ts"; const mutating: Partial> = { - initializeConfiguration: true, stageCatalog: true, promoteCatalog: true, discardCatalog: true, select: true, changeAccounts: true, + initializeConfiguration: true, stageCatalog: true, promoteCatalog: true, discardCatalog: true, select: true, adoptCheck: true, changeAccounts: true, configureServices: true, runSession: true, cancelSession: true, }; const pure: Partial> = { draftInventory: true, deriveCatalog: true }; +// A person's own checks and approval: no container is involved, so no container capability is +// asked. They are container-graded so that a token confined to one container reaches them and is +// answered as keeping none, rather than refused in a way a caller could not tell from a failure. +const personal: Partial> = { readChecks: true, approveChecks: true }; // Only Code's external suggestion policy needs native authority. Policy // composition never delegates OMP execution, jobs, accounts or terminals. const actionDelegates: Partial> = { @@ -54,7 +59,11 @@ const productHandlers: ProductHandlers = { const record = requireConfiguration(previous); compileCatalog(args.document); const provenance = args.verification === undefined ? null : verificationProvenance(ctx, record, args.document, args.verification); - return commitConfiguration(ctx, previous, { ...record, draft: { document: args.document, digest: digestOf(args.document), provenance } }); + const draft = { document: args.document, digest: digestOf(args.document), provenance }; + // A verified list is also kept as the checking person's check of its pool and budget, which their other + // workspaces with that pool adopt instead of checking again; it stays a true measurement whatever this stage's fate. + if (draft.provenance !== null) await recordCheck(ctx, { ...draft, provenance: draft.provenance }, args.verification!.budget); + return commitConfiguration(ctx, previous, { ...record, draft }); }, async reviewCatalog(ctx, args) { const previous = await readConfiguration(ctx, args); expectRevision(previous, args.expectedRevision); @@ -81,6 +90,9 @@ const productHandlers: ProductHandlers = { reviewCatalog(compileCatalog(record.active.document), args.selection, ctx.now()); return commitConfiguration(ctx, previous, { ...record, selection: args.selection }); }, + readChecks, + approveChecks, + adoptCheck, async changeAccounts(ctx, args) { const previous = await readConfiguration(ctx, args, true); expectRevision(previous, args.expectedRevision); const record = requireConfiguration(previous); @@ -133,7 +145,7 @@ const plugin = { manifest: PluginManifestSchema.parse(manifestJson), actions: (Object.keys(rootActionSchemas) as RootAction[]).map(name => defineServerAction({ name, title: name.replace(/([A-Z])/g, " $1"), - caps: pure[name] ? [] : [mutating[name] ? "containers:write" : "containers:read"], + caps: pure[name] || personal[name] ? [] : [mutating[name] ? "containers:write" : "containers:read"], ...(actionDelegates[name] ? { delegates: actionDelegates[name]! } : {}), scope: pure[name] ? "workspace" : "container", trace: "opaque", input: rootActionSchemas[name].input as z.ZodType, result: rootActionSchemas[name].result as z.ZodType, diff --git a/plugins/code/ui.tsx b/plugins/code/ui.tsx index 8593699..1131698 100644 --- a/plugins/code/ui.tsx +++ b/plugins/code/ui.tsx @@ -15,7 +15,7 @@ function cx(...classes: (string | false | null | undefined)[]): string { // ---------------------------------------------------------------- vocabulary -/** A control's tooltip naming its key as this keyboard labels it: `Defaults (D)`, `Verify models (Ctrl ↵)`. */ +/** A control's tooltip naming its key as this keyboard labels it: `Defaults (D)`, `Launch (Ctrl ↵)`. */ export function withKey(label: string, stroke: string): string { return `${label} (${keyCapLabel(stroke)})`; } diff --git a/plugins/code/workflow.ts b/plugins/code/workflow.ts index f0f82bb..89601f1 100644 --- a/plugins/code/workflow.ts +++ b/plugins/code/workflow.ts @@ -8,7 +8,8 @@ import { JobDeploymentApplyArgsSchema, JobDeploymentListArgsSchema, JobDeploymen ListRunsV2ResultSchema, RegisterAgentV2ResultSchema, UpdateAgentV2ResultSchema, formatManifoldUri, type AgentRunV2, type AgentV2, type AuthorityScope, type InspectRunV2Result, type LaunchRunResult, type ListRunsV2Result, type RunModel } from "@manifold/protocol"; import { z } from "zod"; -import { createCodeClient, reviewedSessionOptions, sessionInput, type SessionOptions, type CodeAction, type ActionInput, type ActionResult, type Configuration, type Target, type VerificationProvenance } from "./contract.ts"; +import { createCodeClient, reviewedSessionOptions, sessionInput, type SessionOptions, type CodeAction, type ActionInput, type ActionResult, + type Configuration, type Target, type VerificationProvenance } from "./contract.ts"; import { HARNESS_OPERATION_ID, observePermissionPlan, operationReady, type PermissionPlanInput } from "./permission-plan.ts"; import { selectedAccountPool } from "../domain/accounts.ts"; import { compileCatalog } from "../domain/catalog.ts"; @@ -28,21 +29,27 @@ const messages: Readonly> = { code_catalog_missing: "No model list is in use yet. Review one in Models.", code_account_unavailable: "The selected accounts are unavailable or no longer resolve exactly. Review the account choices.", code_accounts_changed: "The accounts changed. Read them again before continuing.", - code_omp_changed: "OMP or its model list changed on the machine since the inventory. Verify again.", - code_model_unpublished: "OMP no longer lists a model this profile uses. Verify models again.", + code_omp_changed: "OMP or its model list changed on the machine since the inventory. Check again.", + code_model_unpublished: "OMP no longer lists a model this profile uses. Check the models again.", code_invalid_accounts: "The account observation could not be used. Read the accounts again.", code_invalid_selection: "This profile cannot be formed from the current models.", code_budget_unsatisfiable: "No free route serves this profile.", code_invalid_catalog: "The model list could not be used.", code_preview_changed: "The reviewed model list changed. Review it again.", - code_starter_candidate_limit: "The bundled model list is too large to verify at once.", - code_probe_missing_probe: "OMP's results do not match the models it was asked to check. Verify again.", + code_invalid_provenance: "The checked list names a provider these accounts do not include. Check again.", + code_starter_candidate_limit: "The bundled model list is too large to check at once.", + code_probe_missing_probe: "OMP's results do not match the models it was asked to check. Check again.", code_probe_insufficient_ladder: "No model answered its check, so there is no list to build.", code_ladder_regression: "Among the models that answered, a higher tier thinks less than a lower one that costs no more.", - code_models_unanswered: "Models the list in use or this profile needs gave no clear answer, so the list and profile stayed as they were. Verify again later, or choose a profile or accounts without them.", - code_verification_cancelled: "Verification cancelled.", - code_verification_changed: "The machine, workspace or edit access changed, so the verification stopped.", - code_verification_confirmed: "That charge was already confirmed.", + code_models_unanswered: "Models the list in use or this profile needs gave no clear answer, so the list and profile stayed as they were. Check again later, or choose a profile or accounts without them.", + code_verification_cancelled: "Model check cancelled.", + code_verification_changed: "The machine, workspace or edit access changed, so the model check stopped.", + code_verification_confirmed: "That charge was already approved.", + code_check_missing: "No model check of these accounts is kept yet.", + code_check_current: "This workspace's model list is already as recent as your check.", + code_person_required: "Only a person can approve what model checks spend.", + code_checks_confined: "This sign-in reaches only part of Manifold, so it keeps no model checks and approves none.", + code_approval_withdrawn: "Model checks now ask before spending, so this one spent nothing.", code_operation_unavailable: "The Code action could not be completed.", code_scope_refused: "Your current authority does not cover this container.", code_service_configuration_changed: "Native service configuration changed. Read and review its current revision again.", @@ -139,8 +146,9 @@ function unansweredNeeds(exclusions: readonly Exclusion[], inUse: ReadonlySet boolean; signal?: AbortSignal; onProgress?: (progress: VerificationProgress) => void }; + * cancels the probe job it is waiting on. `onProgress` hears every step and per-provider count. + * `beforeWrite` is awaited before the run first writes the model list (adopting or staging), so a + * caller can hold those writes while something else of its own is in flight. `unasked` marks a + * confirmation nobody pressed for: it spends only within the standing approval as read again. */ +export type VerificationOptions = { isCurrent?: () => boolean; signal?: AbortSignal; onProgress?: (progress: VerificationProgress) => void; + beforeWrite?: () => Promise; unasked?: boolean }; /** * What confirming a verification will spend, shown before anything is: one tiny benchmark * request per candidate, per provider, through the exact pool and OMP defaults the inventory @@ -182,11 +194,26 @@ export type ChargeReview = { replacesDraft: boolean; }; export type VerifiedModels = { configuration: Configuration; exclusions: Exclusion[]; provenance: VerificationProvenance }; -/** A prepared verification: its charge, and the one explicit confirmation that spends it. */ -export type PendingVerification = { - charge: ChargeReview; - confirm: (selection: Selection | null, options?: VerificationOptions) => Promise; -}; +/** + * A prepared verification: its charge, the caller's standing approval as read while preparing it + * (`approvedRequests`, workflow.ts `approvalCovers`), and the one confirmation that spends it; the + * caller's own newer check of the workspace's pool and budget, already put in use without spending + * anything (`exclusions` are then unknown and empty); or, for an adopt-only run, `none`: no such + * check, and nothing was started or written. + */ +export type PendingVerification = + | { kind: "charge"; charge: ChargeReview; approvedRequests: number; + confirm: (selection: Selection | null, options?: VerificationOptions) => Promise } + | { kind: "adopted"; verified: VerifiedModels } + | { kind: "none" }; +/** + * Whether a person's standing approval lets a check spend `requests` without asking: up to twice + * the largest charge they approved, so OMP listing a few more models than last time asks nothing + * again, while a charge out of proportion to what they agreed to does. + */ +export function approvalCovers(approvedRequests: number, requests: number): boolean { + return approvedRequests > 0 && requests > 0 && requests <= approvedRequests * 2; +} /** * The artifact a destination's OMP inventory operation is installed from, as `describeDestination` * pins it: the identity of the OMP runtime an inventory there runs, whatever version it reports. @@ -213,6 +240,8 @@ interface VerificationRun { call(work: () => Promise, checkAfter?: boolean): Promise; /** A pause that a cancellation ends early, and that checks currency when it ends. */ wait(ms: number): Promise; + /** The caller's hold before the run first writes the model list (`beforeWrite`), checked for currency when it ends. */ + hold(): Promise; } function verificationRun(options: VerificationOptions, evidence: VerificationEvidence): VerificationRun { let step: VerificationStep = "observe"; @@ -245,6 +274,11 @@ function verificationRun(options: VerificationOptions, evidence: VerificationEvi options.signal?.addEventListener("abort", done, { once: true }); }).then(check); }, + async hold(): Promise { + check(); + await options.beforeWrite?.(); + check(); + }, }; } function accepted(reply: T | { refused: string }): T { @@ -514,17 +548,22 @@ export function createCodeWorkflowClient(dispatch: Dispatch) { /** * VERIFY MODELS WITH THE OPERATOR'S ACCOUNTS, through OMP's own inventory and benchmark: the one * way a Code catalog comes to name models, because only a probe shows which this operator can - * call. Preparing reads the workspace at `expectedRevision` (initializing an absent one), runs - * OMP's inventory with the pool Code composes, and answers the charge — nothing has been probed - * or paid for yet. Only `confirm` spends: one benchmark job per provider, then derive, stage, - * review, promote, and save the caller's selection narrowed to what the verified catalog hosts. + * call. Preparing reads the workspace at `expectedRevision` (initializing an absent one). When + * the caller keeps a check of the workspace's present pool, derived under `budget`, newer than the + * list in use, preparing puts it in use with `selection` narrowed to it and ends there, spending + * nothing. Otherwise it runs OMP's inventory with the pool Code composes and answers the charge — + * nothing has been probed or paid for yet; an `adoptOnly` run instead ends with `none`, having + * started and written nothing (and it never initializes an absent workspace). Only `confirm` + * spends: one benchmark job per provider, then derive, stage (which also keeps the list as the + * caller's check of that pool and budget), review, promote, and save the caller's selection + * narrowed to what the verified catalog hosts. * * Every step re-observes before it acts and stops, naming itself, on a moved revision, pool, * OMP defaults or OMP, a refusal, a cancelled `signal` or a revoked `isCurrent`. A stop never undoes: * probe jobs stay in OMP's history and a staged catalog stays staged, both named in the error's * evidence. A failed or uncertain confirmation is never replayed; observe the workspace first. */ - async function verifyModels(target: Target, input: { expectedRevision: number; budget: Selection["budget"] }, + async function verifyModels(target: Target, input: { expectedRevision: number; budget: Selection["budget"]; selection: Selection | null; adoptOnly?: boolean }, options: VerificationOptions = {}): Promise { const evidence: VerificationEvidence = { inventoryJobId: null, benchmarkJobIds: [], revision: null }; const run = verificationRun(options, evidence); @@ -533,6 +572,7 @@ export function createCodeWorkflowClient(dispatch: Dispatch) { if (observed.revision !== input.expectedRevision) throw run.fail("code_stale_preferences"); let record = observed.configuration; if (record === null) { + if (input.adoptOnly) return { kind: "none" }; run.enter("initialize"); record = await run.call(() => code("initializeConfiguration", { containerId: target.containerId, expectedRevision: input.expectedRevision })); evidence.revision = record.revision; @@ -541,13 +581,27 @@ export function createCodeWorkflowClient(dispatch: Dispatch) { run.enter("inventory"); // The model list OMP bundles now is the one the inventory picks its models from when it starts, so its revision is read // before the start: one republished while the inventory runs is then a moved OMP, refused before the charge is answered. - const [defaults, pool, catalogRevision] = await run.call(() => Promise.all([omp("readDefaults", {}), observePool(target.containerId, revision), - readCatalogRevision()])); - // The models of the list in use, when it was verified with this exact pool: what a model that merely gives no clear - // answer may not remove. Spark's retired rung (tier 0) is never routed, so nothing needs it. + const [defaults, pool, catalogRevision, checks] = await run.call(() => Promise.all([omp("readDefaults", {}), observePool(target.containerId, revision), + readCatalogRevision(), code("readChecks", {})])); + // What a model that merely gives no clear answer may not remove: the models of the list in use when it was verified with + // this exact pool, and those of the caller's own check of it, which their other workspaces adopt. Spark's retired rung + // (tier 0) is never routed, so nothing needs it. const active = record.active; - const inUse = new Set(active && active.provenance?.poolIdentityDigest === pool.poolIdentityDigest - ? active.document.models.filter(model => model.tier !== 0).map(probeAddress) : []); + const kept = checks.checks.find(check => check.poolIdentityDigest === pool.poolIdentityDigest && check.budget === input.budget); + const held = active?.provenance ?? null; + // The caller's own check of this pool, newer than what the workspace holds for it: put in use as it is, spending nothing. + if (kept && (held === null || held.poolIdentityDigest !== kept.poolIdentityDigest || held.benchmarkCompletedAt < kept.provenance.benchmarkCompletedAt)) { + await run.hold(); + run.enter("adopt"); + const configuration = await run.call(() => code("adoptCheck", { containerId: target.containerId, expectedRevision: revision, + accounts: pool.accounts, poolIdentityDigest: pool.poolIdentityDigest, budget: input.budget, selection: input.selection })); + evidence.revision = configuration.revision; + if (!configuration.active?.provenance) throw run.fail("code_preview_changed"); + return { kind: "adopted", verified: { configuration, exclusions: [], provenance: configuration.active.provenance } }; + } + if (input.adoptOnly) return { kind: "none" }; + const inUse = new Set([...held?.poolIdentityDigest === pool.poolIdentityDigest + ? active!.document.models.filter(model => model.tier !== 0).map(probeAddress) : [], ...kept?.models ?? []]); // The start is checked by the job's own wait, so a stop that lands now cancels the job rather than abandoning it. const job = await run.call(() => omp("startInventory", { ...target, expectedDefaultsRevision: defaults.revision, accountPool: pool.accountPool }), false); evidence.inventoryJobId = job.jobId; @@ -569,18 +623,25 @@ export function createCodeWorkflowClient(dispatch: Dispatch) { run.progress(charge.providers.map(({ provider, requests: total }) => ({ provider, done: 0, total }))); let confirmed = false; return { - charge, + kind: "charge", charge, approvedRequests: checks.approvedRequests, confirm: (selection, confirmOptions = options) => { if (confirmed) return Promise.reject(new WorkflowError("code_verification_confirmed")); confirmed = true; - return confirmVerification(charge, inUse, selection, verificationRun(confirmOptions, { ...evidence, benchmarkJobIds: [] })); + return confirmVerification(charge, inUse, selection, verificationRun(confirmOptions, { ...evidence, benchmarkJobIds: [] }), confirmOptions.unasked === true); }, }; } - async function confirmVerification(charge: ChargeReview, inUse: ReadonlySet, wanted: Selection | null, run: VerificationRun): Promise { + /** + * A charge spent without asking (`unasked`) is spent only while the caller's standing approval, + * read again now, still covers it: an approval withdrawn since the charge was answered stops the + * run before its first benchmark request. + */ + async function confirmVerification(charge: ChargeReview, inUse: ReadonlySet, wanted: Selection | null, run: VerificationRun, + unasked: boolean): Promise { const { target } = charge; run.enter("benchmark"); await unchangedCharge(charge, run); + if (unasked && !approvalCovers((await run.call(() => code("readChecks", {}))).approvedRequests, charge.requests)) throw run.fail("code_approval_withdrawn"); const progress: ProviderProgress[] = charge.providers.map(({ provider, requests }) => ({ provider, done: 0, total: requests })); run.progress(progress); const receipts: BenchmarkReceipt[] = []; @@ -614,12 +675,13 @@ export function createCodeWorkflowClient(dispatch: Dispatch) { let selection: Selection; try { selection = clampSelection(compileCatalog(derived.document), wanted); } catch (error) { throw run.fail(error instanceof Error ? error.message : "code_invalid_selection"); } + await run.hold(); run.enter("stage"); const pool = await unchangedCharge(charge, run); const staged = await run.call(() => code("stageCatalog", { containerId: target.containerId, expectedRevision: charge.revision, document: derived.document, verification: { ompVersion: charge.inventory.ompVersion, inventoryArtifactSha256: charge.inventoryArtifactSha256, catalogRevision: charge.catalogRevision, inventoryObservedAt: charge.inventory.observedAt, benchmarkCompletedAt: benchmark.completedAt, accounts: pool.accounts, - poolIdentityDigest: charge.pool.poolIdentityDigest } })); + poolIdentityDigest: charge.pool.poolIdentityDigest, budget: charge.budget } })); run.evidence.revision = staged.revision; run.enter("review"); const review = await run.call(() => code("reviewCatalog", { containerId: target.containerId, expectedRevision: staged.revision, source: "draft" })); @@ -726,6 +788,8 @@ export function createCodeWorkflowClient(dispatch: Dispatch) { return { code, omp, native, readStarterCatalog, verifyModels, observeVerification, + readChecks: () => code("readChecks", {}), + approveChecks: (expectedRevision: number, requests: number) => code("approveChecks", { expectedRevision, requests }), readSkillCatalog: (target: Target) => omp("readSkillCatalog", target), listSessions, runningSession, async resumeSession(ref: OmpSessionRef, options: ResumeSessionOptions = {}, isCurrent: () => boolean = () => true): Promise { diff --git a/plugins/scripts/verify-browser.ts b/plugins/scripts/verify-browser.ts index 0aa3b04..bea0595 100755 --- a/plugins/scripts/verify-browser.ts +++ b/plugins/scripts/verify-browser.ts @@ -63,10 +63,14 @@ rows and judge no provider's pool, the lead row's only box, toggled by a press a focus kept on it, kept across a lead change and disabled under Mixed, with struck leads and boxes that say why, and Models' pasted imports, discards and the exact review a staged list is used by, through the real Code server. -Separate synthetic RPC responses exercise the verification charge and its refused spend; a verified -catalog that outlives an OMP upgrade alone and goes stale once a republished list drops a routed -model; a Models verification run end to end on fictional probe jobs that reach no provider, whose -derivation, staging and promotion are the real server's; Setup's +Separate synthetic RPC responses exercise the model check a first Launch runs, its charge and its +refused spend; a checked list that outlives an OMP upgrade alone and is checked again once a +republished list drops a routed model; a Models check run end to end on fictional probe jobs that +reach no provider, whose derivation, staging and promotion are the real server's; a person's checks +across workspaces (each check scenario signs in as a person of its own): the approval an approve +press records, a check within it that asks nothing, a kept check adopted without an inventory on a +press and on its own, and a launch that stays ready while a changed account pool is checked again +in the background; Setup's readiness rows, folder jobs and the owner's classifier (an owner-class identity minted for it alone); and launch/resume review invalidation and refusal. The preparation, configuration and execution they lead to are refused, never native execution or consent success. @@ -295,13 +299,16 @@ const G = "plugin-atyrode_code_generator__"; const stage = `${generator} [data-tui]`; const generatorTitle = element(`${generator} [data-pane="generator"] .${G}title`); const launchButton = element(`${generator} .${G}launch`); -const confirmCharge = element(`${generator} .${G}confirm`); +/** The approve beside the launch while a model check's charge waits on the person (generator-pane.tsx `LaunchLine`). */ +const approveButton = element(`${generator} .${G}confirm`); const readout = element(`${generator} .${G}readout`); // The announcer is the panel's polite live region; other status roles (skills, notices) are not it. const liveRegion = element(`${generator} div.plugin-atyrode_code__sr[role="status"][aria-live="polite"]`); const lineText = `(${element(`${generator} .${G}launch-line`)}?.textContent ?? '')`; /** A failure or refusal said beside the launch, in the attention colour. */ const failureLine = `!!document.querySelector(${JSON.stringify(`${generator} .${G}launch-part[data-tone="attention"]`)})`; +/** The facts beside the launch, part by part, each with its tone; the line's asides are said in the readout instead. */ +const launchParts = `[...document.querySelectorAll(${JSON.stringify(`${generator} .${G}launch-line .${G}launch-part`)})].map(el => [el.textContent, el.dataset.tone ?? null])`; const cancelCharge = `[...document.querySelectorAll(${JSON.stringify(`${generator} .${G}launch-line .${G}fix`)})].find(el => el.textContent.trim() === 'Cancel')`; const shortcuts = element(`${generator} dialog.${G}shortcuts`); const machinePicker = element(`${generator} [data-machine-picker]`); @@ -532,6 +539,24 @@ async function panelReads(browser: BrowserInstance, revision: number): Promise el.textContent === 'revision')?.nextElementSibling?.textContent === '${revision}'`); await closeSheet(browser); } +/** One of Setup's profile facts beside its checklist, as it reads. */ +function setupFact(name: string): string { + return `[...document.querySelectorAll('${setupSheet} .${G}setup-facts dt')].find(el => el.textContent === ${JSON.stringify(name)})?.nextElementSibling?.textContent`; +} +/** + * The person withdraws their approval in Setup, as they would: its `checks` fact says checks run on their own up to twice + * the largest charge they approved, with ask first beside it, whose press withdraws the approval and says what follows. + * Every check after it asks before it spends. + */ +async function askFirst(browser: BrowserInstance, approved: number): Promise { + await openSheet(browser, "setup"); + await until(browser, "Setup says checks run on their own up to twice the approved charge, with ask first beside it", + `${setupFact("checks")} === ${JSON.stringify(`on their own up to ${2 * approved} requests · ask first`)}`); + await click(browser, element(`${setupSheet} [data-checks="ask"]`)); + await until(browser, "ask first withdraws the approval and says every later check asks before it spends", + `${sheetSays(setupSheet)} === 'checks · every later check asks before it spends' && ${setupFact("checks")} === 'ask before spending'`); + await closeSheet(browser); +} /** * The panel's own reads of what it stands on, as the SDK's feed probe counts them (polled-resource.ts `__manifoldFeeds`, @@ -601,6 +626,31 @@ async function readConfiguration(server: TestServer, grant: TokenGrant, workspac assert(result.configuration === null || (result.configuration && Number.isSafeInteger(result.configuration.revision)), "Code must return an absent or versioned configuration"); return result; } +/** A person's own model checks and their standing approval, as the real server keeps them (checks.ts). */ +async function readChecks(server: TestServer, grant: TokenGrant): Promise> { + const outcome = await callAction(server, grant.token, "atyrode.code.readChecks", {}); + assert(outcome.ok, "A person reads their own model checks"); + return outcome.result as ActionResult<"readChecks">; +} +/** What a workspace writer may do: the writer's capabilities, and those of each person a check scenario signs in as. */ +const WRITER_CAPS: Parameters[1]["caps"] = ["containers:read", "containers:write", "machines:read", "machines:run", "jobs:read", "services:read"]; +/** + * Runs `scenario` signed in as a writer of its own, who has approved nothing and keeps no model check. The approval and + * the kept checks belong to the person (checks.ts) and reach every workspace they open, so a scenario whose first check + * must ask, or must find nothing to adopt, never inherits another scenario's. The browser is the writer's again after. + */ +async function asNewPerson(browser: BrowserInstance, server: TestServer, writer: TokenGrant, name: string, + scenario: (person: TokenGrant) => Promise): Promise { + const person = await mintToken(server, { principal: { kind: "human", name, color: "#6a5acd" }, caps: WRITER_CAPS }); + assert.deepEqual(await readChecks(server, person), { revision: 0, approvedRequests: 0, approvedAt: null, checks: [], keeps: true }, + "A new person keeps their own checks, has approved nothing and keeps no model check yet"); + await browser.evaluate(identity(person)); + try { + await scenario(person); + } finally { + await browser.evaluate(identity(writer)); + } +} async function openWorkspace( browser: BrowserInstance, @@ -829,10 +879,12 @@ async function rememberTeam(browser: BrowserInstance, principalId: string, conta await browser.evaluate(`localStorage.setItem(${JSON.stringify(recentTeamsKey(principalId, containerId))}, ${JSON.stringify(JSON.stringify([{ selection, launchedAt: Date.now() - 60_000 }]))})`); } -async function usableStarter(browser: BrowserInstance): Promise { +/** The bundled preview, usable: its rows filled and editable, its routes formed. `edited`: the rows hold an unsaved edit. */ +async function usableStarter(browser: BrowserInstance, edited = false): Promise { await until(browser, "the bundled preview fills the generator's rows", `${JSON.stringify(ROWS)}.every(id => document.querySelector('${generator} [data-row="' + id + '"]'))`); - // A bundled preview is saved only by the verification that replaces it; the fixture reads no accounts and has no discovery, so that is refused. - await until(browser, "the starter's only step is a refused verification", launchIs("verify models", "refused")); + // A bundled preview is saved only by the model check a first launch runs before it saves; the fixture reads no accounts + // and has no discovery, so that launch is refused. An edit of the rows makes it Save & launch. + await until(browser, "the starter's only step is a launch refused before its model check", launchIs(edited ? "save & launch" : "launch", "refused")); // Before shared policy exists no row is locked. The generator's rows are the stage's; Setup's checklist rows are its own. assert.deepEqual(await browser.evaluate(`[...document.querySelectorAll('${stage} [data-row]')].filter(row => row.getAttribute('aria-disabled') === 'true').map(row => row.dataset.row)`), [], "Every row is editable before shared policy exists"); @@ -971,7 +1023,7 @@ const SHEET_CONTROLS = `button, select, input, textarea, [role="radio"], [role=" * what `skip` holds for an element. In the main view that is what the readouts say, which by design changes inside their * fixed boxes with whatever is pointed or focused; the next-refresh line, which follows the clock and the refresh * cadence, never the pointer; and screen-reader text. In a sheet it is what its readout line says, and the head's state - * line, which follows the clock ("verified 1m ago") and never the pointer. + * line, which follows the clock ("checked 1m ago") and never the pointer. */ function boxesOf(view: string, skip: string): string { return `(() => { @@ -1741,7 +1793,7 @@ async function acceptanceScenario(browser: BrowserInstance, label: string, recen await motionless("a sheet"); await sheetStill(browser, "Models coming in"); await click(browser, element(`${modelsSheet} [data-go]`)); - await until(browser, "the starter's refused verify says why", `${element(`${modelsSheet} [data-readout]`)}.dataset.tone === 'warn'`); + await until(browser, "the starter's refused check says why", `${element(`${modelsSheet} [data-readout]`)}.dataset.tone === 'warn'`); assert.equal(await browser.evaluate(`${element(`${modelsSheet} [data-go]`)}.dataset.shake`), undefined, "Reduced motion: a refused press does not shake"); await sheetStill(browser, "a refused press of the next action"); await closeSheet(browser); @@ -1830,7 +1882,7 @@ async function starterWorkbenchScenario(browser: BrowserInstance, server: TestSe assert.deepEqual(await readProfile(browser, draftKey), chosen, "Sheets and views do not regenerate or rebase starter choices"); // The unsaved choices are kept in this tab: a reload brings them back on the same bundled list. await openGenerator(browser, server, workspace.id); - await usableStarter(browser); + await usableStarter(browser, true); assert.deepEqual(await readProfile(browser, draftKey), chosen, "A reload keeps the unsaved starter choices"); if (!initialized) await acceptanceScenario(browser, "bundled starter", recent, draftKey); assert.deepEqual(await readConfiguration(server, writer, target), base, @@ -1853,8 +1905,8 @@ async function starterWorkbenchScenario(browser: BrowserInstance, server: TestSe assert.equal(model.tokensPerSecond, null, "Bundled metadata does not invent measured speed"); assert.equal(model.timeToFirstTokenMs, null, "Bundled metadata does not invent measured latency"); } - assert.deepEqual(await readConfiguration(server, writer, target), base, "Nothing but a verification persists the preview"); - await until(browser, "saved policy is not native launch readiness", launchIs("verify models", "refused")); + assert.deepEqual(await readConfiguration(server, writer, target), base, "Nothing but a model check persists the preview"); + await until(browser, "saved policy is not native launch readiness", launchIs("save & launch", "refused")); const native = await callAction(server, writer.token, "atyrode.omp.describeDestination", { ...target, machineId: destination.machineId }); assert(native.ok); assert((native.result as OmpResult<"describeDestination">).operations.every(operation => !operation.nativeReady)); @@ -1884,13 +1936,13 @@ async function starterConflictScenario(browser: BrowserInstance, server: TestSer const routes = await routedRoles(browser); const competing = await callAction(server, writer.token, "atyrode.code.initializeConfiguration", { ...target, expectedRevision: 0 }); assert(competing.ok); - // A competing initializer moves the revision under the frozen preview: unlike a verification's own + // A competing initializer moves the revision under the frozen preview: unlike a model check's own // initialization, it is a change made elsewhere, and the launch line offers theirs. - await until(browser, "a revision-zero preview refuses to attach to a competing initializer", `${launchIs("verify models", "refused")} && !!${fix("conflict")}`); + await until(browser, "a revision-zero preview refuses to attach to a competing initializer", `${launchIs("save & launch", "refused")} && !!${fix("conflict")}`); // Models says the same refusal as its one next action, with the same fix beside it. await openSheet(browser, "models"); - await until(browser, "Models refuses verifying a conflicted preview and offers theirs", - `${goIs(modelsSheet, "verify models", "refused")} && JSON.stringify(${goParts(modelsSheet)}) === ${JSON.stringify(JSON.stringify([["The workspace profile changed elsewhere", "attention"]]))} && + await until(browser, "Models refuses checking a conflicted preview and offers theirs", + `${goIs(modelsSheet, "check models", "refused")} && JSON.stringify(${goParts(modelsSheet)}) === ${JSON.stringify(JSON.stringify([["The workspace profile changed elsewhere", "attention"]]))} && JSON.stringify(${goFixes(modelsSheet)}) === '["use theirs"]'`); await closeSheet(browser); await showView(browser, "accounts"); @@ -1899,7 +1951,7 @@ async function starterConflictScenario(browser: BrowserInstance, server: TestSer await still(browser); assert.deepEqual(await routedRoles(browser), routes, "A conflict does not replace the shown routes"); assert.equal((await readConfiguration(server, writer, target)).revision, 1, "Navigation cannot auto-rebase or replay a rejected first adoption"); - assert.equal(await browser.evaluate(launchIs("verify models", "refused")), true, "A conflicted preview stays unsavable after observation recovery"); + assert.equal(await browser.evaluate(launchIs("save & launch", "refused")), true, "A conflicted preview stays unsavable after observation recovery"); } /** Only transport failure/latency is injected. Every successful configuration and @@ -2004,23 +2056,24 @@ async function starterObservationScenario(browser: BrowserInstance, server: Test } } -// ---------------------------------------------------------------- the verification charge +// ---------------------------------------------------------------- the model check's charge /** - * Synthetic OMP observations reach the verification charge: the destination reports discovery and - * benchmark ready, a fictional account is observed and the inventory job has settled. Code's own - * initialization, probe composition and inventory draft are the real server's. The benchmark that - * would spend is refused, so nothing is probed, verified or saved. The account switch at the end is - * the real changeAccounts CAS against the real server. + * Synthetic OMP observations reach the charge of the model check a first Launch runs: the destination reports discovery + * and benchmark ready, a fictional account is observed and the inventory job has settled. Code's own initialization, + * probe composition, inventory draft and the person's approval are the real server's. The benchmark that would spend is + * refused, so nothing is probed, checked or saved. It runs as a person of its own (`asNewPerson`), who has approved + * nothing, so the first charge asks; once their approve press records an approval, Setup's ask first withdraws it, so + * the charges after it ask too. The account switch at the end is the real changeAccounts CAS against the real server. */ -async function verificationChargeScenario(browser: BrowserInstance, server: TestServer, writer: TokenGrant, destination: Target): Promise { - const workspace = await createContainer(server, "Verification charge", "canvas"); +async function checkChargeScenario(browser: BrowserInstance, server: TestServer, writer: TokenGrant, destination: Target): Promise { + const workspace = await createContainer(server, "Model check charge", "canvas"); const target: Target = { containerId: workspace.id, machineId: destination.machineId }; const packed = JSON.parse(readFileSync(join(ompBundleDirectory, "atyrode.omp.manifold-plugin.json"), "utf8")) as { manifest: { machine: { operations: Record } }; }; for (const operationId of [INVENTORY_OPERATION_ID, BENCHMARK_OPERATION_ID]) assert(packed.manifest.machine.operations[operationId], "Synthetic readiness names real upstream operations"); - // The synthetic installation the probe jobs (`probeJob`) run from: a verification stops once the destination pins another. + // The synthetic installation the probe jobs (`probeJob`) run from: a model check stops once the destination pins another. const pins = { installationRevision: "synthetic-ui-only", artifactSha256: "c".repeat(64), resourceBindingDigest: "c".repeat(64) }; const model = (id: string, input: number, levels: ("low" | "medium" | "high" | "xhigh" | "max")[]) => ({ provider: "anthropic", id, api: "anthropic-messages", inputCostPerMillion: input, outputCostPerMillion: input * 5, contextWindow: 200_000, maxTokens: 64_000, reasoning: true, thinkingLevels: levels, images: true, quotaTier: null }); @@ -2028,7 +2081,7 @@ async function verificationChargeScenario(browser: BrowserInstance, server: Test model("claude-haiku-5", 1, ["low", "medium", "high"]), model("claude-sonnet-5", 3, ["low", "medium", "high", "xhigh"]), model("claude-opus-5", 5, ["low", "medium", "high", "xhigh", "max"]) ] }; const charge = inventoryDraft(inventory, "any").benchmark.candidates; - const started: Record[] = [], benchmarks: Record[] = [], cancels: unknown[] = []; + const started: Record[] = [], benchmarks: Record[] = [], cancels: unknown[] = [], approvals: Record[] = []; let listFails = true; // A check held in flight, to observe the panel while a step runs. const heldInventory = holdable(); @@ -2063,20 +2116,22 @@ async function verificationChargeScenario(browser: BrowserInstance, server: Test case "atyrode.omp.readInventory": return { ok: true, result: { job: probeJob(target, String(input.jobId), INVENTORY_OPERATION_ID, writer.principal.id), inventory } }; case "atyrode.omp.startBenchmark": { const request = ompActionSchemas.startBenchmark.input.parse(input); - assert.deepEqual(request.candidates.candidates, charge, "A confirmation spends exactly the charge it showed"); + assert.deepEqual(request.candidates.candidates, charge, "An approve press spends exactly the charge it showed"); benchmarks.push(input); return refused("synthetic_benchmark_refused"); } case "engine.jobs.cancel": cancels.push(input); return refused("synthetic_cancel_unexpected"); + // The person's approval is written by the real server; its inputs are kept to check what each press recorded. + case "atyrode.code.approveChecks": approvals.push(input); return undefined; default: return undefined; } }); try { - // The model list fails at first. Verify needs no list, so it stays the step; with no profile to form there are no - // rows and no routes, and the launch line names the failure with the list's own retry and Models. + // The model list fails at first. A first Launch checks the models, which needs no list, so it stays the step; with no + // profile to form there are no rows and no routes, and the launch line names the failure with the list's own retry and Models. await openGenerator(browser, server, workspace.id); - await until(browser, "beside a ready Verify the launch line names the failed model list instead of a profile, with its retry and Models", - `${launchIs("verify models", "ready")} && ${listFailure("instead")}`); + await until(browser, "beside a ready Launch the launch line names the failed model list instead of a profile, with its retry and Models", + `${launchIs("launch", "ready")} && ${listFailure("instead")}`); assert.equal(await browser.evaluate(`document.querySelector(${JSON.stringify(routeRows)}) === null`), true, "With no profile to form there are no routes"); listFails = false; await click(browser, fix("list-retry")); @@ -2084,28 +2139,36 @@ async function verificationChargeScenario(browser: BrowserInstance, server: Test // On arrival the panel root has focus, so its keys work at once: an arrow gives the rows focus and changes nothing. await openGenerator(browser, server, workspace.id); - await until(browser, "with discovery ready the first step is to verify", launchIs("verify models", "ready")); + await until(browser, "with discovery ready the first launch is ready, checking the models first", launchIs("launch", "ready")); await until(browser, "on arrival the panel root has focus", `document.activeElement === ${element(generator)}`); const arrived = await browser.evaluate(profileValues); await key(browser, "ArrowDown", 40); await until(browser, "an arrow on arrival gives the first row focus", `!!${row("lane")}.contains(document.activeElement)`); assert.equal(await browser.evaluate(profileValues), arrived, "An arrow on arrival changes no setting"); + assert.deepEqual(started, [], "Before a press nothing checks on its own: the person has approved nothing and keeps no check"); - // A double-click on Verify starts one verification; its second click lands on a busy launch, never on Confirm. + // A double-click on Launch starts one check; its second click lands on a busy launch, never on Approve. const point = await pointOf(browser, launchButton); await press(browser, point, 1); await press(browser, point, 2); - await until(browser, "the pressed launch checks the accounts' models and keeps focus while it checks", - `${launchIs("checking…", "busy")} && document.activeElement === ${launchButton} && ${launchButton}.getAttribute('aria-busy') === 'true'`); - await until(browser, "the charge waits on its own confirmation", `${launchIs("verify models", "waiting")} && ${confirmCharge} !== null`); - assert.equal(started.length, 1, "A double-click on Verify starts exactly one inventory"); - assert.equal(benchmarks.length, 0, "A double-click on Verify never confirms the charge"); - assert.equal(await browser.evaluate(`document.activeElement === ${launchButton}`), true, "Confirm charge is never focused for the person; the launch keeps focus"); + await until(browser, "the pressed launch checks the models and keeps focus while it checks", + `${launchIs("checking models…", "busy")} && document.activeElement === ${launchButton} && ${launchButton}.getAttribute('aria-busy') === 'true'`); + await until(browser, "the charge waits on its own approval", `${launchIs("launch", "waiting")} && ${approveButton} !== null`); + assert.equal(started.length, 1, "A double-click on Launch starts exactly one inventory"); + assert.equal(benchmarks.length, 0, "A double-click on Launch never approves the charge"); + assert.equal(await browser.evaluate(`document.activeElement === ${launchButton}`), true, "Approve is never focused for the person; the launch keeps focus"); assert.equal(await browser.evaluate(`${launchButton}.getAttribute('aria-disabled')`), "true", "While a charge waits the launch refuses by aria-disabled"); - assert.equal(await browser.evaluate(`${confirmCharge}.getAttribute('aria-label')`), `Confirm charge: ${charge.length} ${charge.length === 1 ? "request" : "requests"}`, - "Confirm is bound to the exact charge shown"); - await until(browser, "the charge is announced", `${liveRegion}?.textContent.includes(${JSON.stringify(String(charge.length))})`); - assert.equal(await browser.evaluate(`${lineText}.includes(${JSON.stringify(`Claude ${charge.length}`)})`), true, "The charge names its provider by family"); + assert.deepEqual(await browser.evaluate(`[${approveButton}.textContent, ${approveButton}.getAttribute('aria-label')]`), + ["approve", `Approve: ${charge.length} ${charge.length === 1 ? "request" : "requests"}, and later checks within it`], + "Approve is bound to the exact charge shown, and to the later checks within it"); + await until(browser, "the charge is announced, with approve as the next control", + `${liveRegion}?.textContent.includes(${JSON.stringify(`Checking models spends ${charge.length} tiny requests. Approve is the next control.`)})`); + assert.deepEqual(await browser.evaluate(launchParts), [[`Checking models spends ${charge.length} tiny ${charge.length === 1 ? "request" : "requests"}`, "plain"], [`Claude ${charge.length}`, "plain"]], + "The charge says what checking the models spends, by provider family"); + // Pointed, the launch says the line's aside: the person is asked once, and later checks within it run on their own. + await pointOf(browser, launchButton); + await until(browser, "pointed, the launch says the approval is asked once", `${readout}.textContent.includes('asked once: later checks run on their own')`); + await pointAway(browser); // While the charge waits, no profile, machine or account edit may start: every row and the machine refuse with the gate's reason. assert.deepEqual(await browser.evaluate(`[...document.querySelectorAll('${stage} [data-row]')].filter(row => row.getAttribute('aria-disabled') !== 'true').map(row => row.dataset.row)`), [], @@ -2124,24 +2187,42 @@ async function verificationChargeScenario(browser: BrowserInstance, server: Test "No profile or machine edit lands while a charge waits, and the machine list does not open"); await focusLaunch(browser); await key(browser, "Tab", 9); - assert.equal(await browser.evaluate(`document.activeElement === ${confirmCharge}`), true, "Confirm charge is the next Tab stop after the launch"); + assert.equal(await browser.evaluate(`document.activeElement === ${approveButton}`), true, "Approve is the next Tab stop after the launch"); + + // The waiting line beside the launch, its charge, approve and Cancel: pointing at or focusing anything moves no layout box, + // from 170 to 1440px nothing overflows, cuts or collides, and on a coarse pointer every target is 44px. None of it is a + // step: the charge still waits, nothing more is checked, spent or approved. + await zeroShift(browser, "waiting charge"); + await geometryAcrossWidths(browser, "waiting charge"); + await browser.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 }); + try { + assert.equal(await browser.evaluate("matchMedia('(pointer: coarse)').matches"), true); + await coarseTargets(browser, "waiting charge"); + } finally { + await browser.send("Emulation.clearDeviceMetricsOverride", {}); + await browser.send("Emulation.setTouchEmulationEnabled", { enabled: false }); + } + assert.equal(await browser.evaluate(`${launchIs("launch", "waiting")} && ${approveButton} !== null`), true, "Pointing, focus, views and resizing leave the charge waiting"); + assert.deepEqual([started.length, benchmarks.length, approvals.length], [1, 0, 0], "Pointing, focus, views and resizing check, spend and approve nothing"); // Only a deliberate single press spends: never the second click of a multi-click, never a held key. - await press(browser, await pointOf(browser, confirmCharge), 2); + await press(browser, await pointOf(browser, approveButton), 2); await key(browser, "Enter", 13, { autoRepeat: true }); await key(browser, " ", 32, { autoRepeat: true }); await Bun.sleep(300); - assert.equal(benchmarks.length, 0, "Neither a second click nor key repeat confirms the charge"); - assert.equal(await browser.evaluate(`${confirmCharge} !== null`), true, "The charge still waits"); - // The press that prepared the charge initialized the first-use workspace: that is the run's own - // effect, so a cancelled or stopped run offers Verify again, never a conflict with "theirs". - const verifyAgain = `${launchIs("verify models", "ready")} && ${confirmCharge} === null && !${fix("conflict")}`; + assert.equal(benchmarks.length, 0, "Neither a second click nor key repeat approves the charge"); + assert.deepEqual(approvals, [], "Neither a second click nor key repeat records an approval"); + assert.equal(await browser.evaluate(`${approveButton} !== null`), true, "The charge still waits"); + // The press that prepared the charge initialized the first-use workspace: that is the check's own + // effect, so a cancelled or stopped check offers Launch again, never a conflict with "theirs". + const launchAgain = `${launchIs("launch", "ready")} && ${approveButton} === null && !${fix("conflict")}`; await click(browser, cancelCharge); - await until(browser, "a cancelled first-use charge offers Verify again without a conflict", verifyAgain); + await until(browser, "a cancelled first-use charge offers Launch again without a conflict, and says the check was cancelled", + `${launchAgain} && JSON.stringify(${launchParts}) === ${JSON.stringify(JSON.stringify([["Model check cancelled", "done"]]))}`); assert.equal(benchmarks.length, 0, "Cancelling a waiting charge spends nothing"); assert.deepEqual(cancels, [], "A charge that never spent has no native job to cancel"); const prepared = await readConfiguration(server, writer, target); - assert.equal(prepared.configuration?.revision, 1, "The Verify press initialized the first-use workspace exactly once"); + assert.equal(prepared.configuration?.revision, 1, "The Launch press initialized the first-use workspace exactly once"); assert.equal(prepared.configuration?.active, null, "Preparing a charge promotes nothing"); assert.equal(prepared.configuration?.draft, null, "Preparing a charge stages nothing"); @@ -2166,41 +2247,67 @@ async function verificationChargeScenario(browser: BrowserInstance, server: Test // From here the SDK's feed probe counts the panel's own reads (`ownReads`), checked at the end of this scenario. await browser.evaluate("localStorage.setItem('manifold:debug', '1')"); // On arrival a plain ↵ at the panel root takes the launch's step through the same gate as Mod+↵: it prepares the - // charge, and pressed again while the charge waits it never confirms it. + // charge, and pressed again while the charge waits it never approves it. Under reduced motion neither the check nor + // the waiting charge animates. await openGenerator(browser, server, workspace.id); - const openedAt = Date.now(); - await until(browser, "the reopened panel offers Verify", verifyAgain); + await until(browser, "the reopened panel offers Launch", launchAgain); await until(browser, "on arrival the panel root has focus", `document.activeElement === ${element(generator)}`); - await key(browser, "Enter", 13); - await until(browser, "↵ on arrival prepares a charge", `${launchIs("verify models", "waiting")} && ${confirmCharge} !== null`); + await browser.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: "reduce" }] }); + try { + // A transition already under way when motion is reduced runs out its own duration; the press starts from rest. + await still(browser); + await key(browser, "Enter", 13); + await until(browser, "↵ on arrival checks the models", launchIs("checking models…", "busy")); + await settle(browser); + assert.equal(await browser.evaluate(running), 0, "Reduced motion: the check a launch runs does not animate"); + await until(browser, "↵ on arrival prepares a charge", `${launchIs("launch", "waiting")} && ${approveButton} !== null`); + await settle(browser); + assert.equal(await browser.evaluate(running), 0, "Reduced motion: the waiting charge and its approve do not animate"); + } finally { + await browser.send("Emulation.setEmulatedMedia", { features: [] }); + } assert.equal(started.length, 2, "↵ on arrival takes exactly one step"); assert.equal(await browser.evaluate(`document.activeElement === ${element(generator)}`), true, "The step leaves focus on the panel root"); await key(browser, "Enter", 13); await Bun.sleep(300); - assert.equal(benchmarks.length, 0, "↵ at the panel root never confirms a waiting charge"); - assert.equal(await browser.evaluate(`${confirmCharge} !== null`), true, "The charge still waits"); + assert.equal(benchmarks.length, 0, "↵ at the panel root never approves a waiting charge"); + assert.equal(await browser.evaluate(`${approveButton} !== null`), true, "The charge still waits"); await click(browser, cancelCharge); - await until(browser, "the cancelled charge offers Verify again", verifyAgain); + await until(browser, "the cancelled charge offers Launch again", launchAgain); // Mod+↵ takes the launch's step from a row and runs from the initialized revision (a run from the // stale one would refuse before its inventory); holding it takes no other step; one deliberate - // press spends the charge once. + // press approves the charge, which records the person's approval, and spends it once. await focusRow(browser, "lane"); await key(browser, "Enter", 13, { modifiers: CTRL }); - await until(browser, "Mod+↵ prepares a charge from revision 1", `${launchIs("verify models", "waiting")} && ${confirmCharge} !== null`); + await until(browser, "Mod+↵ prepares a charge from revision 1", `${launchIs("launch", "waiting")} && ${approveButton} !== null`); assert.equal(started.length, 3); await key(browser, "Enter", 13, { modifiers: CTRL, autoRepeat: true }); await Bun.sleep(300); assert.equal(started.length, 3, "A held Mod+↵ never takes another step"); assert.equal(benchmarks.length, 0); - await click(browser, confirmCharge); + await click(browser, approveButton); await waitFor(() => benchmarks.length === 1, timeout, 50); - await until(browser, "the refused spend stops the verification and the launch offers it again without a conflict", verifyAgain); - assert.equal(benchmarks.length, 1, "One press spends once"); - assert.equal(await browser.evaluate(failureLine), true, "The stopped verification says so beside the launch"); - assert.deepEqual(await readConfiguration(server, writer, target), prepared, "Neither the later runs nor the refused benchmark initialize, stage, promote or save anything"); + await until(browser, "the refused spend stops the check, which says so beside the launch, and the launch offers it again without a conflict", + `${launchAgain} && ${failureLine} && ${lineText}.includes('Model check stopped while measuring models: ')`); + assert.deepEqual(approvals, [{ expectedRevision: 0, requests: charge.length }], "The approve press records the person's approval of exactly the charge it shows"); + assert.equal((await readChecks(server, writer)).approvedRequests, charge.length, "The person's approval is kept for their later checks"); + // A stopped check is the person's to try again: nothing checks again on its own, so no second inventory follows and + // nothing is spent twice, and the stop stays said beside the launch. + await Bun.sleep(1_000); + assert.deepEqual([started.length, benchmarks.length], [3, 1], "One approve press spends once, and no inventory follows the stopped check"); + assert.equal(await browser.evaluate(`${launchAgain} && ${failureLine}`), true, "The stopped check stays said beside the launch"); + assert.deepEqual(await readConfiguration(server, writer, target), prepared, "Neither the later checks nor the refused benchmark initialize, stage, promote or save anything"); assert.deepEqual(cancels, [], "A spend refused at its start leaves no native job to cancel"); + // The approval is the person's, so Setup says how far it reaches, and ask first withdraws it: every charge after it + // asks again. + await askFirst(browser, charge.length); + // Closing Setup read the panel's inputs; the reads below count from here. + const askedAt = Date.now(); + assert.deepEqual(approvals.at(-1), { expectedRevision: 1, requests: 0 }, "Ask first withdraws the approval at the revision it was recorded at"); + assert.equal((await readChecks(server, writer)).approvedRequests, 0, "Withdrawn, the approval lets no check spend without asking"); + // The accounts view: `a` gives its first switch focus, Space saves that account's inclusion through the real // changeAccounts CAS, and the switch keeps focus through the re-read its save causes. await key(browser, "a", 65); @@ -2223,9 +2330,9 @@ async function verificationChargeScenario(browser: BrowserInstance, server: Test await until(browser, "Esc closes the accounts and gives the rows focus", `${shownView("main")} && !!document.activeElement?.closest('${generator} [data-row]')`); // The panel reads its inputs again on its own (auto-read.ts). Away at another tab and back a while after its last read - // (it read when it opened, above), it reads them again, once. + // (it read when Setup closed, above), it reads them again, once. const unread = await readConfiguration(server, writer, target); - await Bun.sleep(Math.max(0, openedAt + SHOWN_AGAIN_MS + 1_000 - Date.now())); + await Bun.sleep(Math.max(0, askedAt + SHOWN_AGAIN_MS + 1_000 - Date.now())); const counted = await browser.evaluate<(number | null)[]>(ownReads); assert(counted.every(count => count !== null), "The SDK's feed probe counts the panel's reads of the machine list and OMP's defaults"); const reads = counted.map(count => count! + 1); @@ -2265,7 +2372,7 @@ async function verificationChargeScenario(browser: BrowserInstance, server: Test // Discard returns focus to the management's Active account pool selector, a field in use that holds the read too; // with focus gone from it to the generator, nothing holds the read any more. await click(browser, element(`${generator} [data-view-tab="main"]`)); - await until(browser, "the Generator tab shows the generator, which offers Verify", `${shownView("main")} && ${verifyAgain}`); + await until(browser, "the Generator tab shows the generator, which offers Launch", `${shownView("main")} && ${launchAgain}`); await readsOnce(browser, "once the unsaved pool is discarded the panel reads its inputs again", ++machineReads); readAt = Date.now(); // A sheet's opening and its closing each read everything, once. With the sheet open the panel reads nothing on its own, @@ -2279,18 +2386,18 @@ async function verificationChargeScenario(browser: BrowserInstance, server: Test await closeSheet(browser); await readsOnce(browser, "closing the sheet reads the panel's inputs", ++machineReads); readAt = Date.now(); - // While a step runs (a verification's check, held in flight) the panel reads nothing, away and back or not; once the - // step and the charge it prepares have ended, it reads, once. + // While a step runs (a launch's check, held in flight) the panel reads nothing, away and back or not; once the step and + // the charge it prepares have ended, it reads, once. The approval withdrawn, the charge asks again. holdNextInventory = true; await click(browser, launchButton); await waitFor(() => heldInventory.held, timeout, 50); - await until(browser, "the verification's check runs", launchIs("checking…", "busy")); + await until(browser, "the launch's check runs", launchIs("checking models…", "busy")); await lookAwayAfter(browser, readAt); await readsHeld(browser, "While a step runs the panel reads nothing", Date.now(), machineReads); heldInventory.release(); - await until(browser, "the checked charge waits", `${launchIs("verify models", "waiting")} && ${confirmCharge} !== null`); + await until(browser, "with the approval withdrawn the checked charge waits on approve again", `${launchIs("launch", "waiting")} && ${approveButton} !== null`); await click(browser, cancelCharge); - await until(browser, "the cancelled charge offers Verify again", verifyAgain); + await until(browser, "the cancelled charge offers Launch again", launchAgain); await readsOnce(browser, "once the step has ended the panel reads its inputs again", ++machineReads); assert.deepEqual(await readConfiguration(server, writer, target), unread, "Reading on its own, a discarded pool and a cancelled check write nothing"); await browser.evaluate("localStorage.removeItem('manifold:debug')"); @@ -2300,17 +2407,18 @@ async function verificationChargeScenario(browser: BrowserInstance, server: Test await fixture.stop(); } assert.deepEqual(await ownerAction(server, "engine.jobs.listDeployments", { pluginId: "atyrode.omp", limit: 100 }), deployments, - "The verification charge never requests native approval"); + "The model check's charge never requests native approval"); assert.deepEqual(await ownerAction(server, "core.terminals.listAll", {}), terminals); } /** - * A verified catalog outlives an OMP update its routed models survive. Recorded as the workflow records it — the pinned + * A checked list outlives an OMP update its routed models survive. Recorded as the workflow records it — the pinned * OMP's artifact and the real bundled catalog's revision — on a destination whose synthetic readiness pins that artifact, - * the verification is current and the launch is the step. The same destination upgraded alone, with the same accounts, - * catalog and Code bundles, keeps the launch the step, and Models says OMP was updated since. A model list then - * republished under another revision without the default lead makes Verify the step, ready to run, and Models names the - * model OMP dropped. Nothing is probed, spent, saved or installed. + * the check holds and the launch is the step. The same destination upgraded alone, with the same accounts, catalog and + * Code bundles, keeps the launch the step, and Models says OMP was updated since. A model list then republished under + * another revision without the default lead makes the launch check the models first, ready to run, and Models names the + * model OMP dropped. The writer has approved nothing, so no check renews the list on its own. Nothing is probed, spent, + * saved or installed. */ async function ompUpgradeScenario(browser: BrowserInstance, server: TestServer, writer: TokenGrant, destination: Target): Promise { const workspace = await createContainer(server, "OMP upgrade", "canvas"); @@ -2328,7 +2436,7 @@ async function ompUpgradeScenario(browser: BrowserInstance, server: TestServer, const staged = await callAction(server, writer.token, "atyrode.code.stageCatalog", { containerId: workspace.id, expectedRevision: revision, document, verification: { ompVersion: OMP_VERSION, inventoryArtifactSha256: runtime, catalogRevision: metadata.revision, inventoryObservedAt: Date.now() - 3_000, benchmarkCompletedAt: Date.now() - 2_000, accounts: observation, - poolIdentityDigest: (probe.result as ActionResult<"composeProbe">).poolIdentityDigest } }); + poolIdentityDigest: (probe.result as ActionResult<"composeProbe">).poolIdentityDigest, budget: "any" } }); assert(staged.ok, "A verification records the pinned OMP's artifact and the real catalog revision"); const reviewInput = { containerId: workspace.id, expectedRevision: (staged.result as Configuration).revision, source: "draft" }; const reviewed = await callAction(server, writer.token, "atyrode.code.reviewCatalog", reviewInput); @@ -2358,24 +2466,25 @@ async function ompUpgradeScenario(browser: BrowserInstance, server: TestServer, await openGenerator(browser, server, workspace.id); await waitFor(() => described > 0, timeout, 50); await Bun.sleep(500); - // Discovery is ready, so a stale verification would make Verify the step; the launch this machine has no permission for is. - await until(browser, "on the OMP it ran under, the verification is current and the next step is the launch", launchIs("launch", "refused")); + // Discovery is ready, so a list that no longer held would make the launch check the models first, ready to run; the + // launch this machine has no permission for is refused instead. + await until(browser, "on the OMP it ran under, the check holds and the next step is the launch", launchIs("launch", "refused")); // OMP upgraded on the machine, and nothing else: its runtime is another artifact, whatever version it reports. Models // says so once the panel has read it, and the launch stays the step. runtime = "9".repeat(64); await openGenerator(browser, server, workspace.id); await openFromMore(browser, "models"); - await until(browser, "Models keeps the verification and says OMP was updated since", + await until(browser, "Models keeps the check and says OMP was updated since", `${sheetState(modelsSheet)}.endsWith(' · OMP updated since') && ${goIs(modelsSheet, "back to code")}`); await closeSheet(browser); await until(browser, "an OMP upgrade alone keeps the launch the next step", launchIs("launch", "refused")); // The model list republished under another revision without the default lead: the launch would be refused for it. republished = { ...metadata, revision: "f".repeat(64), models: metadata.models.filter(model => model.provider !== lead.provider || model.id !== lead.id) }; await openGenerator(browser, server, workspace.id); - await until(browser, "a model list that dropped a routed model makes Verify the step again, ready to run", launchIs("verify models", "ready")); + await until(browser, "a model list that dropped a routed model makes the launch check the models first, ready to run", launchIs("launch", "ready")); await openFromMore(browser, "models"); - await until(browser, "Models names the model OMP dropped beside verify models", - `${goIs(modelsSheet, "verify models")} && JSON.stringify(${goParts(modelsSheet)}) === ${JSON.stringify(JSON.stringify([[`OMP dropped ${alias}`, "warn"]]))}`); + await until(browser, "Models names the model OMP dropped beside check models", + `${goIs(modelsSheet, "check models")} && JSON.stringify(${goParts(modelsSheet)}) === ${JSON.stringify(JSON.stringify([[`OMP dropped ${alias}`, "warn"]]))}`); await closeSheet(browser); assert.deepEqual(await readConfiguration(server, writer, target), saved, "Observing an updated OMP saves nothing"); fixture.check(); @@ -2392,9 +2501,10 @@ async function ompUpgradeScenario(browser: BrowserInstance, server: TestServer, * lists the pinned OMP's bundled Claude and Codex rows with OMP's quota class on each, and Spark in its `spark` class, * and each provider's benchmark job answers every candidate reachable at fictional speeds, except the Codex models the * plan does not serve (`unserved`), which it answers `client_blocked`. Everything Code decides is the real - * server's: the initialization, the probe pool, the charge (`draftInventory`), the derivation, staging with the recorded - * verification, reviews, promotions, imports and discards. No provider is reached, no native job runs and nothing is - * approved. + * server's: the initialization, the probe pool, the charge (`draftInventory`), the person's approval, the derivation, + * staging with the recorded check, reviews, promotions, imports and discards. It runs as a person of its own + * (`asNewPerson`), so its first check asks; the approval its approve press records is withdrawn in Setup before checking + * again, so that charge asks too. No provider is reached, no native job runs and nothing is natively approved. */ async function modelsSheetScenario(browser: BrowserInstance, server: TestServer, writer: TokenGrant, destination: Target): Promise { const workspace = await createContainer(server, "Models sheet", "canvas"); @@ -2510,7 +2620,7 @@ async function modelsSheetScenario(browser: BrowserInstance, server: TestServer, const phaseIs = (phase: string) => `${element(modelsSheet)}?.dataset.phase === ${JSON.stringify(phase)}`; const sideIs = (side: string | null) => side === null ? `${element(`${modelsSheet} [data-side]`)} === null` : `${element(`${modelsSheet} [data-side="${side}"]`)} !== null`; const writes = (from: number) => trace.requests.slice(from).filter(request => /^atyrode\.code\.(initializeConfiguration|stageCatalog|reviewCatalog|promoteCatalog|discardCatalog|select)$/.test(request.name)); - /** What a verification run leads to, by the derivation Code's server makes of it: the union of its providers' receipts. */ + /** What a check leads to, by the derivation Code's server makes of it: the union of its providers' receipts. */ const derivedFrom = (inventoryJobId: string) => { const inventory = inventories.get(inventoryJobId)!; const receipts = [...benchmarks.values()].filter(run => run.request.inventoryJobId === inventoryJobId && run.receipt).map(run => run.receipt!); @@ -2542,17 +2652,17 @@ async function modelsSheetScenario(browser: BrowserInstance, server: TestServer, try { await openGenerator(browser, server, workspace.id); await chooseMachine(browser, target.machineId, machineName); - await until(browser, "with discovery ready the first step is to verify", launchIs("verify models", "ready")); + await until(browser, "with discovery ready the first launch is ready, checking the models first", launchIs("launch", "ready")); - // More opens Models with focus on its way back. The head counts the bundled starter's models and says it is unverified; - // the list is the starter's ladders, unmeasured, and the one next action is to verify. + // More opens Models with focus on its way back. The head counts the bundled starter's models and says they are not + // checked; the list is the starter's ladders, unmeasured, and the one next action is to check them. await openFromMore(browser, "models"); - await until(browser, "Models shows the starter, unverified, with verify models its one next action", `${phaseIs("unverified")} && ${goIs(modelsSheet, "verify models")} && ${sideIs(null)}`); + await until(browser, "Models shows the starter, not checked, with check models its one next action", `${phaseIs("unverified")} && ${goIs(modelsSheet, "check models")} && ${sideIs(null)}`); assert.deepEqual(await browser.evaluate(`({ state: ${sheetState(modelsSheet)}, parts: ${goParts(modelsSheet)}, fixes: ${goFixes(modelsSheet)}, list: ${modelList} })`), - { state: `${listOf(starter).length} models · starter · unverified`, parts: [["unverified", "warn"]], fixes: [], list: listOf(starter) }, - "The head counts the starter's models, each rung is its model and verifying is the one next action"); + { state: `${listOf(starter).length} models · starter · not checked`, parts: [["not checked", "warn"]], fixes: [], list: listOf(starter) }, + "The head counts the starter's models, each rung is its model and checking them is the one next action"); assert.equal(await browser.evaluate(`[...document.querySelectorAll('${modelsSheet} [data-cell][data-key]')].every(cell => cell.dataset.unmeasured !== undefined)`), true, - "Nothing is measured before a verification"); + "Nothing is measured before a check"); // Ids, prices and speeds are never drawn in the list; pointing at a rung says its model's exact id and prices in the readout. const grid = element(`${modelsSheet} [role="grid"][aria-label="model list"]`); assert.equal(await browser.evaluate(`/\\/|\\$|tok\\/s/.test(${grid}.textContent)`), false, "The list draws aliases only: no provider ids, prices or speeds"); @@ -2595,8 +2705,8 @@ async function modelsSheetScenario(browser: BrowserInstance, server: TestServer, await sheetZeroShift(browser, modelsSheet, "Models starter"); await sheetGeometry(browser, modelsSheet, "Models starter"); - // A double-click on verify models starts one check: its second click lands on the busy check, which draws the hold the - // charge keeps before it can be confirmed, never on a confirm. Import and export refuse while it runs. + // A double-click on check models starts one check: its second click lands on the busy check, which draws the hold the + // charge keeps before it can be approved, never on approve. Import and export refuse while it runs. holdNextInventory = true; const point = await pointOf(browser, goButton); await press(browser, point, 1); @@ -2604,78 +2714,83 @@ async function modelsSheetScenario(browser: BrowserInstance, server: TestServer, await until(browser, "the press checks the accounts' models, the checking hold drawn, focus kept", `${phaseIs("inventory")} && ${goIs(modelsSheet, "checking models…", "busy")} && ${goButton}.dataset.hold !== undefined && document.activeElement === ${goButton}`); await waitFor(() => heldInventory.held, timeout, 50); - assert.equal(started.length, 1, "A double-click on verify models starts exactly one inventory"); - assert.deepEqual(await browser.evaluate(`({ state: ${sheetState(modelsSheet)}.endsWith(' · verifying'), cues: [...document.querySelectorAll('${modelsSheet} [data-cue]')].map(cue => cue.getAttribute('aria-disabled')), fixes: ${goFixes(modelsSheet)} })`), - { state: true, cues: ["true", "true"], fixes: ["cancel"] }, "While it checks the head says a verification runs, import and export refuse, and cancel is beside it"); + assert.equal(started.length, 1, "A double-click on check models starts exactly one inventory"); + assert.deepEqual(await browser.evaluate(`({ state: ${sheetState(modelsSheet)}.endsWith(' · checking'), cues: [...document.querySelectorAll('${modelsSheet} [data-cue]')].map(cue => cue.getAttribute('aria-disabled')), fixes: ${goFixes(modelsSheet)} })`), + { state: true, cues: ["true", "true"], fixes: ["cancel"] }, "While it checks the head says a check runs, import and export refuse, and cancel is beside it"); heldInventory.release(); const firstInventory = "synthetic-inventory-1"; const charge = inventoryDraft(inventories.get(firstInventory)!, "any"); const requests = (provider: string) => charge.benchmark.candidates.filter(candidate => candidate.provider === provider).length; const count = (n: number, one: string) => `${n} ${n === 1 ? one : `${one}s`}`; - await until(browser, "after the hold the charge waits on its own confirmation", `${phaseIs("charge")} && ${goIs(modelsSheet, "confirm charge")} && ${sideIs("charge")}`); + await until(browser, "after the hold the charge waits on its own approval", `${phaseIs("charge")} && ${goIs(modelsSheet, "approve")} && ${sideIs("charge")}`); assert.deepEqual(await browser.evaluate(`({ parts: ${goParts(modelsSheet)}, fixes: ${goFixes(modelsSheet)}, providers: [...document.querySelectorAll('${modelsSheet} [data-side="charge"] .${G}models-pw')].map(el => el.getAttribute('aria-label')).sort(), unprobed: [...document.querySelectorAll('${modelsSheet} [data-side="charge"] .${G}models-xid')].map(el => el.textContent) })`), { - parts: [[count(charge.benchmark.candidates.length, "tiny request"), "strong"], ["nothing spent yet", null]], fixes: ["cancel"], + parts: [[count(charge.benchmark.candidates.length, "tiny request"), "strong"], ["asked once", null]], fixes: ["cancel"], providers: [`Claude: ${count(requests("anthropic"), "request")}`, `Codex: ${count(requests("openai-codex"), "request")}`], unprobed: charge.exclusions.map(exclusion => `${exclusion.provider}/${exclusion.id}`), - }, "The charge says exactly what it spends per provider, that nothing is spent yet, and which models are not probed"); + }, "The charge says exactly what it spends per provider, that it is asked once, and which models are not probed"); await sheetGeometry(browser, modelsSheet, "Models charge"); // Only a deliberate single press spends: never the second click of a double-click, never a held key. await press(browser, await pointOf(browser, goButton), 2); - await until(browser, "a double press confirms nothing and says so", `${readoutLine}.dataset.tone === 'warn' && ${sheetSays(modelsSheet)} === 'confirm charge · A double press confirms nothing; press once.'`); + await until(browser, "a double press approves nothing and says so", `${readoutLine}.dataset.tone === 'warn' && ${sheetSays(modelsSheet)} === 'approve · A double press approves nothing; press once.'`); assert.equal(await browser.evaluate(`document.activeElement === ${goButton}`), true, "The next action keeps focus"); await key(browser, "Enter", 13, { autoRepeat: true }); await key(browser, " ", 32, { autoRepeat: true }); await Bun.sleep(300); - assert.equal(benchmarks.size, 0, "Neither a second click nor a held key confirms the charge"); + assert.equal(benchmarks.size, 0, "Neither a second click nor a held key approves the charge"); assert.equal(await browser.evaluate(phaseIs("charge")), true, "The charge still waits"); + const approvals = () => trace.requests.filter(request => request.name === "atyrode.code.approveChecks").map(request => request.input); + assert.deepEqual(approvals(), [], "Neither a second click nor a held key records an approval"); - // One press spends: verifying, the requests measured per provider, the rungs of the providers still owed probing. + // One press approves and spends: the person's approval of the charge is recorded first, then the check measures, the + // requests measured per provider, the rungs of the providers still owed probing. holdNextBenchmark = true; await click(browser, goButton); - await until(browser, "one press confirms: verifying, measuring per provider", `${phaseIs("benchmark")} && ${goIs(modelsSheet, "verifying…", "busy")} && ${sideIs("measuring")}`); + await until(browser, "one press approves: checking, measuring per provider", `${phaseIs("benchmark")} && ${goIs(modelsSheet, "checking…", "busy")} && ${sideIs("measuring")}`); + assert.deepEqual(approvals(), [{ expectedRevision: 0, requests: charge.benchmark.candidates.length }], + "The approve press records the person's approval of exactly the charge it shows"); await waitFor(() => benchmarks.size === 1, timeout, 50); assert.deepEqual(await browser.evaluate(`({ parts: ${goParts(modelsSheet)}, providers: [...document.querySelectorAll('${modelsSheet} [data-side="measuring"] .${G}models-pw')].map(el => el.getAttribute('aria-label')).sort(), probing: [...document.querySelectorAll('${modelsSheet} [data-cell][data-key]')].filter(cell => (cell.dataset.probing !== undefined) !== ['anthropic', 'openai'].includes(cell.dataset.family)).map(cell => cell.dataset.cell) })`), { parts: [["measuring models", null], [`0 of ${charge.benchmark.candidates.length}`, "strong"]], providers: [`Claude: 0 of ${requests("anthropic")} measured`, `Codex: 0 of ${requests("openai-codex")} measured`], probing: [], - }, "Verifying says what it has measured, and the rungs of every provider still owed are probing, no other"); + }, "Checking says what it has measured, and the rungs of every provider still owed are probing, no other"); for (const run of benchmarks.values()) run.settled = true; - // Verified: the measured list is put in use, the head says when, and beside it is what the verification left out, each with - // its reason in a word; Spark, in OMP's spark quota class, reads retired. Back to code is the next action, and verify again is - // beside it once the verification's own save has been read, since verifying needs a current read of the workspace. - const settled = `${phaseIs("verified")} && ${goIs(modelsSheet, "back to code")} && ${sideIs("left out")} && JSON.stringify(${goFixes(modelsSheet)}) === '["verify again"]'`; - await until(browser, "the measured list is put in use and verified", settled); - const verified = derivedFrom(firstInventory); + // Checked: the measured list is put in use, the head says when, and beside it is what the check left out, each with its + // reason in a word; Spark, in OMP's spark quota class, reads retired. Back to code is the next action, and check again is + // beside it once the check's own save has been read, since a check needs a current read of the workspace. + const settled = `${phaseIs("verified")} && ${goIs(modelsSheet, "back to code")} && ${sideIs("left out")} && JSON.stringify(${goFixes(modelsSheet)}) === '["check again"]'`; + await until(browser, "the measured list is put in use, checked", settled); + const checked = derivedFrom(firstInventory); const afterFirst = await readConfiguration(server, writer, target); - assert.deepEqual(afterFirst.configuration?.active?.document, verified.document, "The list in use is exactly the derivation of what was measured"); - assert.equal(afterFirst.configuration?.active?.provenance?.benchmarkCompletedAt, verified.benchmark.completedAt, "The verification records when it was measured"); + assert.deepEqual(afterFirst.configuration?.active?.document, checked.document, "The list in use is exactly the derivation of what was measured"); + assert.equal(afterFirst.configuration?.active?.provenance?.benchmarkCompletedAt, checked.benchmark.completedAt, "The check records when it was measured"); assert.equal(afterFirst.configuration?.draft, null); assert.deepEqual(await browser.evaluate(`({ state: ${sheetState(modelsSheet)}, fixes: ${goFixes(modelsSheet)}, list: ${modelList}, unmeasured: [...document.querySelectorAll('${modelsSheet} [data-cell][data-unmeasured]')].length })`), - { state: `${listOf(verified.document).length} models · verified just now`, fixes: ["verify again"], list: listOf(verified.document), unmeasured: 0 }, - "Each rung is its measured model, and the head says it was verified just now"); + { state: `${listOf(checked.document).length} models · checked just now`, fixes: ["check again"], list: listOf(checked.document), unmeasured: 0 }, + "Each rung is its measured model, and the head says it was checked just now"); const outs = await browser.evaluate<{ id: string; why: string; label: string }[]>(`[...document.querySelectorAll('${modelsSheet} [data-side="left out"] li')].map(li => ({ id: li.querySelector('.${G}models-xid').textContent, why: li.querySelector('.${G}models-why').textContent, label: li.querySelector('.${G}models-xid').getAttribute('aria-label') }))`); - assert.deepEqual(outs.map(entry => entry.id), verified.exclusions.map(exclusion => `${exclusion.provider}/${exclusion.id}`), "Left out are exactly the models the derivation left out"); + assert.deepEqual(outs.map(entry => entry.id), checked.exclusions.map(exclusion => `${exclusion.provider}/${exclusion.id}`), "Left out are exactly the models the derivation left out"); assert.deepEqual(outs.find(entry => entry.id === sparkId), { id: sparkId, why: "retired", label: `${sparkId}: Retired: Code no longer routes to Spark` }, "Spark, in OMP's spark quota class, reads retired"); assert.deepEqual(outs.filter(entry => entry.id !== sparkId && entry.why === "retired"), [], "Only Spark's class reads retired"); await pointOf(browser, `[...document.querySelectorAll('${modelsSheet} [data-side="left out"] .${G}models-xid')].find(el => el.textContent === ${JSON.stringify(sparkId)})`); await until(browser, "pointing at Spark says why in the readout", `${sheetSays(modelsSheet)} === ${JSON.stringify(`${spark.id} · Retired: Code no longer routes to Spark`)}`); await pointAway(browser); - await sheetZeroShift(browser, modelsSheet, "Models verified list"); - await sheetGeometry(browser, modelsSheet, "Models verified list"); - await sheetCoarseTargets(browser, modelsSheet, "Models verified list"); + await sheetZeroShift(browser, modelsSheet, "Models checked list"); + await sheetGeometry(browser, modelsSheet, "Models checked list"); + await sheetCoarseTargets(browser, modelsSheet, "Models checked list"); // Back to code goes back to the stage, focus to More, which opened Models. await click(browser, goButton); await until(browser, "back to code returns to the stage and gives focus back to More", `${element(stage)}.hidden === false && document.activeElement === ${more}`); // Import, with i, stages a pasted list beside the one in use: one rung measured faster. Nothing is in use until it is used; - // an import over it says it replaces it, in the dialog and once staged; discard drops it, and the verified list stays in use. + // an import over it says it replaces it, in the dialog and once staged; discard drops it, and the checked list stays in use. await openSheet(browser, "models"); const active = afterFirst.configuration!.active!.document; const faster: CatalogDocument = { ...active, models: active.models.map((model, index) => index === 0 ? { ...model, tokensPerSecond: 101 } : model) }; @@ -2710,46 +2825,56 @@ async function modelsSheetScenario(browser: BrowserInstance, server: TestServer, assert.deepEqual([restaged.configuration?.active, restaged.configuration?.draft?.document], [afterFirst.configuration?.active, fastest], "It replaces the staged list, and nothing is in use"); from = trace.requests.length; await click(browser, goFix(modelsSheet, "discard")); - await until(browser, "discard drops the staged list; the verified one stays in use", `${phaseIs("verified")} && ${goIs(modelsSheet, "back to code")}`); + await until(browser, "discard drops the staged list; the checked one stays in use", `${phaseIs("verified")} && ${goIs(modelsSheet, "back to code")}`); assert.deepEqual(writes(from), [{ name: "atyrode.code.discardCatalog", input: { containerId: target.containerId, expectedRevision: restaged.revision } }], "Discard drops the staged list at the revision shown"); const discarded = await readConfiguration(server, writer, target); assert.deepEqual([discarded.configuration?.active, discarded.configuration?.draft], [afterFirst.configuration?.active, null], "Discarding leaves the list in use as it was"); - // Verify again measures the verified list afresh. Under reduced motion its check and charge animate nothing, and Esc - // stops the run while its charge waits: the sheet stays open on the verified list and nothing is spent or cancelled. + // The approval the first press recorded would let checking again spend at once; the person chooses ask first in Setup, + // so the charge below waits on them. + await closeSheet(browser); + await askFirst(browser, charge.benchmark.candidates.length); + assert.deepEqual(approvals().at(-1), { expectedRevision: 1, requests: 0 }, "Ask first withdraws the approval at the revision it was recorded at"); + await openSheet(browser, "models"); + await until(browser, "Models shows the checked list again, with check again beside back to code", settled); + + // Check again measures the checked list afresh. Under reduced motion its check and charge animate nothing, and Esc + // stops the check while its charge waits: the sheet stays open on the checked list and nothing is spent or cancelled. await browser.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: "reduce" }] }); // A transition already under way when motion is reduced runs out its own duration; the check starts from rest. await still(browser); try { - await click(browser, goFix(modelsSheet, "verify again")); - await until(browser, "verify again checks the accounts' models again", `${phaseIs("inventory")} || ${phaseIs("charge")}`); + await click(browser, goFix(modelsSheet, "check again")); + await until(browser, "check again checks the accounts' models again", `${phaseIs("inventory")} || ${phaseIs("charge")}`); await sheetStill(browser, "the checking hold"); - await until(browser, "its charge waits", `${phaseIs("charge")} && ${goIs(modelsSheet, "confirm charge")}`); + await until(browser, "its charge waits on approve", `${phaseIs("charge")} && ${goIs(modelsSheet, "approve")}`); await sheetStill(browser, "the charge"); - assert.equal(started.length, 2, "Verify again runs one more inventory"); + assert.equal(started.length, 2, "Check again runs one more inventory"); await click(browser, element(`${modelsSheet} [data-cell][data-key]`)); await key(browser, "Escape", 27); - await until(browser, "Esc stops the waiting charge, and the sheet stays open on the verified list", `${phaseIs("verified")} && ${element(stage)}.hidden === true`); - await sheetStill(browser, "a stopped run"); + await until(browser, "Esc stops the waiting charge, and the sheet stays open on the checked list", `${phaseIs("verified")} && ${element(stage)}.hidden === true`); + await sheetStill(browser, "a stopped check"); } finally { await browser.send("Emulation.setEmulatedMedia", { features: [] }); } assert.deepEqual([benchmarks.size, cancels.length], [2, 0], "A stopped charge spends nothing and has no job to cancel"); - await click(browser, goFix(modelsSheet, "verify again")); - await until(browser, "verify again waits on its charge", `${phaseIs("charge")} && ${goIs(modelsSheet, "confirm charge")}`); - // This time the plan serves one GPT model, the one the first verification put on GPT's first rung. - const servedId = verified.document.models.find(model => model.provider === "openai-codex" && model.tier === 1)!.id; + await click(browser, goFix(modelsSheet, "check again")); + await until(browser, "check again waits on approve", `${phaseIs("charge")} && ${goIs(modelsSheet, "approve")}`); + // This time the plan serves one GPT model, the one the first check put on GPT's first rung. + const servedId = checked.document.models.find(model => model.provider === "openai-codex" && model.tier === 1)!.id; unserved = new Set(inventoryDraft(inventories.get("synthetic-inventory-3")!, "any").benchmark.candidates .filter(candidate => candidate.provider === "openai-codex" && candidate.id !== servedId).map(candidate => candidate.id)); assert(unserved.size > 0, "The plan leaves some Codex model unserved"); await click(browser, goButton); - await until(browser, "the list is measured again and verified", settled); + await until(browser, "the list is measured again and checked", settled); + assert.deepEqual(approvals().at(-1), { expectedRevision: 2, requests: inventoryDraft(inventories.get("synthetic-inventory-3")!, "any").benchmark.candidates.length }, + "Approving again records the approval again"); const again = derivedFrom("synthetic-inventory-3"); const afterAgain = await readConfiguration(server, writer, target); - assert.equal(benchmarks.size, 4, "Verify again spends one benchmark per provider"); - assert.deepEqual(afterAgain.configuration?.active?.document, again.document, "Verified again, the list in use is the new derivation"); - assert.equal(afterAgain.configuration?.active?.provenance?.benchmarkCompletedAt, again.benchmark.completedAt, "The new verification is recorded"); - assert(again.benchmark.completedAt > verified.benchmark.completedAt); + assert.equal(benchmarks.size, 4, "Check again spends one benchmark per provider"); + assert.deepEqual(afterAgain.configuration?.active?.document, again.document, "Checked again, the list in use is the new derivation"); + assert.equal(afterAgain.configuration?.active?.provenance?.benchmarkCompletedAt, again.benchmark.completedAt, "The new check is recorded"); + assert(again.benchmark.completedAt > checked.benchmark.completedAt); // GPT is laddered on the one model its plan serves, short rather than refused, and says so beside its name; the models // the plan does not serve are left out as not served. An empty rung GPT offers names the rung it lands on; elite, which @@ -2803,7 +2928,7 @@ async function modelsSheetScenario(browser: BrowserInstance, server: TestServer, "Save writes exactly the edit at the revision shown"); // Use staged list reviews the staged list at the revision shown and promotes exactly that review; the list in use is then - // the staged one with its own default team, unverified, and verifying it is the next action. While those writes are in + // the staged one with its own default team, not checked, and checking it is the next action. While those writes are in // flight the main view takes no edit, so their receipt never lands on one: Esc goes back to Code, a row press there is // refused, and nothing is kept. from = trace.requests.length; @@ -2819,9 +2944,9 @@ async function modelsSheetScenario(browser: BrowserInstance, server: TestServer, "No edit lands, or is kept, while Models' writes are in flight"); await openSheet(browser, "models"); heldReview.release(); - await until(browser, "use staged list puts it in use with its own default team, unverified, and says so", `${phaseIs("unverified")} && ${goIs(modelsSheet, "verify models")} && - ${sheetState(modelsSheet)} === ${JSON.stringify(`${listOf(slower).length} models · unverified`)} && - ${sheetSays(modelsSheet)} === ${JSON.stringify("in use · the staged list replaced the one in use; the profile is the new list's default now; verify it next")}`); + await until(browser, "use staged list puts it in use with its own default team, not checked, and says so", `${phaseIs("unverified")} && ${goIs(modelsSheet, "check models")} && + ${sheetState(modelsSheet)} === ${JSON.stringify(`${listOf(slower).length} models · not checked`)} && + ${sheetSays(modelsSheet)} === ${JSON.stringify("in use · the staged list replaced the one in use; the profile is the new list's default now; check it next")}`); const review = reviews.at(-1); assert(review, "The real server reviewed the staged list"); assert.equal(review.catalogDigest, before.configuration?.draft?.digest, "The review is of exactly the staged list"); @@ -2831,7 +2956,7 @@ async function modelsSheetScenario(browser: BrowserInstance, server: TestServer, ], "Use staged list reviews the staged list at the revision shown, then promotes exactly that review"); const used = await readConfiguration(server, writer, target); assert.deepEqual([used.configuration?.active?.document, used.configuration?.active?.provenance, used.configuration?.draft], [slower, null, null], - "The staged list is in use, unverified, and nothing is staged"); + "The staged list is in use, not checked, and nothing is staged"); assert.deepEqual(used.configuration?.selection, review.review.selection, "The staged list goes in use with the team its review chose, its own default"); assert.notEqual(used.configuration?.selection?.advisor, advisor, "The saved edit does not carry over to the staged list"); await closeSheet(browser); @@ -2845,6 +2970,353 @@ async function modelsSheetScenario(browser: BrowserInstance, server: TestServer, assert.deepEqual(await ownerAction(server, "engine.jobs.listDeployments", { pluginId: "atyrode.omp", limit: 100 }), deployments, "Models never requests native approval"); assert.deepEqual(await ownerAction(server, "core.terminals.listAll", {}), terminals); } + +// ---------------------------------------------------------------- a person's checks across workspaces + +/** + * Model checks belong to the person who ran them (checks.ts): what one workspace checked or approved reaches the others. + * Synthetic OMP observations stand in for the destination, the accounts and the probe jobs, as in Models: discovery, + * benchmark and the launch ready, one fictional Claude account per workspace, inventories of the pinned OMP's bundled + * Claude rows, and benchmarks that answer every candidate reachable. Everything Code decides is the real server's: the + * initialization, the charge, the approval, the derivation, staging, promotion, the kept checks and their adoption. + * Session preparation is refused, so a launch goes as far as the reviewed session. It runs as a person of its own + * (`asNewPerson`), who has approved nothing and keeps nothing at first. + * + * - A first Launch press asks before its check spends; the approve press records the person's approval, and the same + * press goes on to review the saved profile and launch it. + * - In a workspace with another Claude account, a first Launch press checks within that approval and spends without + * asking. The check narrows the profile to what answered, so the press stops on what changed, and Launch goes on with it. + * - A workspace with the first account adopts the kept check without an inventory or a spend: on a first Save & launch + * press, which goes on to the launch, and, once set up, on its own as it opens. + * - Once the accounts change under a list that passed a check, its lead still served, Launch stays ready and launches + * while the check that renews the list runs on its own, within the approval. + * - When they change again and OMP refuses the renewal's benchmark, the stop is said in Models only, Launch stays ready, + * and nothing checks again on its own. Opened again, a renewal spending under the approval is cancelled at its owner by + * Setup's ask first, and writes nothing. + * + * A workspace never set up is never checked on its own, whatever the person keeps or approved (model-verification.ts): + * only its first press initializes it. + */ +async function keptChecksScenario(browser: BrowserInstance, server: TestServer, person: TokenGrant, destination: Target): Promise { + const first = await createContainer(server, "Checked on a first launch", "canvas"); + const within = await createContainer(server, "Checked within the approval", "canvas"); + const pressed = await createContainer(server, "Adopted on a press", "canvas"); + const opened = await createContainer(server, "Set up, adopted as it opens", "canvas"); + const workspaces = new Set([first.id, within.id, pressed.id, opened.id]); + const catalogRead = await callAction(server, person.token, "atyrode.omp.readModelCatalog", { providers: ["anthropic"] }); + assert(catalogRead.ok, "The pinned OMP bundle supplies its passive model metadata"); + const claude = ModelCatalogSnapshotSchema.parse(catalogRead.result).models; + const defaults = await callAction(server, person.token, "atyrode.omp.readDefaults", {}); + assert(defaults.ok); + const defaultsRevision = (defaults.result as OmpResult<"readDefaults">).revision; + // The synthetic installation the probe jobs (`probeJob`) and the launch run from. + const pins = { installationRevision: "synthetic-ui-only", artifactSha256: "c".repeat(64), resourceBindingDigest: "c".repeat(64) }; + /** The accounts the panel observes: one fictional Claude account, or more once they change. */ + let observed = fixtureAccounts([1]); + const inventories = new Map(); + const benchmarks = new Map; startedAt: number; receipt: BenchmarkReceipt | null }>(); + /** Benchmarks the destination refused at their start, so they spent nothing and left no job. */ + const refusedBenchmarks: OmpInput<"startBenchmark">[] = []; + const prepared: string[] = []; + /** The probe jobs a check asked their owner to cancel, by job. */ + const cancels: string[] = []; + // A check held at its inventory, to observe the launch while it runs. + const heldInventory = holdable(); + let holdNextInventory = false; + /** The next benchmark is refused at its start; or it starts and keeps running (`held`) until it is cancelled. */ + let nextBenchmark: "answered" | "refused" | "held" = "answered"; + let heldBenchmark: string | null = null; + const deployments = await ownerAction(server, "engine.jobs.listDeployments", { pluginId: "atyrode.omp", limit: 100 }); + const terminals = await ownerAction(server, "core.terminals.listAll", {}); + await arrangeWorkbench(server, person); + const trace = await watchActions(browser, server); + const fixture = await intercept(browser, server, async (name, input) => { + switch (name) { + case "atyrode.omp.describeDestination": { + if (!workspaces.has(String(input.containerId)) || input.machineId !== destination.machineId) return undefined; + const result: OmpResult<"describeDestination"> = { containerId: String(input.containerId), machineId: destination.machineId, pluginId: "atyrode.omp", + state: "ready", reason: null, deployment: null, services: [], operations: [INVENTORY_OPERATION_ID, BENCHMARK_OPERATION_ID, LAUNCH_OPERATION_ID] + .map(operationId => ({ operationId, pins, nativeReady: true, callerRefusal: null, state: "ready", reason: null })) }; + return { ok: true, result }; + } + case "atyrode.omp.accounts.accounts": return { ok: true, result: observed }; + case "atyrode.omp.accounts.usage": return { ok: true, result: fixtureUsage(observed) }; + case "atyrode.omp.startInventory": { + const request = ompActionSchemas.startInventory.input.parse(input); + assert(workspaces.has(request.containerId) && request.machineId === destination.machineId, "Only this scenario's workspaces are checked"); + assert.deepEqual(Object.keys(request.accountPool), ["anthropic"], "The inventory runs with the Claude pool Code composed from the saved choices"); + const jobId = `kept-inventory-${inventories.size + 1}`; + inventories.set(jobId, { schemaVersion: 1, kind: "inventory", ompVersion: OMP_VERSION, observedAt: Date.now() - 1_000, models: claude }); + if (holdNextInventory) { holdNextInventory = false; await heldInventory.wait(); } + return { ok: true, result: probeJob(destination, jobId, INVENTORY_OPERATION_ID, person.principal.id) }; + } + case "atyrode.omp.readInventory": { + const inventory = inventories.get(String(input.jobId)); + assert(inventory, "Only an inventory this fixture started is read"); + return { ok: true, result: { job: probeJob(destination, String(input.jobId), INVENTORY_OPERATION_ID, person.principal.id), inventory } }; + } + case "atyrode.omp.startBenchmark": { + const request = ompActionSchemas.startBenchmark.input.parse(input); + const inventory = inventories.get(request.inventoryJobId); + assert(inventory, "A benchmark follows an inventory this fixture answered"); + assert.deepEqual(request.candidates, inventoryDraft(inventory, "any").benchmark, "A check spends exactly the charge of its inventory"); + if (nextBenchmark === "refused") { nextBenchmark = "answered"; refusedBenchmarks.push(request); return refused("synthetic_benchmark_refused"); } + const jobId = `kept-benchmark-${benchmarks.size + 1}`; + benchmarks.set(jobId, { request, startedAt: Date.now(), receipt: null }); + if (nextBenchmark === "held") { nextBenchmark = "answered"; heldBenchmark = jobId; } + return { ok: true, result: probeJob(destination, jobId, BENCHMARK_OPERATION_ID, person.principal.id, "started") }; + } + case "engine.jobs.status": { + const { node } = input as { node: { jobId: string; operationId: string } }; + assert(inventories.has(node.jobId) || benchmarks.has(node.jobId), "Only this fixture's probe jobs are followed"); + return { ok: true, result: probeJob(destination, node.jobId, node.operationId, person.principal.id, node.jobId === heldBenchmark ? "started" : "exited") }; + } + case "atyrode.omp.readBenchmark": { + const run = benchmarks.get(String(input.jobId)); + assert(run, "Only a benchmark this fixture started is read"); + run.receipt ??= { schemaVersion: 1, kind: "benchmark", ompVersion: OMP_VERSION, inventoryObservedAt: run.request.candidates.inventoryObservedAt, + startedAt: run.startedAt, completedAt: Date.now(), results: run.request.candidates.candidates.map(candidate => { + const weight = [...candidate.id].reduce((sum, char) => sum + char.charCodeAt(0), 0); + return { ...candidate, status: "reachable" as const, tokensPerSecond: 25 + weight % 90, timeToFirstTokenMs: 400 + weight % 1_400 }; + }) }; + return { ok: true, result: { job: probeJob(destination, String(input.jobId), BENCHMARK_OPERATION_ID, person.principal.id), benchmark: run.receipt } }; + } + // Only the held benchmark is running, so only it can be cancelled; its owner accepts, and the job ends. + case "engine.jobs.cancel": { + const { node } = input as { node: { jobId: string } }; + cancels.push(node.jobId); + if (node.jobId !== heldBenchmark) return refused("synthetic_cancel_unexpected"); + heldBenchmark = null; + return { ok: true, result: { accepted: true } }; + } + case "atyrode.omp.readSkillCatalog": return { ok: true, result: { revision: 0, skills: [], sets: [], updatedAt: null, updatedBy: null } }; + case "atyrode.omp.reviewSession": + return { ok: true, result: { destination: { containerId: input.containerId, machineId: destination.machineId }, operationId: LAUNCH_OPERATION_ID, pins, + reviewDigest: "d".repeat(64), defaultsRevision, effectiveOverlay: input.overlay, accountPool: input.accountPool, automation: input.automation ?? { mode: "ordinary" }, + skills: { mode: "preserve", catalogRevision: null, selected: [] } } }; + case "atyrode.omp.prepareSession": prepared.push(String(input.containerId)); return refused("omp_review_changed"); + case "atyrode.omp.listSessions": return { ok: true, result: [] }; + default: return undefined; + } + }); + /** The doors that say what a press or the panel did about the models, and about the launch after. */ + const doors = ["atyrode.code.initializeConfiguration", "atyrode.omp.startInventory", "atyrode.code.approveChecks", "atyrode.omp.startBenchmark", + "atyrode.code.adoptCheck", "atyrode.code.promoteCatalog", "atyrode.omp.reviewSession", "atyrode.omp.prepareSession"]; + /** + * Those doors requested since `from`, in order, through the first session preparation: once a launch is refused the panel + * may review it again on its own, which is no part of the press. + */ + const requested = (from: number) => { + const names = trace.requests.slice(from).map(request => request.name).filter(name => doors.includes(name)); + const launched = names.indexOf("atyrode.omp.prepareSession"); + return launched === -1 ? names : names.slice(0, launched + 1); + }; + /** The rows lead Claude only, which a Claude-only list hosts as it is: an edit of the bundled starter, which leads Mixed. */ + const leadClaudeOnly = async () => { + await choose(browser, "lane", "anthropic"); + if (await browser.evaluate(`${onlyBox}.getAttribute('aria-checked') !== 'true'`)) await click(browser, onlyBox); + await until(browser, "the rows lead Claude only", `${rowValue("lane")} === 'Claude only'`); + }; + /** + * Opens a workspace never set up and edits its rows with `edit`. Before the edit nothing initializes or checks it on its + * own, whatever the person keeps or approved; after it, Save & launch is ready, checking the models first. + */ + const openEdited = async (containerId: string, edit: () => Promise) => { + const from = trace.requests.length, probes = inventories.size; + await openGenerator(browser, server, containerId); + await chooseMachine(browser, destination.machineId, machineName); + await until(browser, "a workspace never set up offers a first launch, checking the models first", launchIs("launch", "ready")); + await Bun.sleep(500); + assert.deepEqual([requested(from), inventories.size], [[], probes], "A workspace never set up is neither initialized nor checked on its own"); + await edit(); + await until(browser, "the edited rows save and launch, checking the models first", launchIs("save & launch", "ready")); + }; + /** Models' head says the list in use is the checked one: its models, checked as of the check it came from. */ + const headSaysChecked = async (document: CatalogDocument, description: string) => { + await openSheet(browser, "models"); + await until(browser, description, `/^${listOf(document).length} models · checked (just now|\\d+m ago)$/.test(${sheetState(modelsSheet)})`); + await closeSheet(browser); + }; + try { + // A first Launch press asks before its check spends. The rows lead Claude only, which the check keeps as it is, so the + // press saves them and goes on: the approve press records the person's approval, then the press reviews and launches. + await openEdited(first.id, leadClaudeOnly); + let from = trace.requests.length; + await click(browser, launchButton); + await until(browser, "the press checks the models", launchIs("checking models…", "busy")); + await until(browser, "the charge waits on approve beside the launch", `${launchIs("save & launch", "waiting")} && ${approveButton}?.textContent === 'approve'`); + const charge = inventoryDraft(inventories.get("kept-inventory-1")!, "any").benchmark.candidates.length; + await click(browser, approveButton); + await waitFor(() => prepared.length === 1, timeout, 50); + await until(browser, "the same press goes on to the reviewed session, whose refusal is said beside the launch", failureLine); + assert.deepEqual(requested(from), ["atyrode.code.initializeConfiguration", "atyrode.omp.startInventory", "atyrode.code.approveChecks", + "atyrode.omp.startBenchmark", "atyrode.code.promoteCatalog", "atyrode.omp.reviewSession", "atyrode.omp.prepareSession"], + "One press checks the models, asking once, then reviews the saved profile and launches it"); + assert.deepEqual(trace.requests.slice(from).find(request => request.name === "atyrode.code.approveChecks")?.input, { expectedRevision: 0, requests: charge }, + "The first approve press records the person's approval of exactly its charge"); + const checked = (await readConfiguration(server, person, { containerId: first.id })).configuration?.active; + assert(checked?.provenance, "The check put a checked list in use"); + const checkedPool = checked.provenance.poolIdentityDigest; + const kept = await readChecks(server, person); + assert.equal(kept.approvedRequests, charge, "The approval is the person's, kept for their later checks"); + assert.deepEqual(kept.checks.map(check => [check.poolIdentityDigest, check.digest]), [[checkedPool, checked.digest]], + "The checked list is kept as the person's check of its account pool"); + + // In a workspace with another Claude account, a first press checks within the approval and spends without asking. The + // rows lead GPT, which the starter offers and a Claude-only list cannot host: the check narrows them, so the press stops + // on what it changed after the review, and Launch goes on with it. + observed = fixtureAccounts([2]); + await openEdited(within.id, () => choose(browser, "lane", "openai")); + // Whatever happens until the press ends, the approve control never shows and the launch never waits on it. + await browser.evaluate(`(globalThis.__codeAsked = false, new MutationObserver(() => { + if (${approveButton} !== null || ${launchButton}?.dataset.state === 'waiting') globalThis.__codeAsked = true; + }).observe(${element(generator)}, { subtree: true, childList: true, attributes: true }), true)`); + from = trace.requests.length; + await click(browser, launchButton); + await until(browser, "the check within the approval narrows the profile, and the press stops on what it changed", + `${element(`${generator} .${G}launch-part[data-tone="warn"]`)}?.textContent === 'The model check changed the profile'`); + const narrowed = await browser.evaluate<[string, string | null][]>(launchParts); + assert(narrowed[0]?.[1] === "warn" && narrowed.length > 1 && narrowed.slice(1).every(([text]) => text.includes(" → ")) && narrowed.some(([text]) => text.startsWith("lane ")), + `The stop names each setting the check moved, the lane among them: ${JSON.stringify(narrowed)}`); + assert.equal(await browser.evaluate("globalThis.__codeAsked"), false, "Within the approval the check never shows approve and the launch never waits on it"); + assert.deepEqual(requested(from), ["atyrode.code.initializeConfiguration", "atyrode.omp.startInventory", "atyrode.omp.startBenchmark", + "atyrode.code.promoteCatalog", "atyrode.omp.reviewSession"], + "The check within the approval spends without asking, and the press reviews the narrowed profile and stops before launching"); + await pointOf(browser, launchButton); + await until(browser, "pointed, the launch says it goes on with the narrowed profile", `${readout}.textContent.includes('launch to go on with it, or change a setting first')`); + await pointAway(browser); + await until(browser, "Launch is ready to go on with the narrowed profile", launchIs("launch", "ready")); + from = trace.requests.length; + await click(browser, launchButton); + await waitFor(() => prepared.length === 2, timeout, 50); + await until(browser, "the launch's refusal is said beside it", failureLine); + assert.deepEqual(requested(from), ["atyrode.omp.prepareSession"], "Launch goes on with the profile as the check left it"); + + // A workspace with the first account adopts the person's kept check of it on a first Save & launch press: no inventory + // and no charge, the kept list in use with the rows' edit, and the press goes on to the launch. + observed = fixtureAccounts([1]); + await openEdited(pressed.id, leadClaudeOnly); + let probes = [inventories.size, benchmarks.size]; + from = trace.requests.length; + await click(browser, launchButton); + await waitFor(() => prepared.length === 3, timeout, 50); + await until(browser, "the same press goes on to the reviewed session, whose refusal is said beside the launch", failureLine); + assert.deepEqual([inventories.size, benchmarks.size], probes, "Adopting a kept check reaches no inventory or benchmark"); + assert.deepEqual(requested(from), ["atyrode.code.initializeConfiguration", "atyrode.code.adoptCheck", "atyrode.omp.reviewSession", "atyrode.omp.prepareSession"], + "One press adopts the kept check, asking and spending nothing, then reviews and launches"); + const adopted = (await readConfiguration(server, person, { containerId: pressed.id })).configuration?.active; + assert.deepEqual([adopted?.digest, adopted?.provenance], [checked.digest, checked.provenance], "The kept list is in use as it was checked"); + await headSaysChecked(checked.document, "Models says the list adopted on a press is checked, as of the check it adopted"); + + // The accounts change under the adopted list: a second Claude account joins the first, so the pool is another, and + // Claude, the lead, is still served. A list that passed a check still serves a launch while the check renewing it runs on + // its own within the approval, held here at its inventory: Launch stays a launch, reviewed and ready, says the check runs + // in the background when pointed, and launches. Opening the workspace again observes the new pool. + observed = fixtureAccounts([1, 4]); + holdNextInventory = true; + probes = [inventories.size, benchmarks.size]; + from = trace.requests.length; + await openGenerator(browser, server, pressed.id); + await waitFor(() => heldInventory.held, timeout, 50); + await until(browser, "with only the accounts changed the launch stays a launch, reviewed and ready", launchIs("launch", "ready")); + await pointOf(browser, launchButton); + await until(browser, "pointed, the launch says a check runs in the background", `${readout}.textContent.includes('checking models in the background')`); + await pointAway(browser); + await click(browser, launchButton); + await waitFor(() => prepared.length === 4, timeout, 50); + await until(browser, "the launch's refusal is said beside it", failureLine); + heldInventory.release(); + await waitFor(async () => (await readConfiguration(server, person, { containerId: pressed.id })).configuration?.active?.provenance?.poolIdentityDigest + !== checkedPool, timeout, 100); + assert.deepEqual([inventories.size, benchmarks.size], [probes[0]! + 1, probes[1]! + 1], "The renewal runs one inventory and spends one benchmark"); + assert.deepEqual(trace.requests.slice(from).filter(request => request.name === "atyrode.code.approveChecks" || request.name === "atyrode.code.adoptCheck"), [], + "The renewal within the approval asks nothing and adopts nothing"); + const renewed = (await readConfiguration(server, person, { containerId: pressed.id })).configuration?.active; + assert(renewed?.provenance && renewed.provenance.benchmarkCompletedAt > checked.provenance.benchmarkCompletedAt, "The renewal put a list checked with the new pool in use"); + await headSaysChecked(renewed.document, "Models says the renewed list is checked"); + + // A workspace set up with the first account, its starter list not checked, adopts the person's kept check of it on its + // own as it opens: adopting asks and spends nothing, no inventory reaches OMP, and Models says the list is checked. The + // panel lands on the machine where OMP answers by itself, and the adoption holds the machine's choice while it runs. + observed = fixtureAccounts([1]); + const setUp = await callAction(server, person.token, "atyrode.code.initializeConfiguration", { containerId: opened.id, expectedRevision: 0 }); + assert(setUp.ok, "The person sets up the workspace's Code choices"); + probes = [inventories.size, benchmarks.size]; + from = trace.requests.length; + await openGenerator(browser, server, opened.id); + await until(browser, "the launch names the machine the panel judged, where OMP answers", `${machinePicker}?.textContent === ${JSON.stringify(machineName)}`); + await waitFor(() => trace.requests.slice(from).some(request => request.name === "atyrode.omp.reviewSession"), timeout, 50); + await until(browser, "adopted on its own, the launch is reviewed and ready", launchIs("launch", "ready")); + assert.deepEqual([inventories.size, benchmarks.size], probes, "Adopting a kept check on its own reaches no inventory or benchmark"); + assert.deepEqual(requested(from), ["atyrode.code.adoptCheck", "atyrode.omp.reviewSession"], + "Opening the set-up workspace adopts the kept check, asking and spending nothing, and reviews the launch"); + const own = (await readConfiguration(server, person, { containerId: opened.id })).configuration?.active; + assert.deepEqual([own?.digest, own?.provenance], [checked.digest, checked.provenance], "The kept list is in use as it was checked"); + await headSaysChecked(checked.document, "Models says the list adopted on its own is checked, as of the check it adopted"); + + // The accounts change again under the list renewed in the workspace adopted on a press: a third Claude account joins. + // That list passed a check and the approval covers the charge, so a renewal starts on its own, and OMP refuses its + // benchmark. A renewal that stopped is said in Models only, beside a launch the list in use still serves, and nothing + // checks again on its own, which would spend again each time. + observed = fixtureAccounts([1, 4, 5]); + nextBenchmark = "refused"; + probes = [inventories.size, benchmarks.size]; + from = trace.requests.length; + await openGenerator(browser, server, pressed.id); + await waitFor(() => refusedBenchmarks.length === 1, timeout, 50); + await until(browser, "beside the stopped renewal the launch stays a launch, reviewed and ready, and says nothing of the stop", + `${launchIs("launch", "ready")} && !${failureLine} && !${lineText}.includes('Model check')`); + await Bun.sleep(2_000); + assert.deepEqual([inventories.size, benchmarks.size, refusedBenchmarks.length], [probes[0]! + 1, probes[1]!, 1], + "The stopped renewal ran one inventory and one refused benchmark, and nothing checks again on its own"); + assert.equal(await browser.evaluate(`${launchIs("launch", "ready")} && !${failureLine}`), true, "The launch still says nothing of the stop"); + await openSheet(browser, "models"); + await until(browser, "Models says the renewal stopped, beside the accounts changed since the check", + `${goParts(modelsSheet)}.some(([text, tone]) => text === 'check stopped' && tone === 'attention') && ${sheetState(modelsSheet)}.endsWith(' · accounts changed since checked')`); + await closeSheet(browser); + assert.deepEqual([inventories.size, refusedBenchmarks.length], [probes[0]! + 1, 1], "Opening and closing Models starts no check"); + assert.deepEqual(trace.requests.slice(from).filter(request => /^atyrode\.code\.(approveChecks|stageCatalog|promoteCatalog|adoptCheck|select)$/.test(request.name)), [], + "The stopped renewal asks, stages, promotes, adopts and saves nothing"); + assert.deepEqual((await readConfiguration(server, person, { containerId: pressed.id })).configuration?.active, renewed, "The stopped renewal leaves the list in use as it was"); + + // Ask first while a renewal spends under the approval: opened again, the workspace renews the list on its own, and its + // benchmark keeps running until Setup's ask first withdraws the approval, which cancels it at its owner. Nothing is + // staged, promoted or adopted, and nothing checks again on its own. + nextBenchmark = "held"; + probes = [inventories.size, benchmarks.size]; + from = trace.requests.length; + await openGenerator(browser, server, pressed.id); + await waitFor(() => heldBenchmark !== null, timeout, 50); + const spending = heldBenchmark; + await until(browser, "while the renewal spends, the launch stays a launch, reviewed and ready", launchIs("launch", "ready")); + await askFirst(browser, charge); + await waitFor(() => heldBenchmark === null, timeout, 50); + assert.deepEqual(cancels, [spending], "Ask first cancels the benchmark the renewal was spending, at its owner"); + assert.deepEqual(trace.requests.slice(from).filter(request => request.name === "atyrode.code.approveChecks").map(request => request.input), + [{ expectedRevision: 1, requests: 0 }], "Ask first records that every later check asks before it spends"); + await Bun.sleep(2_000); + assert.deepEqual([inventories.size, benchmarks.size], [probes[0]! + 1, probes[1]! + 1], + "The cancelled renewal ran one inventory and one benchmark, and nothing checks again on its own"); + assert.deepEqual(trace.requests.slice(from).filter(request => /^atyrode\.code\.(stageCatalog|promoteCatalog|adoptCheck|select)$/.test(request.name)), [], + "The cancelled renewal stages, promotes, adopts and saves nothing"); + assert.deepEqual((await readConfiguration(server, person, { containerId: pressed.id })).configuration?.active, renewed, "The list in use stays as it was"); + await until(browser, "after ask first the launch still serves", launchIs("launch", "ready")); + + const after = await readChecks(server, person); + assert.deepEqual(trace.requests.filter(request => request.name === "atyrode.code.approveChecks").map(request => request.input), + [{ expectedRevision: 0, requests: charge }, { expectedRevision: 1, requests: 0 }], "Only the first approve press recorded an approval, and ask first withdrew it"); + assert.equal(after.approvedRequests, 0, "Withdrawn, the approval lets no check spend without asking"); + assert.equal(after.checks.length, 3, "Each pool checked is kept: the first, the other account's and the changed pool's; a stopped or cancelled renewal keeps none"); + fixture.check(); + } finally { + heldInventory.release(); + await fixture.stop(); + trace.stop(); + } + assert.deepEqual(await ownerAction(server, "engine.jobs.listDeployments", { pluginId: "atyrode.omp", limit: 100 }), deployments, + "A person's checks never request native approval"); + assert.deepEqual(await ownerAction(server, "core.terminals.listAll", {}), terminals); +} // ---------------------------------------------------------------- tiered usage windows /** @@ -3056,7 +3528,7 @@ async function sharedWorkbenchScenario(browser: BrowserInstance, viewerBrowser: const staged = await callAction(server, writer.token, "atyrode.code.stageCatalog", { containerId: first.containerId, expectedRevision: excludedRevision, document, verification: { ompVersion: OMP_VERSION, inventoryArtifactSha256: pinnedOmpArtifact(), catalogRevision: metadata.revision, inventoryObservedAt: Date.now() - 3_000, benchmarkCompletedAt: Date.now() - 2_000, - accounts: observation, poolIdentityDigest: (probe.result as ActionResult<"composeProbe">).poolIdentityDigest } }); + accounts: observation, poolIdentityDigest: (probe.result as ActionResult<"composeProbe">).poolIdentityDigest, budget: "any" } }); assert(staged.ok, "Writer stages a real shared catalog with its recorded verification, without native installation"); const reviewInput = { containerId: first.containerId, expectedRevision: (staged.result as Configuration).revision, source: "draft" }; const reviewed = await callAction(server, writer.token, "atyrode.code.reviewCatalog", reviewInput); @@ -3110,8 +3582,8 @@ async function sharedWorkbenchScenario(browser: BrowserInstance, viewerBrowser: await browser.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: "reduce" }] }); await openGenerator(browser, server, first.containerId); await chooseMachine(browser, first.machineId, machineName); - // The verification holds; the next step is the launch, which this machine has no permission for. - await until(browser, "the verified profile's next step is a refused launch", launchIs("launch", "refused")); + // The check holds; the next step is the launch, which this machine has no permission for. + await until(browser, "the checked profile's next step is a refused launch", launchIs("launch", "refused")); await assertRoutes(browser, initial.selection?.advisor !== "off"); assert((await routedRoles(browser)).every(entry => document.models.some(model => model.key === entry.alias)), "Configured routes use the saved catalog, never bundled starter metadata"); @@ -3152,7 +3624,7 @@ async function sharedWorkbenchScenario(browser: BrowserInstance, viewerBrowser: await openOptions(browser); await turn(browser, autoPlans, true); await closeOptions(browser); - await until(browser, "an edited verified profile saves before its review, and offers the saved one back", `${launchIs("save & review", "ready")} && !!${revertButton}`); + await until(browser, "an edited checked profile saves before its review, and offers the saved one back", `${launchIs("save & review", "ready")} && !!${revertButton}`); // The launch says what its save changes while it is pointed: exactly the three edited settings. await pointOf(browser, launchButton); await until(browser, "the launch's readout counts the edited settings", `/^save & review · 3 changes to the profile: /.test(${readout}.textContent)`); @@ -3175,7 +3647,7 @@ async function sharedWorkbenchScenario(browser: BrowserInstance, viewerBrowser: `${launchIs("save & review", "ready")} && !!${revertButton} && !${fix("conflict")} && ${profileValues} === ${JSON.stringify(edits)}`); // A lead no included account serves refuses the save, as the session door would refuse its launch. The panel reads the - // Claude account as disabled (a GPT one stays) while the verification it stands on is not observed again: the save + // Claude account as disabled (a GPT one stays) while the check it stands on is not observed again: the save // is refused with the family named, and neither a press nor Mod+↵ writes. const gpt = { reference: { kind: "credential" as const, scope: fixtureScope, provider: "openai-codex", credentialId: 3 }, credentialId: 3, type: "api_key" as const, identityKey: null, email: null, disabled: false, blocks: [] }; @@ -3194,10 +3666,10 @@ async function sharedWorkbenchScenario(browser: BrowserInstance, viewerBrowser: await key(browser, "r", 82); await until(browser, "with the Claude account back the save is ready again", `${launchIs("save & review", "ready")} && ${profileValues} === ${JSON.stringify(edits)}`); - // Models shows the workspace's verified list rung by rung; the accounts' management keeps an unsaved draft in its view under the accounts. + // Models shows the workspace's checked list rung by rung; the accounts' management keeps an unsaved draft in its view under the accounts. await openSheet(browser, "models"); - await until(browser, "Models shows the saved list, verified", `${element(modelsSheet)}?.dataset.phase === 'verified' && ${goIs(modelsSheet, "back to code")}`); - assert.deepEqual(await browser.evaluate(modelList), listOf(document), "Models shows the saved verified list, each rung on its model"); + await until(browser, "Models shows the saved list, checked", `${element(modelsSheet)}?.dataset.phase === 'verified' && ${goIs(modelsSheet, "back to code")}`); + assert.deepEqual(await browser.evaluate(modelList), listOf(document), "Models shows the saved checked list, each rung on its model"); await closeSheet(browser); const manage = `${generator} [data-pane="manage"]`; await showView(browser, "accounts"); @@ -3252,7 +3724,7 @@ async function sharedWorkbenchScenario(browser: BrowserInstance, viewerBrowser: assert.equal(saved.configuration?.selection?.capability, 4, "The fourth capability saves unchanged from the second destination"); assert.equal(saved.configuration?.selection?.planYolo, true); assert.deepEqual(saved.configuration?.accounts, initial.accounts, "Saving the profile preserves shared account pools and exclusions"); - assert.deepEqual(saved.configuration?.active, initial.active, "Saving the profile leaves the verified catalog as it is"); + assert.deepEqual(saved.configuration?.active, initial.active, "Saving the profile leaves the checked list as it is"); await chooseMachine(browser, first.machineId, machineName); assert.equal(await browser.evaluate(chosenKey("thinking")), localThinking, "Saved choices remain identical when returning to the first destination"); assert(reads.length > 0 && reads.every(input => input.containerId === first.containerId && Object.keys(input).length === 1), @@ -3464,7 +3936,7 @@ async function stagedCatalogScenario(browser: BrowserInstance, server: TestServe * CDP's text insertion into the field). What is not a list is said so and cannot be staged. An edit of the bundled preview, * which the import would replace, holds it until the edit is discarded in the dialog. A list is staged by the one * explicit press, which initializes the absent workspace at the revision the panel read and stages once; nothing is - * verified or put in use until use staged list, which reviews the staged list at its revision and promotes exactly that + * checked or put in use until use staged list, which reviews the staged list at its revision and promotes exactly that * review. A competing first save made meanwhile is never overwritten: the panel's configuration reads are held so it * stages against the absent workspace it last read, and the real server refuses that initialization. An import whose own * initialization succeeds and whose stage is then refused leaves the bundled preview on that initialization, never a @@ -3563,15 +4035,15 @@ async function importedCatalogScenario(browser: BrowserInstance, server: TestSer ], "The import initializes the absent workspace once, then stages once from that initialization"); const after = await readConfiguration(server, writer, target); assert.deepEqual([after.revision, after.configuration?.draft], [1, null], "The initialization stands and nothing is staged"); - // Once the panel has read its own initialization, the preview rests on it: verifying is refused for what the fixture - // lacks, never as a change made elsewhere with theirs to use. + // Once the panel has read its own initialization, the preview rests on it: checking models is refused for what the + // fixture lacks, never as a change made elsewhere with theirs to use. await waitFor(() => readsAnswered!.includes(1), timeout, 50); await Bun.sleep(300); readsAnswered = null; - const verifyLine = await browser.evaluate<{ refused: boolean; parts: [string, string | null][]; fixes: string[] }>( - `({ refused: ${goIs(modelsSheet, "verify models", "refused")}, parts: ${goParts(modelsSheet)}, fixes: ${goFixes(modelsSheet)} })`); - assert(verifyLine.refused && !verifyLine.parts.some(([text]) => text === "The workspace profile changed elsewhere") && !verifyLine.fixes.includes("use theirs"), - `The panel's own initialization is no conflict: ${JSON.stringify(verifyLine)}`); + const checkLine = await browser.evaluate<{ refused: boolean; parts: [string, string | null][]; fixes: string[] }>( + `({ refused: ${goIs(modelsSheet, "check models", "refused")}, parts: ${goParts(modelsSheet)}, fixes: ${goFixes(modelsSheet)} })`); + assert(checkLine.refused && !checkLine.parts.some(([text]) => text === "The workspace profile changed elsewhere") && !checkLine.fixes.includes("use theirs"), + `The panel's own initialization is no conflict: ${JSON.stringify(checkLine)}`); await key(browser, "Escape", 27); await until(browser, "Esc closes the import, and the sheet stays open", `!${dialog}.open && ${element(stage)}.hidden === true`); await closeSheet(browser); @@ -3607,13 +4079,13 @@ async function importedCatalogScenario(browser: BrowserInstance, server: TestSer assert.equal(staged.revision, 2); assert.equal(staged.configuration?.active, null, "Staging puts nothing in use"); assert.deepEqual(staged.configuration?.draft?.document, document); - assert.equal(staged.configuration?.draft?.provenance, null, "An imported list is not verified"); + assert.equal(staged.configuration?.draft?.provenance, null, "An imported list is not checked"); assert.deepEqual(await browser.evaluate(modelList), listOf(document), "Models shows the staged list, each rung on its model"); const using = trace.requests.length; await click(browser, element(`${modelsSheet} [data-go]`)); - await until(browser, "use staged list puts it in use, unverified, and says so", - `${element(modelsSheet)}.dataset.phase !== 'staged' && ${sheetState(modelsSheet)} === '4 models · unverified' && - ${sheetSays(modelsSheet)} === 'in use · the staged list replaced the one in use; verify it next'`); + await until(browser, "use staged list puts it in use, not checked, and says so", + `${element(modelsSheet)}.dataset.phase !== 'staged' && ${sheetState(modelsSheet)} === '4 models · not checked' && + ${sheetSays(modelsSheet)} === 'in use · the staged list replaced the one in use; check it next'`); const review = reviews.at(-1); assert(review, "The real server reviewed the staged list"); assert.equal(review.catalogDigest, staged.configuration?.draft?.digest, "The review is of exactly the staged list"); @@ -3624,13 +4096,13 @@ async function importedCatalogScenario(browser: BrowserInstance, server: TestSer const promoted = await readConfiguration(server, writer, target); assert.equal(promoted.revision, 3); assert.deepEqual(promoted.configuration?.active?.document, document, "Promotion puts exactly the reviewed list in use"); - assert.equal(promoted.configuration?.active?.provenance, null, "A list put in use without a verification is unverified"); + assert.equal(promoted.configuration?.active?.provenance, null, "A list put in use without a check is not checked"); assert.equal(promoted.configuration?.draft, null); await closeSheet(browser); await until(browser, "the list in use routes the profile in the main view", `[...document.querySelectorAll(${JSON.stringify(`${routeRows} .${G}tok-alias`)})].some(el => el.textContent.startsWith('entry-model-tier-'))`); - // An imported list is unverified; verifying it needs native discovery the fixture does not have. - await until(browser, "missing native runtime still prevents launch after importing a list", launchIs("verify models", "refused")); + // An imported list is not checked, so the launch checks it first, which needs native discovery the fixture does not have. + await until(browser, "missing native runtime still prevents launch after importing a list", launchIs("launch", "refused")); } fixture.check(); } finally { @@ -3941,11 +4413,15 @@ async function setupSheetScenario(browser: BrowserInstance, server: TestServer, rowsAre({ machine: ["ok", "online"], omp: ["ok", "ready"], connection: ["todo", "review needed"], discovery: ["todo", "off"], sessions: ["todo", "off"], folders: ["todo", "not prepared"] }, "connection")); assert.deepEqual(await browser.evaluate(`({ state: ${sheetState(setupSheet)}, parts: ${goParts(setupSheet)} })`), { state: `${machineName} · 4 to fix`, parts: [[`on ${machineName}`, null]] }); - // The profile's facts beside the checklist: the revision read, the list in use (a way to Models), its source and the workspace. + // The profile's facts beside the checklist: the revision read, the list in use (a way to Models), its source, how far the + // person's model checks may spend on their own (the writer approved none, so every check asks) and the workspace. The + // checks fact shows once the person's approval has been read. + await until(browser, "Setup states how far the person's checks may spend on their own", `${setupFact("checks")} !== undefined`); const facts = await browser.evaluate>(`Object.fromEntries([...document.querySelectorAll('${setupSheet} .${G}setup-facts dt')].map(dt => [dt.textContent, dt.nextElementSibling.textContent]))`); assert.deepEqual({ ...facts, models: facts.models?.split(" · ")[0] }, { - revision: String(saved.revision), models: `${listOf(saved.configuration.active.document).length} in use`, source: `stored list · OMP ${bundledVersion} bundled list`, workspace: target.containerId, - }, "The facts state the revision read, the list in use, its source and the workspace"); + revision: String(saved.revision), models: `${listOf(saved.configuration.active.document).length} in use`, source: `stored list · OMP ${bundledVersion} bundled list`, + checks: "ask before spending", workspace: target.containerId, + }, "The facts state the revision read, the list in use, its source, that every check asks before it spends, and the workspace"); // The classifier is the instance owner's: for a writer it says so, its reason in the readout. await tabTo(browser, "the classifier's state", element(`${setupSheet} .${G}setup-classifier .${G}setup-st[tabindex]`)); await until(browser, "the classifier says why a writer cannot configure it", `${sheetSays(setupSheet)} === 'classifier · Only the instance owner configures the classifier.' && @@ -3961,7 +4437,7 @@ async function setupSheetScenario(browser: BrowserInstance, server: TestServer, document.activeElement.tabIndex === 0 && document.querySelectorAll('${setupSheet} [data-roving][tabindex="0"]').length === 1`); for (const [name, code, row, says] of [ ["ArrowDown", 40, "connection", "connection · the model gateway connects OMP to your account broker; it moves no credentials and starts no request"], - ["ArrowDown", 40, "discovery", "discovery · lets Code read the models your accounts reach and measure them; verifying models needs it"], + ["ArrowDown", 40, "discovery", "discovery · lets Code read the models your accounts reach and measure them; checking models needs it"], ["ArrowUp", 38, "connection", null], ["ArrowUp", 38, "omp", `omp · OMP answers on ${machineName}`], ["ArrowUp", 38, "machine", null], ] as const) { await key(browser, name, code); @@ -4609,7 +5085,7 @@ async function agentLaunchScenario(browser: BrowserInstance, server: TestServer, const target: Target = { containerId: workspace.id, machineId: destination.machineId }; const sponsor = await mintToken(server, { principal: { kind: "human", name: "Code sponsor", color: "#339966" }, caps: ["containers:read", "containers:write", "machines:read", "machines:run", "jobs:read", "services:read", "agents:delegate"] }); - // The sponsor's profile: four published anthropic models without minimal thinking, verified as the workflow records it. + // The sponsor's profile: four published anthropic models without minimal thinking, checked as the workflow records a check. const created = await callAction(server, sponsor.token, "atyrode.code.initializeConfiguration", { containerId: workspace.id, expectedRevision: 0 }); assert(created.ok, "A sponsor initializes its workspace's Code profile"); const bundled = await callAction(server, sponsor.token, "atyrode.omp.readModelCatalog", { providers: starterProviders }); @@ -4631,13 +5107,13 @@ async function agentLaunchScenario(browser: BrowserInstance, server: TestServer, const staged = await callAction(server, sponsor.token, "atyrode.code.stageCatalog", { containerId: workspace.id, expectedRevision: initialRevision, document, verification: { ompVersion: OMP_VERSION, inventoryArtifactSha256: pinnedOmpArtifact(), catalogRevision: metadata.revision, inventoryObservedAt: Date.now() - 3_000, benchmarkCompletedAt: Date.now() - 2_000, - accounts: observation, poolIdentityDigest: (probe.result as ActionResult<"composeProbe">).poolIdentityDigest } }); - assert(staged.ok, "The sponsor stages a verified list"); + accounts: observation, poolIdentityDigest: (probe.result as ActionResult<"composeProbe">).poolIdentityDigest, budget: "any" } }); + assert(staged.ok, "The sponsor stages a checked list"); const reviewInput = { containerId: workspace.id, expectedRevision: (staged.result as Configuration).revision, source: "draft" }; const reviewedList = await callAction(server, sponsor.token, "atyrode.code.reviewCatalog", reviewInput); assert(reviewedList.ok); const promoted = await callAction(server, sponsor.token, "atyrode.code.promoteCatalog", { ...reviewInput, reviewDigest: (reviewedList.result as ActionResult<"reviewCatalog">).reviewDigest }); - assert(promoted.ok, "The verified list is in use"); + assert(promoted.ok, "The checked list is in use"); const saved = promoted.result as Configuration; assert.equal(saved.selection?.planYolo, false, "A new profile approves no plan automatically, so an agent run can carry it"); const compiled = compileCatalog(document as CatalogDocument); @@ -5257,8 +5733,7 @@ async function run(): Promise { const container = await createContainer(server, "Code browser regression", "canvas"); const target = { containerId: container.id, machineId: agent.machineId }; const secondTarget = { containerId: container.id, machineId: secondAgent.machineId }; - const writer = await mintToken(server, { principal: { kind: "human", name: "Code writer", color: "#336699" }, - caps: ["containers:read", "containers:write", "machines:read", "machines:run", "jobs:read", "services:read"] }); + const writer = await mintToken(server, { principal: { kind: "human", name: "Code writer", color: "#336699" }, caps: WRITER_CAPS }); const viewer = await mintToken(server, { principal: { kind: "human", name: "Code viewer", color: "#996633" }, caps: ["containers:read", "machines:read", "machines:run", "jobs:read", "services:read"] }); assert.notEqual(writer.principal.id, viewer.principal.id, "Writer and viewer must be different native identities"); @@ -5448,12 +5923,14 @@ async function run(): Promise { await starterWorkbenchScenario(writerBrowser, server, writer, target); phase = "starter observation failures are not absence"; await starterObservationScenario(writerBrowser, server, writer); - phase = "verification charge gate"; - await verificationChargeScenario(writerBrowser, server, writer, target); - phase = "a verified catalog outlives an OMP upgrade alone and asks for another once a routed model is dropped"; + phase = "a first launch's model check: its charge, the approve press that records the approval, ask first and a refused spend"; + await asNewPerson(writerBrowser, server, writer, "Code charge checker", person => checkChargeScenario(writerBrowser, server, person, target)); + phase = "a checked list outlives an OMP upgrade alone, and Launch checks the models again once a routed model is dropped"; await ompUpgradeScenario(writerBrowser, server, writer, target); - phase = "Models: verify, the charge's hold and deliberate confirm, measuring, left out, staged, imported, discarded and verified again"; - await modelsSheetScenario(writerBrowser, server, writer, target); + phase = "Models: check models, the charge's hold and deliberate approve, measuring, left out, staged, imported, discarded, ask first and check again"; + await asNewPerson(writerBrowser, server, writer, "Code models checker", person => modelsSheetScenario(writerBrowser, server, person, target)); + phase = "a person's checks across workspaces: the approval their first approve press records, a check within it that asks nothing, a kept check adopted on a press and on its own, Launch kept ready while changed accounts are checked again, a stopped renewal never retried and ask first cancelling a renewal that spends"; + await asNewPerson(writerBrowser, server, writer, "Code check keeper", person => keptChecksScenario(writerBrowser, server, person, target)); phase = "tiered usage windows are their own rows and judge no pool"; await tieredUsageScenario(writerBrowser, server, writer); phase = "struck leads and the only box say why"; @@ -5484,7 +5961,7 @@ async function run(): Promise { const cleanupFailures = await stopEverything(); if (cleanupFailures.length) throw new Error(`Cleanup failed: ${cleanupFailures.join(", ")}${failure ? `; proof failed during ${phase}` : ""}`); if (failure) throw failure; - console.log(`PASS (${Math.round((Date.now() - started) / 1000)}s): packed Code in two real browsers and two permitted destinations; shared choices converge with viewer authority intact, and a viewer's main view and accounts refuse every write. Genuine pinned OMP metadata yields an editable render-only starter whose routing and Models list are exactly the policy derivation; a conflicted local profile stays on show without revision-zero rebasing; configuration/metadata failures are not absence, and a failed model list is named with its retry and Models whatever the launch says. The verification charge is never confirmed by a double-click, a second click, key repeat or ↵ at the panel root, waits as the next Tab stop after the launch with every row and the machine locked, and a refused spend saves nothing; an account switch saves through the real changeAccounts CAS and keeps focus. A verified catalog recorded against the pinned OMP's artifact and the real catalog revision is current, and an OMP upgrade alone, with the same accounts and Code, makes Verify the step again. The panel reads its inputs again on its own when shown again, never while an edit is unsaved (a changed row, a named pool, until saved or discarded), a step runs or a sheet is open, and once that ends. Tiered usage windows are rows of their own that leave Claude neither tight nor maxed, and no reset is clipped where the usage grid turns to two columns. The lead row's only box toggles by a press and Space with focus kept on it, survives a lead change, is disabled under Mixed and moves no word of the row; a struck lead or box says why, and a lead whose lane on the box's side cannot run lands on its other. Models, from More with focus on its way back, shows the list as ladders whose rungs say their ids and prices only in the readout, walks them as one roving Tab stop, and offers one next action that follows the step: verify, the checking hold a double-click cannot pass, a charge confirmed only by one deliberate press, measuring per provider, verified with what it left out (Spark, by OMP's spark quota class, retired), back to code and verify again, which Esc stops while its charge waits; a pasted list is refused until it is one, staged by one press (initializing an absent workspace once, never over a competing first save), discarded at its revision, or used by promoting exactly its review. Setup is one checklist whose rows say what they need, pointing at the first unready one: another machine where OMP is absent, then each native review (Esc back onto the next action, nothing approved; sessions request both OMP's launch and its harness), OMP's folder job by either route, refused, and the owner's classifier reviewed by the real server before its configuration, refused. More and its Models item mark a staged list exactly when it waits beside the active one, and a staged-only workspace's launch opens Models on it. Profile and account drafts survive destinations, sheets, the accounts' management and a reload; own account edits never conflict with an unsaved edit while a foreign profile write does; a save is refused for a lead no account serves; a writer without a canvas saves and is told why launching waits, never read-only. From 170 to 1440px no view or sheet overflows, cuts or collides text, no route runs past the routing pane and More's menu stays inside the panel; pointing and focus shift nothing in a view or a sheet; More is a menu button by pointer and keyboard; keys stay panel-local and never act from a sheet or an open machine list, More menu or session drum; arrival keys reach the rows or take the launch's step; a refused keyboard step says why; the wheel steps only focused or rested rows; coarse targets are 44px in views and sheets, which hide their key line; reduced motion animates nothing and leaves no sheet duration. Auto-review, skills, automation, refused launch, resume and session correlations (one row per folder with its drum, re-read on refresh) stay gated at their native owners, native permission and folder readiness stay review-only, and no native approval, terminal or provider request is made. A sponsor's one press registers Code's Agent and creates its Run at the real doors; the machine's refused launchRun cancels the Run and the same press goes on to the reviewed session, as every later press there does, its readout saying the machine cannot launch agent runs (or, before it, that the harness is not enabled). Fixture Runs say their activity, a lease to 24 of 24, renewal due and detached, and one square on the Sessions tab; their dials go pending, queued as next, confirmed, clamped, not served, unanswered then sent again, and forbidden, and a starting or detached Run's dials send nothing; ←→ settle before one send and a press within that settle sends only itself, ↵ and Space send at once, ↑↓ cross Runs; send again keeps the said line whole on a coarse pointer; a pointed word, lease or activity moves nothing, reduced motion stills every mark, and from 170 to 1440px nothing overflows.`); + console.log(`PASS (${Math.round((Date.now() - started) / 1000)}s): packed Code in two real browsers and two permitted destinations; shared choices converge with viewer authority intact, and a viewer's main view and accounts refuse every write. Genuine pinned OMP metadata yields an editable render-only starter whose routing and Models list are exactly the policy derivation; a conflicted local profile stays on show without revision-zero rebasing; configuration/metadata failures are not absence, and a failed model list is named with its retry and Models whatever the launch says. A first Launch checks the models itself: its charge waits on approve, the next Tab stop after the launch, with every row and the machine locked, and a double-click, a second click, key repeat or ↵ at the panel root never approves it; the waiting line paints whole from 170 to 1440px, shifts nothing under pointer or focus, has 44px targets on a coarse pointer and animates nothing under reduced motion; one approve press records the person's approval and spends once, and its refused spend saves nothing, stays said beside the launch and is never retried on its own; Setup says how far the approval reaches, and ask first withdraws it so the next charge asks again; an account switch saves through the real changeAccounts CAS and keeps focus. A checked list recorded against the pinned OMP's artifact and the real catalog revision holds; an OMP upgrade alone, with the same accounts and Code, keeps the launch the step while Models says OMP was updated since, and a republished list that drops a routed model makes Launch check the models first. The panel reads its inputs again on its own when shown again, never while an edit is unsaved (a changed row, a named pool, until saved or discarded), a step runs or a sheet is open, and once that ends. Tiered usage windows are rows of their own that leave Claude neither tight nor maxed, and no reset is clipped where the usage grid turns to two columns. The lead row's only box toggles by a press and Space with focus kept on it, survives a lead change, is disabled under Mixed and moves no word of the row; a struck lead or box says why, and a lead whose lane on the box's side cannot run lands on its other. Models, from More with focus on its way back, shows the list as ladders whose rungs say their ids and prices only in the readout, walks them as one roving Tab stop, and offers one next action that follows the step: check models, the checking hold a double-click cannot pass, a charge approved only by one deliberate press, which records the approval, measuring per provider, checked with what it left out (Spark, by OMP's spark quota class, retired), back to code and, after ask first, check again, which Esc stops while its charge waits; a pasted list is refused until it is one, staged by one press (initializing an absent workspace once, never over a competing first save), discarded at its revision, or used by promoting exactly its review. A person's checks reach every workspace they open: after their first approve press records the approval and the press goes on to review and launch, a first check elsewhere spends within it without asking, and one that narrows the profile stops the press on what it changed before Launch goes on with it; a workspace with the same account pool adopts their kept check with no inventory or spend, on a first Save & launch press that goes on to the launch and, once set up, on its own, Models saying it is checked; a workspace never set up is never checked on its own; once the accounts change under a list that passed a check, Launch stays ready and launches while the check renewing it runs in the background within the approval; a renewal whose benchmark is refused is said in Models only, beside a launch still ready, and is never retried on its own; and Setup's ask first cancels a renewal spending under the approval at its owner, which writes nothing and starts nothing again. Setup is one checklist whose rows say what they need, pointing at the first unready one: another machine where OMP is absent, then each native review (Esc back onto the next action, nothing approved; sessions request both OMP's launch and its harness), OMP's folder job by either route, refused, and the owner's classifier reviewed by the real server before its configuration, refused; its facts say every check asks before it spends while nothing is approved. More and its Models item mark a staged list exactly when it waits beside the active one, and a staged-only workspace's launch opens Models on it. Profile and account drafts survive destinations, sheets, the accounts' management and a reload; own account edits never conflict with an unsaved edit while a foreign profile write does; a save is refused for a lead no account serves; a writer without a canvas saves and is told why launching waits, never read-only. From 170 to 1440px no view or sheet overflows, cuts or collides text, no route runs past the routing pane and More's menu stays inside the panel; pointing and focus shift nothing in a view or a sheet; More is a menu button by pointer and keyboard; keys stay panel-local and never act from a sheet or an open machine list, More menu or session drum; arrival keys reach the rows or take the launch's step; a refused keyboard step says why; the wheel steps only focused or rested rows; coarse targets are 44px in views and sheets, which hide their key line; reduced motion animates nothing and leaves no sheet duration. Auto-review, skills, automation, refused launch, resume and session correlations (one row per folder with its drum, re-read on refresh) stay gated at their native owners, native permission and folder readiness stay review-only, and no native approval, terminal or provider request is made. A sponsor's one press registers Code's Agent and creates its Run at the real doors; the machine's refused launchRun cancels the Run and the same press goes on to the reviewed session, as every later press there does, its readout saying the machine cannot launch agent runs (or, before it, that the harness is not enabled). Fixture Runs say their activity, a lease to 24 of 24, renewal due and detached, and one square on the Sessions tab; their dials go pending, queued as next, confirmed, clamped, not served, unanswered then sent again, and forbidden, and a starting or detached Run's dials send nothing; ←→ settle before one send and a press within that settle sends only itself, ↵ and Space send at once, ↑↓ cross Runs; send again keeps the said line whole on a coarse pointer; a pointed word, lease or activity moves nothing, reduced motion stills every mark, and from 170 to 1440px nothing overflows.`); } await run(); diff --git a/plugins/test/action-gate.test.ts b/plugins/test/action-gate.test.ts index 97d818a..ee8b482 100644 --- a/plugins/test/action-gate.test.ts +++ b/plugins/test/action-gate.test.ts @@ -3,11 +3,15 @@ import { actionGate, resumeTeam, type GateFacts, type GateVerdict, type Workbenc import type { Configuration } from "../code/contract.ts"; import type { VerificationStatus } from "../code/generator/verification.ts"; +/** A check's facts for a list whose standing is `status`: one that passed a check once unless it was never checked, with nothing in flight. */ +function checked(status: VerificationStatus, ready = true): GateFacts["verification"] { + return { status, held: status === "verifying" ? "current" : status, passed: status !== "unverified", mode: null, ready }; +} /** A verified, saved, launchable workbench with a saved session chosen and nothing in flight; each test changes only what it is about. */ function facts(changes: Partial = {}, status: VerificationStatus = "current"): GateFacts { return { configurationCurrent: true, unsaved: false, stale: false, writable: true, placeable: true, available: true, accounts: "usable", launchReady: true, previewCurrent: false, profile: { source: "active" }, localDraft: null, record: { active: {} }, localReview: {}, skillProblems: [], - queries: { setup: { error: null } }, verification: { status, ready: true }, + queries: { setup: { error: null } }, verification: checked(status), running: false, charge: null, unservedLead: null, savedSessionId: "session-1", planYolo: false, ...changes }; } const every: readonly WorkbenchIntent[] = ["verify", "confirm", "save", "review", "launch", "resume", "resume-with-team", "open", @@ -20,7 +24,7 @@ describe("one gate for every action", () => { for (const intent of every) expect(code(actionGate(facts({ running: true, previewCurrent: true }), intent))).toBe("running"); }); - test("while a charge waits only its Confirm proceeds; edits of the team, session options, machine and accounts wait with it", () => { + test("while a check's charge waits only its Approve proceeds; edits of the team, session options, machine and accounts wait with it", () => { const waiting = facts({ charge: { requests: 19 } }, "unverified"); for (const intent of every) expect(code(actionGate(waiting, intent))).toBe(intent === "confirm" ? "open" : "charge"); expect(code(actionGate(facts({ charge: { requests: 0 } }, "unverified"), "confirm"))).toBe("no-charge"); @@ -52,14 +56,16 @@ describe("one gate for every action", () => { expect(code(actionGate(facts({ writable: false }, "unverified"), "verify"))).toBe("read-only"); expect(code(actionGate(facts({ configurationCurrent: false }, "unverified"), "verify"))).toBe("configuration"); expect(code(actionGate(facts({ unsaved: true, localDraft: { source: "active" } }), "save"))).toBe("open"); - // The bundled preview is saved only by the verification that replaces it, and nothing saves unverified. + // The bundled preview is saved only by the check that replaces it, and nothing saves on a list the door would refuse. expect(code(actionGate(facts({ unsaved: true, profile: { source: "starter" }, localDraft: { source: "starter" } }), "save"))).toBe("unsaved"); expect(code(actionGate(facts({ unsaved: true, localDraft: { source: "active" } }, "unpublished"), "save"))).toBe("not-next"); + // A list that passed a check still serves while only the accounts changed, so an edit of its team saves. + expect(code(actionGate(facts({ unsaved: true, localDraft: { source: "active" } }, "accounts-changed"), "save"))).toBe("open"); expect(code(actionGate(facts({ unsaved: true, localDraft: { source: "active" }, writable: false }), "save"))).toBe("read-only"); }); - test("a current verification can still be renewed on purpose, through the same account, destination, readiness and authority checks", () => { - // What OMP or the accounts reach can change in ways no observation proves, so Verify stays reachable; its charge still waits on Confirm. + test("a list in use that holds can still be checked again on purpose, through the same account, destination, readiness and authority checks", () => { + // What OMP or the accounts reach can change in ways no observation proves, so a check stays reachable; its charge asks unless approved. expect(code(actionGate(facts(), "verify"))).toBe("open"); expect(code(actionGate(facts({ writable: false }), "verify"))).toBe("read-only"); expect(code(actionGate(facts({ stale: true }), "verify"))).toBe("conflict"); @@ -67,7 +73,7 @@ describe("one gate for every action", () => { expect(code(actionGate(facts({ accounts: "unreadable" }), "verify"))).toBe("accounts"); expect(code(actionGate(facts({ accounts: "none" }), "verify"))).toBe("no-accounts"); expect(code(actionGate(facts({ queries: { setup: { error: "unreadable" } } }), "verify"))).toBe("verify-status"); - expect(code(actionGate(facts({ verification: { status: "current", ready: false } }), "verify"))).toBe("verify-permissions"); + expect(code(actionGate(facts({ verification: checked("current", false) }), "verify"))).toBe("verify-permissions"); expect(code(actionGate(facts({ charge: { requests: 3 } }), "verify"))).toBe("charge"); expect(code(actionGate(facts({ charge: { requests: 3 } }), "confirm"))).toBe("open"); }); diff --git a/plugins/test/operations.test.ts b/plugins/test/operations.test.ts index 60457a8..84e0d92 100644 --- a/plugins/test/operations.test.ts +++ b/plugins/test/operations.test.ts @@ -83,8 +83,10 @@ function fixture(isRoot = false): Fixture { await hook?.(); return value; }, - set: unavailable, delete: unavailable, keys: unavailable, + set: unavailable, delete: async key => { store.delete(key); }, keys: async prefix => [...store.keys()].filter(key => key.startsWith(prefix ?? "")).sort(), compareAndSet: async (key, expected, value) => { + // Plugin storage refuses a key outside this shape (plugin-kit `STORAGE_KEY_PATTERN`), so a write here does too. + if (!/^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/.test(key)) throw new Error(`invalid storage key: ${key}`); if ((store.get(key) ?? null) !== expected) return false; store.set(key, value); return true; @@ -468,7 +470,7 @@ describe("verified catalogs and launch-time honesty", () => { async function verification(f: Fixture, revision: number, observation = accounts()) { const probe = await accepted(f, "composeProbe", { ...workspace, expectedRevision: revision, accounts: observation }); return { ompVersion: "18.4.12", inventoryArtifactSha256: "c".repeat(64), catalogRevision: "a".repeat(64), inventoryObservedAt: now - 2000, - benchmarkCompletedAt: now - 1000, accounts: observation, poolIdentityDigest: probe.poolIdentityDigest }; + benchmarkCompletedAt: now - 1000, accounts: observation, poolIdentityDigest: probe.poolIdentityDigest, budget: "any" as const }; } test("a verification records the pool Code reads itself, and review and promotion carry exactly that provenance", async () => { diff --git a/plugins/test/statement-model.test.ts b/plugins/test/statement-model.test.ts index 0d51f7e..9dcbbfb 100644 --- a/plugins/test/statement-model.test.ts +++ b/plugins/test/statement-model.test.ts @@ -142,7 +142,7 @@ describe("the verb names the model's step", () => { const open = { open: true } as const; const facts = (step: LaunchStep["step"], changes: Partial = {}): VerbFacts => ({ step: step === "blocked" ? { step, reason: { code: "read-only", text: "Edit access needed." } } : { step, reason: null }, - verdict: open, busy: false, inFlight: null, chaining: false, unsaved: false, draft: false, phase: null, verification: "current", + verdict: open, busy: false, inFlight: null, chaining: false, unsaved: false, draft: false, edited: false, phase: null, mode: null, serves: true, stranded: false, grounded: true, placeable: true, launching: false, ...changes, }); @@ -170,8 +170,25 @@ describe("the verb names the model's step", () => { expect(verbView(facts("blocked", { step: staged, verdict: { open: false, refusal: staged.reason } }))).toMatchObject({ state: "ready", opens: "models" }); }); - test("while a charge waits the verb waits too, and while a step runs it names the step", () => { - expect(verbView(facts("verify", { phase: "charge" }))).toMatchObject({ label: "Verify models", state: "waiting" }); + test("Launch is the verb when the models must be checked first, and saves an edit on the way", () => { + expect(verbView(facts("verify", { serves: false }))).toMatchObject({ label: "Launch", state: "ready", checks: true, saves: false }); + expect(verbView(facts("verify", { serves: false, unsaved: true, draft: true, edited: true }))).toMatchObject({ label: "Save & launch", checks: true }); + // Refused, it keeps the label of what it would do: a read-only viewer is never shown a check to run. + const refusal: GateRefusal = { code: "read-only", text: "Edit access needed." }; + expect(verbView(facts("blocked", { serves: false, verdict: { open: false, refusal } }))).toMatchObject({ label: "Launch", state: "refused", refusal }); + }); + + test("while the press's check runs or its charge waits the verb waits too; the panel's own check never takes the verb", () => { + expect(verbView(facts("verify", { serves: false, phase: "inventory", mode: "launch" }))).toMatchObject({ label: "Checking models…", state: "busy" }); + expect(verbView(facts("verify", { serves: false, phase: "charge", mode: "launch" }))).toMatchObject({ label: "Launch", state: "waiting" }); + expect(verbView(facts("launch", { phase: "benchmark", mode: "background" }))).toMatchObject({ label: "Launch", state: "ready" }); + // A check the launch waits on, because the list in use cannot serve it, is named while it runs. + const checking = { step: "blocked", reason: { code: "verifying", text: "Checking models with your accounts…" } } as const; + expect(verbView(facts("blocked", { step: checking, serves: false, phase: "benchmark", mode: "background", verdict: { open: false, refusal: checking.reason } }))) + .toMatchObject({ label: "Checking models…", state: "busy" }); + }); + + test("while a step runs the verb names the step", () => { expect(verbView(facts("launch", { busy: true, inFlight: "launch" }))).toMatchObject({ label: "Launching…", state: "busy" }); expect(verbView(facts("save", { chaining: true }))).toMatchObject({ label: "Reviewing…", state: "busy" }); }); @@ -223,11 +240,11 @@ describe("the fix for roles with no route", () => { describe("the launch line, in precedence", () => { const charge = { requests: 19, providers: [{ provider: "openai-codex", requests: 12 }, { provider: "anthropic", requests: 7 }] }; const ready = verbView({ step: { step: "launch", reason: null }, verdict: { open: true }, busy: false, inFlight: null, chaining: false, unsaved: false, - draft: false, phase: null, verification: "current", stranded: false, grounded: true, placeable: true, launching: false }); + draft: false, edited: false, phase: null, mode: null, serves: true, stranded: false, grounded: true, placeable: true, launching: false }); const facts = (changes: Partial = {}): StatusFacts => ({ - verb: ready, phase: null, progress: null, charge: null, inFlight: null, busy: false, chaining: false, launching: false, + verb: ready, phase: null, mode: null, progress: null, charge: null, keepsChecks: true, inFlight: null, busy: false, chaining: false, launching: false, machine: { name: "Studio", online: true, revoked: false }, machineChosen: true, rosterUnread: false, otherMachine: null, message: null, outcome: null, - stop: null, fix: null, differs: null, laneFix: null, nobodyServes: false, listFailure: "none", ompMissing: false, accountsProblem: null, + stop: null, fix: null, differs: null, narrowed: null, laneFix: null, nobodyServes: false, listFailure: "none", ompMissing: false, accountsProblem: null, configurationFailed: false, ...changes, }); const said = (changes: Partial = {}): StatusLine => launchLine(facts(changes), vocab)!; @@ -237,15 +254,25 @@ describe("the launch line, in precedence", () => { test("at rest it says nothing: the label, the routing and the usage say the rest", () => { expect(launchLine(facts(), vocab)).toBeNull(); const verify = verbView({ step: { step: "verify", reason: null }, verdict: { open: true }, busy: false, inFlight: null, chaining: false, unsaved: false, - draft: false, phase: null, verification: "unverified", stranded: false, grounded: true, placeable: true, launching: false }); + draft: false, edited: false, phase: null, mode: null, serves: false, stranded: false, grounded: true, placeable: true, launching: false }); expect(launchLine(facts({ verb: verify }), vocab)).toBeNull(); + // A check the panel runs beside a launch the list in use serves is said only while the launch is pointed. + const beside = launchLine(facts({ phase: "benchmark", mode: "background", progress: { done: 3, total: 19 } }), vocab); + expect(beside?.parts.every(part => part.tone === "meta")).toBe(true); }); - test("a waiting charge outranks the last outcome, and offers Confirm only for a charge of at least one request", () => { - const line = said({ phase: "charge", charge, outcome: { kind: "launched", machine: "Studio", dials: "run" } }); + test("a waiting charge outranks the last outcome, and offers Approve only for a charge of at least one request", () => { + const line = said({ phase: "charge", mode: "launch", charge, outcome: { kind: "launched", machine: "Studio", dials: "run" } }); expect(line.parts.map(part => part.text).join(" ")).toContain("19"); - expect(line.actions).toContainEqual({ kind: "confirm", requests: 19 }); - expect(fixes(said({ phase: "charge", charge: { requests: 0, providers: [] } }))).toEqual(["cancel"]); + expect(line.actions).toContainEqual({ kind: "confirm", requests: 19, standing: true }); + expect(fixes(said({ phase: "charge", mode: "launch", charge: { requests: 0, providers: [] } }))).toEqual(["cancel"]); + }); + + test("approving promises later checks nothing to a sign-in that keeps no checks", () => { + const keeps = said({ phase: "charge", mode: "launch", charge }), keepsNone = said({ phase: "charge", mode: "launch", charge, keepsChecks: false }); + expect(keeps.parts.map(part => part.text)).toContain("asked once: later checks run on their own"); + expect(keepsNone.parts.map(part => part.text).join(" ")).not.toContain("later checks"); + expect(keepsNone.actions).toContainEqual({ kind: "confirm", requests: 19, standing: false }); }); test("a refusal outranks the outcome of the last step, and read-only is said in neutral grey", () => { @@ -303,7 +330,7 @@ describe("the launch line, in precedence", () => { const missing = said({ verb: status, ompMissing: true, machine: { name: "Code isolated destination", online: true, revoked: false }, otherMachine: { id: "dev-01", name: "dev-01" } }); expect(missing.parts[0]!.text).toBe("OMP isn't on Code isolated destination"); expect(missing.actions).toEqual([{ kind: "fix", key: "machine", label: "Use dev-01", fix: { kind: "machine", machineId: "dev-01" } }]); - expect(said({ verb: status }).parts[0]!.text).toMatch(/^Verification readiness unknown on Studio/); + expect(said({ verb: status }).parts[0]!.text).toMatch(/^Model checks unknown on Studio/); const sessions = { ...ready, state: "refused" as const, refusal: { code: "sessions" as const, text: "" } }; expect(said({ verb: sessions, ompMissing: true }).parts[0]!.text).toBe("OMP isn't on Studio"); }); @@ -350,7 +377,7 @@ describe("the launch line, in precedence", () => { describe("what the launch says while it is pointed", () => { const verb = (changes: Partial = {}) => verbView({ step: { step: "save", reason: null }, verdict: { open: true }, busy: false, inFlight: null, chaining: false, - unsaved: true, draft: true, phase: null, verification: "current", stranded: false, grounded: true, placeable: true, launching: false, ...changes }); + unsaved: true, draft: true, edited: true, phase: null, mode: null, serves: true, stranded: false, grounded: true, placeable: true, launching: false, ...changes }); test("a press that saves says what it changes on the workspace profile; a refused one says only its reason", () => { const edits = [{ word: "thinking" as const, from: "medium", to: "high" }]; @@ -363,7 +390,7 @@ describe("what the launch says while it is pointed", () => { test("a reviewed launch names the pool it reviewed; an unreviewed one on readings not current says the review shows it", () => { const launch = verbView({ step: { step: "launch", reason: null }, verdict: { open: true }, busy: false, inFlight: null, chaining: false, unsaved: false, - draft: false, phase: null, verification: "current", stranded: false, grounded: true, placeable: true, launching: false }); + draft: false, edited: false, phase: null, mode: null, serves: true, stranded: false, grounded: true, placeable: true, launching: false }); expect(launchReadout({ verb: launch, edits: [], reviewed: { machine: "Studio", pool: [{ family: "openai", count: 2 }] }, dials: null, grounded: true, launchStatus: "Ready." }, vocab).text) .toBe("reviewed on Studio: GPT 2"); // Pointed, the launch says whether it runs with live dials, or why not. @@ -372,7 +399,7 @@ describe("what the launch says while it is pointed", () => { expect(launchReadout({ verb: launch, edits: [], reviewed: { machine: "Studio", pool: [] }, dials: "options", grounded: true, launchStatus: "Ready." }, vocab).text) .toBe("reviewed on Studio · no live dials: session options are set"); const review = verbView({ step: { step: "review", reason: null }, verdict: { open: true }, busy: false, inFlight: null, chaining: false, unsaved: false, - draft: false, phase: null, verification: "current", stranded: false, grounded: false, placeable: true, launching: false }); + draft: false, edited: false, phase: null, mode: null, serves: true, stranded: false, grounded: false, placeable: true, launching: false }); expect(launchReadout({ verb: review, edits: [], reviewed: null, dials: null, grounded: false, launchStatus: "Ready." }, vocab).text).toBe("the review shows the pool before anything runs"); }); }); diff --git a/plugins/test/verification.test.ts b/plugins/test/verification.test.ts index 85125ee..3e093fd 100644 --- a/plugins/test/verification.test.ts +++ b/plugins/test/verification.test.ts @@ -7,7 +7,8 @@ import { editedDraft } from "../code/generator/draft-store.ts"; import { autoReviewDue, draftStale, editHoldsCatalogWrite, followCatalogWrite, followInitialization, followRecord, launchStatusText, nextLaunchStep, type CatalogWrite, type DraftBase, type GateFacts, type LaunchFacts, type SharedBase } from "../code/generator/launch-step.ts"; import type { ProfileDraft } from "../code/generator/workbench-model.ts"; -import { CHECKING_HOLD_MS, confirmsCharge, ownInitialization, verificationState, type VerificationStatus } from "../code/generator/verification.ts"; +import { adoptsOnly, backgroundCheckDue, CHECKING_HOLD_MS, confirmsCharge, ownInitialization, spendsWithoutAsking, verificationState, + type BackgroundFacts, type VerificationStatus } from "../code/generator/verification.ts"; const runtime = "c".repeat(64), catalog = "e".repeat(64); const provenance: VerificationProvenance = { ompVersion: OMP_VERSION, inventoryArtifactSha256: runtime, catalogRevision: catalog, @@ -36,11 +37,11 @@ describe("whether a recorded verification still holds", () => { expect(republished).toMatchObject({ status: "current", changes: { runtimeChanged: false, catalogChanged: true } }); }); - test("a moved catalog that dropped a routed model asks to verify again, and the recorded one never reads as dropping any", () => { + test("a moved catalog that dropped a routed model asks for a check before the launch, and the recorded one never reads as dropping any", () => { const dropped = verificationState(provenance, { ...present, catalogRevision: moved, unpublished: ["openai-codex/terra"], pool: same }, false); expect(dropped).toMatchObject({ status: "unpublished", changes: { catalogChanged: true, unpublished: ["openai-codex/terra"] } }); expect(nextLaunchStep(facts({}, dropped.status)).step).toBe("verify"); - expect(launchStatusText(facts({}, dropped.status))).toBe("OMP no longer lists a model this profile uses. Verify again."); + expect(launchStatusText(facts({}, dropped.status))).toBe("OMP no longer lists a model this profile uses; Launch checks the models again first."); // The revision the verification recorded publishes every model it laddered, so a list read against it claims nothing. expect(verificationState(provenance, { ...present, unpublished: ["openai-codex/terra"], pool: same }, false).status).toBe("current"); }); @@ -70,29 +71,48 @@ describe("whether a recorded verification still holds", () => { }); }); -/** A verified, saved, launchable workbench; each test changes only what it is about. */ +/** + * A verified, saved, launchable workbench; each test changes only what it is about. `status` is the list's standing: one that + * passed a check unless it was never checked; `verifying` is a check of a list that cannot serve, still running. + */ function facts(changes: Partial = {}, status: VerificationStatus = "current", ready = true): LaunchFacts { + const held = status === "verifying" ? "unverified" : status; return { configurationCurrent: true, unsaved: false, stale: false, writable: true, placeable: true, available: true, accounts: "usable", launchReady: true, previewCurrent: false, profile: { source: "active" }, localDraft: null, record: { active: {} }, localReview: {}, skillProblems: [], - queries: { setup: { error: null } }, verification: { status, ready }, ...changes }; + queries: { setup: { error: null } }, verification: { status, held, passed: held !== "unverified", mode: null, ready }, unservedLead: null, ...changes }; } +const claude = { provider: "anthropic", family: "anthropic", roles: ["reviewer"] }; -describe("the next launch step with verification", () => { - test("verify precedes save and review whenever the verification is not current", () => { - for (const status of ["unverified", "accounts-changed", "unpublished"] as const) { +describe("the next launch step with the model check", () => { + test("a check precedes save and review whenever the list in use cannot serve a launch, and the Launch press runs it", () => { + for (const status of ["unverified", "unpublished"] as const) { expect(nextLaunchStep(facts({}, status)).step).toBe("verify"); - // The bundled preview and an unsaved edit both verify before anything saves. + // The bundled preview and an unsaved edit both check before anything saves. expect(nextLaunchStep(facts({ unsaved: true, profile: { source: "starter" }, localDraft: { source: "starter" }, record: null }, status)).step).toBe("verify"); expect(nextLaunchStep(facts({ unsaved: true, localDraft: { source: "active" } }, status)).step).toBe("verify"); } + expect(launchStatusText(facts({}, "unverified"))).toBe("Launch first checks which models your accounts can use."); expect(nextLaunchStep(facts()).step).toBe("review"); expect(nextLaunchStep(facts({ previewCurrent: true })).step).toBe("launch"); expect(nextLaunchStep(facts({ unsaved: true, localDraft: { source: "active" } })).step).toBe("save"); - // A bundled preview is never saved directly, even when a verification is somehow current. + // A bundled preview is never saved directly, even when a check is somehow current. expect(nextLaunchStep(facts({ unsaved: true, profile: { source: "starter" }, localDraft: { source: "starter" } }))).toMatchObject({ step: "blocked", reason: { code: "unsaved" } }); }); - test("configuration and a staged catalog still come first, and verification has its own preconditions in order", () => { + test("a list that passed a check keeps serving while only the accounts changed, unless the door would refuse a lead", () => { + expect(nextLaunchStep(facts({}, "accounts-changed")).step).toBe("review"); + expect(nextLaunchStep(facts({ previewCurrent: true }, "accounts-changed")).step).toBe("launch"); + expect(nextLaunchStep(facts({ unsaved: true, localDraft: { source: "active" } }, "accounts-changed")).step).toBe("save"); + // A lead the changed accounts no longer serve is refused by the session door, so the press checks first. + expect(nextLaunchStep(facts({ unservedLead: claude }, "accounts-changed")).step).toBe("verify"); + expect(launchStatusText(facts({ unservedLead: claude }, "accounts-changed"))).toBe("Accounts changed since the models were checked; Launch checks them again first."); + // A list that never passed one (imported) has nothing to keep serving with. + expect(nextLaunchStep(facts({ verification: { status: "accounts-changed", held: "accounts-changed", passed: false, mode: null, ready: true } })).step).toBe("verify"); + // A check running beside it, the panel's own, holds nothing up. + expect(nextLaunchStep(facts({ previewCurrent: true, verification: { status: "verifying", held: "accounts-changed", passed: true, mode: "background", ready: true } })).step).toBe("launch"); + }); + + test("configuration and a staged catalog still come first, and the check has its own preconditions in order", () => { expect(nextLaunchStep(facts({ configurationCurrent: false }, "unverified")).reason?.code).toBe("configuration"); expect(nextLaunchStep(facts({ profile: { source: "draft" }, record: { draft: {} } }, "unverified")).reason?.code).toBe("staged"); expect(nextLaunchStep(facts({}, "verifying")).reason?.code).toBe("verifying"); @@ -101,7 +121,7 @@ describe("the next launch step with verification", () => { expect(nextLaunchStep(facts({ available: false }, "unverified")).reason?.code).toBe("unavailable"); expect(nextLaunchStep(facts({ queries: { setup: { error: "unreadable" } } }, "unverified"))).toMatchObject({ reason: { code: "verify-status", action: "refresh" } }); expect(nextLaunchStep(facts({}, "unverified", false))).toMatchObject({ reason: { code: "verify-permissions", action: "permissions" } }); - // Launch-only preconditions never block a verification: it needs no launch permission, skills or local review. + // Launch-only preconditions never block a check: it needs no launch permission, skills or local review. expect(nextLaunchStep(facts({ launchReady: false, skillProblems: ["x"], localReview: null }, "unpublished")).step).toBe("verify"); // Once current, the launch order is unchanged. expect(nextLaunchStep(facts({ launchReady: false })).reason?.code).toBe("permissions"); @@ -125,7 +145,7 @@ describe("a first verification that stops before it saves", () => { const step = (draft: DraftBase | null, shared: SharedBase) => nextLaunchStep(facts({ unsaved: true, profile: { source: "starter" }, localDraft: { source: "starter" }, record: null, stale: draft !== null && draftStale(draft, shared) }, "unverified")); - test("its own initialization offers Verify models again, whether it stopped before the charge or after", () => { + test("its own initialization offers the check again, whether it stopped before the charge or after", () => { // Stopped at the inventory: the stop's evidence names the initialization. expect(step(followInitialization(starter, 0, ownInitialization(true, null, 1)!), initialized).step).toBe("verify"); // Cancelled at the charge: the charge was made at the initialized revision. @@ -256,17 +276,17 @@ describe("this panel's own writes of the model list in Models", () => { }); }); -describe("confirming the verification charge", () => { +describe("approving a check's charge", () => { const charge = { requests: 19 }; const prepared = { charge, preparedAt: 10_000 }; const at = (ms: number) => prepared.preparedAt + ms; - test("the second click of a double-click on Verify never spends, however long the inventory took", () => { + test("the second click of a double-click on Launch never spends, however long the inventory took", () => { expect(confirmsCharge({ detail: 2, repeat: false }, charge, prepared, at(250))).toBe(false); expect(confirmsCharge({ detail: 2, repeat: false }, charge, prepared, at(10 * CHECKING_HOLD_MS))).toBe(false); expect(confirmsCharge({ detail: 3, repeat: false }, charge, prepared, at(10 * CHECKING_HOLD_MS))).toBe(false); }); - test("a single press confirms only once the checking hold has passed, and a held key never does", () => { + test("a single press approves only once the checking hold has passed, and a held key never does", () => { expect(confirmsCharge({ detail: 1, repeat: false }, charge, prepared, at(CHECKING_HOLD_MS - 1))).toBe(false); expect(confirmsCharge({ detail: 1, repeat: false }, charge, prepared, at(CHECKING_HOLD_MS))).toBe(true); expect(confirmsCharge({ detail: 0, repeat: false }, charge, prepared, at(CHECKING_HOLD_MS))).toBe(true); @@ -286,18 +306,52 @@ describe("confirming the verification charge", () => { }); }); +describe("checks the panel runs or spends on its own", () => { + test("a charge is spent without asking only within the approval, and in the background only to renew a list that passed a check", () => { + expect(spendsWithoutAsking("launch", false, 19, 38)).toBe(true); + expect(spendsWithoutAsking("launch", false, 19, 39)).toBe(false); + expect(spendsWithoutAsking("manual", false, 0, 1)).toBe(false); + expect(spendsWithoutAsking("background", true, 19, 19)).toBe(true); + // A first check that spends is always the person's own press, however much they approved. + expect(spendsWithoutAsking("background", false, 19, 1)).toBe(false); + }); + + test("a background check that may not spend starts no inventory: it only adopts", () => { + expect(adoptsOnly("background", true, 19)).toBe(false); + expect([adoptsOnly("background", false, 19), adoptsOnly("background", true, 0)]).toEqual([true, true]); + // The person's own press always lists models, to show them the charge. + expect([adoptsOnly("launch", false, 0), adoptsOnly("manual", false, 0)]).toEqual([false, false]); + }); + + test("a background check starts only to renew a list that no longer holds, and only when it can adopt or may spend", () => { + const due = (changes: Partial = {}) => backgroundCheckDue({ held: "accounts-changed", initialized: true, staged: false, + imported: false, passed: true, adoptable: false, approvedRequests: 19, ready: true, quiet: false, ...changes }); + expect(due()).toBe(true); + expect(due({ held: "current" })).toBe(false); + // Never in a workspace never set up, over a staged or imported list, while not ready or after a stop. + for (const changes of [{ initialized: false, adoptable: true }, { staged: true }, { imported: true, passed: false, adoptable: true }, + { ready: false }, { quiet: true }, { approvedRequests: null }] as const) expect(due(changes)).toBe(false); + // A list that never passed a check is only adopted; with no approval, only a kept check makes it due. + expect(due({ passed: false })).toBe(false); + expect(due({ passed: false, held: "unverified", adoptable: true })).toBe(true); + expect(due({ approvedRequests: 0 })).toBe(false); + expect(due({ approvedRequests: 0, adoptable: true })).toBe(true); + }); +}); + /** The same workbench, as the gate reads it: nothing in flight, no charge, every lead served. */ function gateFacts(changes: Partial = {}, status: VerificationStatus = "current"): GateFacts { return { ...facts({}, status), running: false, charge: null, unservedLead: null, savedSessionId: "", planYolo: false, ...changes }; } describe("reviewing the saved team when its inputs settle", () => { - test("only the saved, verified team with every launch precondition reviews by itself", () => { + test("only the saved team on a list that serves a launch, with every launch precondition, reviews by itself", () => { expect(autoReviewDue(gateFacts(), 4, null)).toBe(true); expect(autoReviewDue(gateFacts({ unsaved: true, localDraft: { source: "active" } }), 4, null)).toBe(false); expect(autoReviewDue(gateFacts({ unsaved: true, profile: { source: "starter" }, localDraft: { source: "starter" }, record: null }), 4, null)).toBe(false); expect(autoReviewDue(gateFacts({ profile: { source: "draft" }, record: { draft: {} } }), 4, null)).toBe(false); - expect(autoReviewDue(gateFacts({}, "accounts-changed"), 4, null)).toBe(false); + expect(autoReviewDue(gateFacts({}, "accounts-changed"), 4, null)).toBe(true); + expect(autoReviewDue(gateFacts({}, "unpublished"), 4, null)).toBe(false); expect(autoReviewDue(gateFacts({ writable: false }), 4, null)).toBe(false); expect(autoReviewDue(gateFacts({ launchReady: false }), 4, null)).toBe(false); expect(autoReviewDue(gateFacts({ previewCurrent: true }), 4, null)).toBe(false); diff --git a/plugins/test/workflow.test.ts b/plugins/test/workflow.test.ts index 912b60b..17a1389 100644 --- a/plugins/test/workflow.test.ts +++ b/plugins/test/workflow.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import { actionDoor, actionSchemas as ompActionSchemas, BENCHMARK_OPERATION_ID, INVENTORY_OPERATION_ID, OMP_PLUGIN_ID, OMP_VERSION, type AccountsObservation, type BenchmarkInput, type InventoryReceipt, type ActionInput as OmpInput, type ActionResult as OmpResult } from "@atyrode/manifold-omp"; -import { createCodeWorkflowClient, inventoryArtifact, VerificationError, type VerificationProgress } from "../code/workflow.ts"; +import { approvalCovers, createCodeWorkflowClient, inventoryArtifact, VerificationError, type VerificationOptions, type VerificationProgress } from "../code/workflow.ts"; import { verificationState } from "../code/generator/verification.ts"; import { PublicJobSchema, type PublicJob, type TerminalSummary } from "@manifold/protocol"; import { actionSchemas, type ActionResult, type Configuration } from "../code/contract.ts"; @@ -13,6 +13,8 @@ import type { CodeContext } from "../code/context.ts"; import { z } from "zod"; const target = { containerId: "workspace", machineId: "destination" }; +/** Another workspace on the same destination, for checks kept across workspaces. */ +const second = { containerId: "workspace-2", machineId: "destination" }; const unavailable = async (): Promise => { throw new Error("Unexpected native operation"); }; const JobNodeInput = z.strictObject({ node: z.strictObject({ kind: z.literal("job"), machineId: z.string(), operationId: z.string(), jobId: z.string() }) }); /** The verification error a run stopped with; any other outcome fails the test. */ @@ -21,6 +23,12 @@ async function stopOf(run: Promise): Promise { if (!(outcome instanceof VerificationError)) throw new Error(`expected a verification stop, got ${String(outcome)}`); return outcome; } +/** A prepared verification's charge, from the workspace's present revision; one that adopted a kept check instead fails the test. */ +async function prepared(f: ReturnType, expectedRevision: number, options: VerificationOptions = {}, at: typeof target = target) { + const pending = await f.workflow.verifyModels(at, { expectedRevision, budget: "any", selection: null }, options); + if (pending.kind !== "charge") throw new Error("expected a charge, not an adopted check"); + return pending; +} function probeJob(jobId: string, operationId: string, state: PublicJob["state"]): PublicJob { return PublicJobSchema.parse({ jobId, machineId: target.machineId, operationId, pluginId: OMP_PLUGIN_ID, @@ -48,15 +56,22 @@ function verificationFixture() { const states = new Map(); const exitCodes = new Map(); const verdicts: Record = {}; + // Who calls: a person with an unconfined token unless a test says otherwise, as a session would carry them. `ceiling` is a + // credential confined by an authority ceiling: still unscoped, but refused at the workspace root. + const caller = { principal: { id: "writer", kind: "human", name: "Writer", color: "#123456" } as CodeContext["auth"]["principal"], + containerScope: null as string | null, ceiling: false }; const ctx: CodeContext = { now: () => 10_000, emit() {}, outsideScope: async () => null, - auth: { principal: { id: "writer", kind: "human", name: "Writer", color: "#123456" }, - caps: ["containers:read", "containers:write"], containerScope: null, isRoot: false, - allows: async (cap, node) => node?.kind === "container" && node.containerId === target.containerId && - (cap === "containers:read" || cap === "containers:write") }, + auth: { get principal() { return caller.principal; }, + caps: ["containers:read", "containers:write"], get containerScope() { return caller.containerScope; }, isRoot: false, + allows: async (cap, node) => (cap === "containers:read" || cap === "containers:write") && (node === undefined + ? caller.containerScope === null && !caller.ceiling + : node.kind === "container" && (node.containerId === target.containerId || node.containerId === second.containerId)) }, storage: { pluginId: "atyrode.code", get: async key => store.get(key) ?? null, - set: unavailable, delete: unavailable, keys: unavailable, + set: unavailable, delete: async key => { store.delete(key); }, keys: async prefix => [...store.keys()].filter(key => key.startsWith(prefix ?? "")).sort(), compareAndSet: async (key, expected, value) => { + // Plugin storage refuses a key outside this shape (plugin-kit `STORAGE_KEY_PATTERN`), so a write here does too. + if (!/^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/.test(key)) throw new Error(`invalid storage key: ${key}`); if ((store.get(key) ?? null) !== expected) return false; store.set(key, value); return true; } }, @@ -128,8 +143,8 @@ function verificationFixture() { await hooks[door]?.(); return result; }); - const configuration = async () => actionSchemas.readConfiguration.result.parse(await handlers.readConfiguration!(ctx, { containerId: target.containerId })); - return { workflow, store, calls, cancelled, benchmarked, hooks, states, exitCodes, verdicts, omp, ctx, configuration, slot }; + const configuration = async (at: typeof target = target) => actionSchemas.readConfiguration.result.parse(await handlers.readConfiguration!(ctx, { containerId: at.containerId })); + return { workflow, store, calls, cancelled, benchmarked, hooks, states, exitCodes, verdicts, omp, ctx, caller, configuration, slot }; } /** The operator's preview, made against a catalog with OpenAI and a fourth rung. */ const preview: Selection = { lane: { kind: "mixed" }, capability: 4, thinking: "high", advisor: "review", spark: true, @@ -139,7 +154,7 @@ const effects = ["atyrode.code.initializeConfiguration", actionDoor("startInvent test("verification prepares a charge without spending, and one confirmation saves a verified catalog and the narrowed preview", async () => { const f = verificationFixture(), progress: VerificationProgress[] = []; - const pending = await f.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" }, { onProgress: value => progress.push(value) }); + const pending = await prepared(f, 0, { onProgress: value => progress.push(value) }); expect(pending.charge).toMatchObject({ target, revision: 1, budget: "any", inventoryJobId: "inventory-1", requests: 5, replacesDraft: false, defaultsRevision: 3, providers: [{ provider: "anthropic", requests: 3 }, { provider: "deepseek", requests: 2 }], exclusions: [{ provider: "deepseek", id: "deepseek-vision-exp", reason: "unstable_id" }], @@ -172,7 +187,7 @@ test("verification prepares a charge without spending, and one confirmation save test("a confirmation refuses to spend against a revision, pool, OMP defaults or OMP that moved since the charge", async () => { for (const move of ["revision", "pool", "defaults", "runtime", "catalog"] as const) { const f = verificationFixture(); - const pending = await f.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" }); + const pending = await prepared(f, 0); if (move === "revision") await f.workflow.code("changeAccounts", { containerId: target.containerId, expectedRevision: 1, change: { kind: "create-preset", preset: { id: "other", name: "Other", disabled: [] } } }); if (move === "pool") f.omp.accounts.accounts = [f.slot("anthropic", 1)]; @@ -189,13 +204,13 @@ test("a confirmation refuses to spend against a revision, pool, OMP defaults or } // Preparing from a revision the workspace has left spends nothing at all. const f = verificationFixture(); - await expect(f.workflow.verifyModels(target, { expectedRevision: 3, budget: "any" })).rejects.toThrow("observe: code_stale_preferences"); + await expect(prepared(f, 3)).rejects.toThrow("observe: code_stale_preferences"); expect(f.calls.filter(door => effects.includes(door))).toEqual([]); }); test("a pool that moves while the benchmark runs is never recorded as the verified one", async () => { const f = verificationFixture(); - const pending = await f.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" }); + const pending = await prepared(f, 0); f.hooks[actionDoor("readBenchmark")] = () => { f.omp.accounts.accounts = [f.slot("anthropic", 1), f.slot("deepseek", 2), f.slot("deepseek", 3)]; }; const stopped = await stopOf(pending.confirm(preview)); expect([stopped.step, stopped.reason]).toEqual(["stage", "code_accounts_changed"]); @@ -206,7 +221,7 @@ test("a pool that moves while the benchmark runs is never recorded as the verifi test("an OMP upgraded while the benchmark runs is never recorded as the one verified", async () => { const f = verificationFixture(); - const pending = await f.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" }); + const pending = await prepared(f, 0); f.hooks[actionDoor("readBenchmark")] = () => { f.omp.artifact = "9".repeat(64); }; const stopped = await stopOf(pending.confirm(preview)); expect([stopped.step, stopped.reason]).toEqual(["stage", "code_omp_changed"]); @@ -217,7 +232,7 @@ test("a model list republished while the inventory runs stops the verification b const f = verificationFixture(); // OMP picked the inventory's models from the list it bundled when the inventory started; another list is published after. f.hooks[actionDoor("startInventory")] = () => { f.omp.catalog = { ...f.omp.catalog, revision: "b".repeat(64) }; }; - const stopped = await stopOf(f.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" })); + const stopped = await stopOf(prepared(f, 0)); expect([stopped.step, stopped.reason]).toEqual(["draft", "code_omp_changed"]); expect(stopped.evidence).toEqual({ inventoryJobId: "inventory-1", benchmarkJobIds: [], revision: 1 }); expect(f.calls).not.toContain(actionDoor("startBenchmark")); @@ -228,7 +243,7 @@ test("a verification outlives an OMP update its routed models survive, and a mod const f = verificationFixture(); // OMP publishes the models its inventory listed, as its bundled catalog does. f.omp.catalog = { ...f.omp.catalog, models: f.omp.inventory.models }; - const { configuration } = await (await f.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" })).confirm(preview); + const { configuration } = await (await prepared(f, 0)).confirm(preview); const catalog = compileCatalog(configuration.active!.document); const routed = routedModels(catalog, reviewCatalog(catalog, configuration.selection!, 0).routes); // What the workbench compares the saved verification with: the destination's pins, the bundled list's revision, what that @@ -257,14 +272,14 @@ test("a stop cancels the probe job in flight, and a failed one names its step an const cancelling = verificationFixture(), controller = new AbortController(); cancelling.states.set("inventory-1", ["started", "started"]); cancelling.hooks[actionDoor("startInventory")] = () => { controller.abort(); }; - await expect(cancelling.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" }, { signal: controller.signal })) + await expect(prepared(cancelling, 0, { signal: controller.signal })) .rejects.toThrow("inventory: code_verification_cancelled"); expect(cancelling.cancelled).toEqual(["inventory-1"]); expect(cancelling.calls).not.toContain(actionDoor("readInventory")); // Revocation is a stop like cancellation: the running benchmark is cancelled, not abandoned. const revoked = verificationFixture(); let current = true; - const pending = await revoked.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" }, { isCurrent: () => current }); + const pending = await prepared(revoked, 0, { isCurrent: () => current }); revoked.states.set("benchmark-1", ["started"]); revoked.hooks[actionDoor("startBenchmark")] = () => { current = false; }; await expect(pending.confirm(preview)).rejects.toThrow("benchmark: code_verification_changed"); @@ -273,7 +288,7 @@ test("a stop cancels the probe job in flight, and a failed one names its step an // A probe job that fails stops the run at its step, with the job named. const failing = verificationFixture(); failing.exitCodes.set("benchmark-2", 1); - const failed = await stopOf((await failing.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" })).confirm(preview)); + const failed = await stopOf((await prepared(failing, 0)).confirm(preview)); expect([failed.step, failed.reason]).toEqual(["benchmark", "job benchmark-2 exited with exit code 1"]); expect(failed.evidence.benchmarkJobIds).toEqual(["benchmark-1", "benchmark-2"]); expect((await failing.configuration()).configuration).toMatchObject({ revision: 1, active: null, draft: null }); @@ -281,7 +296,7 @@ test("a stop cancels the probe job in flight, and a failed one names its step an test("a status read that fails while a probe runs cancels the probe, and a cancellation that fails is named", async () => { const unread = verificationFixture(); - const pending = await unread.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" }); + const pending = await prepared(unread, 0); unread.states.set("benchmark-1", ["started"]); unread.hooks["engine.jobs.status"] = () => { throw new Error("engine_job_status_unavailable"); }; const stopped = await stopOf(pending.confirm(preview)); @@ -289,7 +304,7 @@ test("a status read that fails while a probe runs cancels the probe, and a cance expect(unread.cancelled).toEqual(["benchmark-1"]); // The cancellation is attempted even when it then fails, and the error keeps both what stopped the wait and that the job runs on. const stuck = verificationFixture(); - const again = await stuck.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" }); + const again = await prepared(stuck, 0); stuck.states.set("benchmark-1", ["started"]); stuck.hooks["engine.jobs.status"] = () => { throw new Error("engine_job_status_unavailable"); }; stuck.hooks["engine.jobs.cancel"] = () => { throw new Error("timeout"); }; @@ -302,7 +317,7 @@ test("a status read that fails while a probe runs cancels the probe, and a cance test("a family Code requires that the accounts reach only in part is verified short, not refused", async () => { const short = verificationFixture(); short.verdicts["claude-sonnet-5"] = "not_found"; short.verdicts["claude-opus-5"] = "client_blocked"; - const verified = await (await short.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" })).confirm(preview); + const verified = await (await prepared(short, 0)).confirm(preview); expect(verified.configuration.active?.document.models.map(model => [model.tier, model.id])) .toEqual([[1, "claude-haiku-5"], [1, "deepseek-flash"], [2, "deepseek-pro"]]); expect(verified.exclusions).toEqual([{ provider: "anthropic", id: "claude-opus-5", reason: "client_blocked" }, @@ -315,41 +330,49 @@ test("a model with no clear answer is left out of a first list, but never shorte // A first list: a provider that could not be checked at all is left out and named, and what answered is saved. const f = verificationFixture(); f.verdicts["deepseek-flash"] = "unresolved"; f.verdicts["deepseek-pro"] = "unmatched"; - const first = await (await f.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" })).confirm(preview); + const first = await (await prepared(f, 0)).confirm(preview); expect(first.configuration.active?.document.models.map(model => model.id)).toEqual(["claude-haiku-5", "claude-sonnet-5", "claude-opus-5"]); expect(first.exclusions.filter(exclusion => exclusion.reason === "inconclusive").map(exclusion => exclusion.id)).toEqual(["deepseek-flash", "deepseek-pro"]); // Verifying again while a model of the list in use gives no clear answer stops before anything is staged; the list and team stay. f.verdicts["claude-opus-5"] = "unresolved"; const before = await f.configuration(); - const again = await stopOf((await f.workflow.verifyModels(target, { expectedRevision: before.revision, budget: "any" })).confirm(first.configuration.selection)); + const again = await stopOf((await prepared(f, before.revision)).confirm(first.configuration.selection)); expect([again.step, again.reason]).toEqual(["derive", "code_models_unanswered: anthropic/claude-opus-5"]); expect(await f.configuration()).toEqual(before); // Another pool's verification vouches for nothing these accounts could keep, so the same non-answer only leaves the model out. f.omp.accounts.accounts.push(f.slot("anthropic", 3)); - const otherPool = await (await f.workflow.verifyModels(target, { expectedRevision: before.revision, budget: "any" })).confirm(first.configuration.selection); + const otherPool = await (await prepared(f, before.revision)).confirm(first.configuration.selection); expect(otherPool.configuration.active?.document.models.map(model => model.id)).toEqual(["claude-haiku-5", "claude-sonnet-5"]); - // So does a list in use that no verification made, such as an imported one. - const imported = await f.workflow.code("stageCatalog", { containerId: target.containerId, expectedRevision: otherPool.configuration.revision, - document: first.configuration.active!.document }); - const review = await f.workflow.code("reviewCatalog", { containerId: target.containerId, expectedRevision: imported.revision, source: "draft" }); - const unverified = await f.workflow.code("promoteCatalog", { containerId: target.containerId, expectedRevision: imported.revision, source: "draft", reviewDigest: review.reviewDigest }); - expect(unverified.active?.provenance).toBeNull(); - const replaced = await (await f.workflow.verifyModels(target, { expectedRevision: unverified.revision, budget: "any" })).confirm(unverified.selection); - expect(replaced.configuration.active?.document.models.map(model => model.id)).toEqual(["claude-haiku-5", "claude-sonnet-5"]); +}); + +test("a list in use that no verification made vouches for nothing either", async () => { + // An imported list, put in use before any check of these accounts was kept. + const f = verificationFixture(); + const initialized = await f.workflow.code("initializeConfiguration", { containerId: target.containerId, expectedRevision: 0 }); + const document = { schemaVersion: 1 as const, models: (["claude-haiku-5", "claude-sonnet-5", "claude-opus-5"] as const).map((id, index) => ({ + key: id, provider: "anthropic", id, api: "anthropic-messages", tier: (index + 1) as 1 | 2 | 3, quotaBucket: null, inputCostPerMillion: index + 1, + outputCostPerMillion: (index + 1) * 5, tokensPerSecond: null, timeToFirstTokenMs: null, contextWindow: 200_000, thinkingLevels: ["low" as const, "high" as const], images: true })) }; + const staged = await f.workflow.code("stageCatalog", { containerId: target.containerId, expectedRevision: initialized.revision, document }); + const review = await f.workflow.code("reviewCatalog", { containerId: target.containerId, expectedRevision: staged.revision, source: "draft" }); + const imported = await f.workflow.code("promoteCatalog", { containerId: target.containerId, expectedRevision: staged.revision, source: "draft", reviewDigest: review.reviewDigest }); + expect(imported.active?.provenance).toBeNull(); + f.verdicts["claude-opus-5"] = "unresolved"; + const replaced = await (await prepared(f, imported.revision)).confirm(imported.selection); + expect(replaced.configuration.active?.document.models.map(model => model.id)).toEqual(["claude-haiku-5", "claude-sonnet-5", "deepseek-flash", "deepseek-pro"]); }); test("a first list stops rather than move the team off a family it chose that gave no clear answer at all", async () => { // The mixed lane names Claude: with no Claude model answering, saving would leave the team on another family. const mixed = verificationFixture(); for (const id of ["claude-haiku-5", "claude-sonnet-5", "claude-opus-5"]) mixed.verdicts[id] = "unresolved"; - const blended = await stopOf((await mixed.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" })).confirm(preview)); + const blended = await stopOf((await prepared(mixed, 0)).confirm(preview)); expect([blended.step, blended.reason]).toEqual(["derive", "code_models_unanswered: anthropic/claude-haiku-5, anthropic/claude-opus-5, anthropic/claude-sonnet-5"]); expect(mixed.calls).not.toContain("atyrode.code.stageCatalog"); // A team that chose DeepSeek alone stops the same way when DeepSeek does not answer. const chosen = verificationFixture(); chosen.verdicts["deepseek-flash"] = "unresolved"; chosen.verdicts["deepseek-pro"] = "unresolved"; const deepseekOnly: Selection = { ...preview, lane: { kind: "provider", family: "deepseek", blend: "only" } }; - const refused = await stopOf((await chosen.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" })).confirm(deepseekOnly)); + const refused = await stopOf((await prepared(chosen, 0)).confirm(deepseekOnly)); expect([refused.step, refused.reason]).toEqual(["derive", "code_models_unanswered: deepseek/deepseek-flash, deepseek/deepseek-pro"]); expect(chosen.calls).not.toContain("atyrode.code.stageCatalog"); }); @@ -358,19 +381,19 @@ test("a derivation or selection the verified catalog cannot serve stops before a // Nothing the accounts offer answers, so there is no catalog to stage. const blocked = verificationFixture(); for (const id of ["claude-haiku-5", "claude-sonnet-5", "claude-opus-5", "deepseek-flash", "deepseek-pro"]) blocked.verdicts[id] = "not_found"; - const refused = await stopOf((await blocked.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" })).confirm(preview)); + const refused = await stopOf((await prepared(blocked, 0)).confirm(preview)); expect([refused.step, refused.reason]).toEqual(["derive", "code_probe_insufficient_ladder"]); expect(blocked.calls).not.toContain("atyrode.code.stageCatalog"); // A free preview against a catalog derived under `any` with nothing free: the budget is never widened. const paid = verificationFixture(); - const unsatisfiable = await stopOf((await paid.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" })).confirm({ ...preview, budget: "free" })); + const unsatisfiable = await stopOf((await prepared(paid, 0)).confirm({ ...preview, budget: "free" })); expect([unsatisfiable.step, unsatisfiable.reason]).toEqual(["derive", "code_budget_unsatisfiable"]); expect(paid.calls).not.toContain("atyrode.code.stageCatalog"); }); test("a write that loses its race after staging stops at its step and keeps the verified draft for review", async () => { const f = verificationFixture(); - const pending = await f.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" }); + const pending = await prepared(f, 0); f.hooks["atyrode.code.reviewCatalog"] = async () => { const current = (await f.configuration()).revision; await f.workflow.code("changeAccounts", { containerId: target.containerId, expectedRevision: current, @@ -386,7 +409,7 @@ test("a write that loses its race after staging stops at its step and keeps the test("the verification observation names the pool a verification would record, and an empty one as none", async () => { const f = verificationFixture(); - const pending = await f.workflow.verifyModels(target, { expectedRevision: 0, budget: "any" }); + const pending = await prepared(f, 0); const record: Configuration = (await f.configuration()).configuration!; expect(await f.workflow.observeVerification(target.containerId, record.revision, record.accounts)) .toEqual({ providers: ["anthropic", "deepseek"], poolIdentityDigest: pending.charge.pool.poolIdentityDigest }); @@ -394,6 +417,182 @@ test("the verification observation names the pool a verification would record, a expect(await f.workflow.observeVerification(target.containerId, record.revision, record.accounts)).toBeNull(); }); +test("a staged verification is kept as the person's own check of its pool and budget, which nobody else reads", async () => { + const f = verificationFixture(); + expect(await f.workflow.readChecks()).toEqual({ keeps: true, revision: 0, approvedRequests: 0, approvedAt: null, checks: [] }); + const verified = await (await prepared(f, 0)).confirm(preview); + expect((await f.workflow.readChecks()).checks).toEqual([{ poolIdentityDigest: verified.provenance.poolIdentityDigest, budget: "any", + digest: verified.configuration.active!.digest, provenance: verified.provenance, recordedAt: 10_000, + models: ["anthropic/claude-haiku-5", "anthropic/claude-opus-5", "anthropic/claude-sonnet-5", "deepseek/deepseek-flash", "deepseek/deepseek-pro"] }]); + // Another person, an agent or a service sees none of it. + f.caller.principal = { id: "reader", kind: "human", name: "Reader", color: "#abcdef" }; + expect((await f.workflow.readChecks()).checks).toEqual([]); + f.caller.principal = { id: "agent-1", kind: "agent", name: "Agent", color: "#654321" }; + expect((await f.workflow.readChecks()).checks).toEqual([]); +}); + +test("a workspace whose saved choices select a kept pool adopts the person's check without spending, narrowed to its profile", async () => { + const f = verificationFixture(); + const first = await (await prepared(f, 0)).confirm(preview); + const from = f.calls.length; + const deepseekOnly: Selection = { ...preview, lane: { kind: "provider", family: "deepseek", blend: "only" } }; + const adopted = await f.workflow.verifyModels(second, { expectedRevision: 0, budget: "any", selection: deepseekOnly }); + expect(adopted.kind).toBe("adopted"); + // Initialized and adopted: no inventory, no benchmark, nothing staged. + expect(f.calls.slice(from).filter(door => effects.includes(door) || door === "atyrode.code.adoptCheck")) + .toEqual(["atyrode.code.initializeConfiguration", "atyrode.code.adoptCheck"]); + const saved = (await f.configuration(second)).configuration!; + expect(saved.active).toEqual(first.configuration.active); + expect(saved.selection?.lane).toEqual(deepseekOnly.lane); + // Holding the check now, that workspace checks for real when asked again. + expect((await prepared(f, saved.revision, {}, second)).charge.requests).toBe(5); +}); + +test("adopting refuses another pool, a pool with no kept check, and a workspace already holding one no older", async () => { + const f = verificationFixture(); + const first = await (await prepared(f, 0)).confirm(preview); + const accounts = await f.workflow.omp("accounts", {}); + const initialized = await f.workflow.code("initializeConfiguration", { containerId: second.containerId, expectedRevision: 0 }); + const adopt = (containerId: string, expectedRevision: number, poolIdentityDigest = first.provenance.poolIdentityDigest, budget: Selection["budget"] = "any") => + handlers.adoptCheck!(f.ctx, { containerId, expectedRevision, accounts, poolIdentityDigest, budget, selection: null }); + // The pool the caller found a check for must be the one the saved choices select now. + expect(await adopt(second.containerId, initialized.revision, "b".repeat(64))).toEqual({ refused: "code_accounts_changed" }); + // The workspace the check came from already holds it. + expect(await adopt(target.containerId, first.configuration.revision)).toEqual({ refused: "code_check_current" }); + // A list derived under another budget is not the check of this one: a free list never replaces a paid one, or the reverse. + expect(await adopt(second.containerId, initialized.revision, first.provenance.poolIdentityDigest, "free")).toEqual({ refused: "code_check_missing" }); + // A token confined to one container keeps no checks, so it adopts none either. + f.caller.containerScope = second.containerId; + expect(await adopt(second.containerId, initialized.revision)).toEqual({ refused: "code_check_missing" }); + f.caller.containerScope = null; + // A kept list naming a provider these accounts do not include is never put in use, whatever key it is filed under. + const key = [...f.store.keys()].find(entry => entry.startsWith("checks/"))!, original = f.store.get(key)!, stored = JSON.parse(original); + f.store.set(key, JSON.stringify({ ...stored, document: { ...stored.document, models: [...stored.document.models, + { ...stored.document.models[0], key: "elsewhere", provider: "openrouter", id: "elsewhere" }] } })); + expect(await adopt(second.containerId, initialized.revision)).toEqual({ refused: "code_invalid_provenance" }); + f.store.set(key, original); + // Another person keeps no check of this pool. + f.caller.principal = { id: "reader", kind: "human", name: "Reader", color: "#abcdef" }; + expect(await adopt(second.containerId, initialized.revision)).toEqual({ refused: "code_check_missing" }); + expect((await f.configuration(second)).configuration).toEqual(initialized); +}); + +test("the standing approval is the caller's own, written at its exact revision, and bounds what checks spend without asking", async () => { + const f = verificationFixture(); + expect(await f.workflow.approveChecks(0, 19)).toMatchObject({ revision: 1, approvedRequests: 19, approvedAt: 10_000 }); + await expect(f.workflow.approveChecks(0, 40)).rejects.toThrow("code_stale_preferences"); + expect((await f.workflow.approveChecks(1, 0)).approvedRequests).toBe(0); + // Checks run without asking up to twice the approved charge, never with none approved, and never for nothing. + expect([approvalCovers(19, 38), approvalCovers(19, 39), approvalCovers(0, 1), approvalCovers(19, 0)]).toEqual([true, false, false, false]); + // A prepared charge carries the approval as read while preparing it. + await f.workflow.approveChecks(2, 5); + expect((await prepared(f, 0)).approvedRequests).toBe(5); + // Another person's approval is theirs; an agent approves nothing. + f.caller.principal = { id: "reader", kind: "human", name: "Reader", color: "#abcdef" }; + expect((await f.workflow.readChecks()).approvedRequests).toBe(0); + f.caller.principal = { id: "agent-1", kind: "agent", name: "Agent", color: "#654321" }; + await expect(f.workflow.approveChecks(0, 19)).rejects.toThrow("code_person_required"); +}); + +test("a check never drops a model the person's own check of the pool listed when it merely gave no clear answer", async () => { + const f = verificationFixture(); + // The person's check of this pool lists Claude Opus. + await (await prepared(f, 0)).confirm(preview); + // In another workspace, someone else put a newer list of the same pool in use without Opus: theirs, not this person's check. + f.caller.principal = { id: "reader", kind: "human", name: "Reader", color: "#abcdef" }; + f.verdicts["claude-opus-5"] = "not_found"; + const theirs = await (await prepared(f, 0, {}, second)).confirm(preview); + expect(theirs.configuration.active?.document.models.map(model => model.id)).not.toContain("claude-opus-5"); + // Checking that workspace again, Opus gives no clear answer: the person's own check keeps it from being dropped. + f.caller.principal = { id: "writer", kind: "human", name: "Writer", color: "#123456" }; + f.verdicts["claude-opus-5"] = "unresolved"; + const before = await f.configuration(second); + const stopped = await stopOf((await prepared(f, before.revision, {}, second)).confirm(theirs.configuration.selection)); + expect([stopped.step, stopped.reason]).toEqual(["derive", "code_models_unanswered: anthropic/claude-opus-5"]); + expect(await f.configuration(second)).toEqual(before); +}); + +test("a check survives its own run losing a race after it was staged, and the next run adopts it instead of spending", async () => { + const f = verificationFixture(); + const pending = await prepared(f, 0); + f.hooks["atyrode.code.reviewCatalog"] = async () => { + const current = (await f.configuration()).revision; + await f.workflow.code("changeAccounts", { containerId: target.containerId, expectedRevision: current, + change: { kind: "create-preset", preset: { id: "other", name: "Other", disabled: [] } } }); + }; + expect((await stopOf(pending.confirm(preview))).step).toBe("promote"); + delete f.hooks["atyrode.code.reviewCatalog"]; + const benchmarks = f.benchmarked.length, now = await f.configuration(); + const again = await f.workflow.verifyModels(target, { expectedRevision: now.revision, budget: "any", selection: preview }); + expect(again.kind).toBe("adopted"); + expect(f.benchmarked).toHaveLength(benchmarks); +}); + +test("an adopt-only run writes and starts nothing unless the person keeps a check of the pool", async () => { + const f = verificationFixture(); + // A workspace never set up is left alone: not even initialized. + expect(await f.workflow.verifyModels(target, { expectedRevision: 0, budget: "any", selection: null, adoptOnly: true })).toEqual({ kind: "none" }); + expect(f.calls.filter(door => effects.includes(door))).toEqual([]); + // A set-up workspace with no kept check of its pool starts no inventory. + const initialized = await f.workflow.code("initializeConfiguration", { containerId: target.containerId, expectedRevision: 0 }); + expect(await f.workflow.verifyModels(target, { expectedRevision: initialized.revision, budget: "any", selection: null, adoptOnly: true })).toEqual({ kind: "none" }); + expect(f.calls).not.toContain(actionDoor("startInventory")); + // With a check of the pool kept from another workspace, it adopts it. + await (await prepared(f, 0, {}, second)).confirm(preview); + expect((await f.workflow.verifyModels(target, { expectedRevision: initialized.revision, budget: "any", selection: null, adoptOnly: true })).kind).toBe("adopted"); +}); + +test("stored checks and approvals are bound to their principal: a record naming anyone else is refused as corruption", async () => { + const f = verificationFixture(); + await (await prepared(f, 0)).confirm(preview); + await f.workflow.approveChecks(0, 19); + for (const prefix of ["checks/", "approvals/"]) { + const key = [...f.store.keys()].find(entry => entry.startsWith(prefix))!; + const original = f.store.get(key)!; + f.store.set(key, JSON.stringify({ ...JSON.parse(original), principalId: "reader" })); + await expect(f.workflow.readChecks()).rejects.toThrow("code_invalid_configuration"); + f.store.set(key, original); + } + // A check filed under another pool's key is not that pool's check: read or adopted, it is refused as corruption. + const key = [...f.store.keys()].find(entry => entry.startsWith("checks/"))!, original = f.store.get(key)!; + const moved = key.replace(/[0-9a-f]{64}$/, "b".repeat(64)); + f.store.delete(key); f.store.set(moved, original); + await expect(f.workflow.readChecks()).rejects.toThrow("code_invalid_configuration"); + f.store.delete(moved); f.store.set(key, original); + // A token confined to one container, or by an authority ceiling, reads none of them and approves nothing. + f.caller.containerScope = target.containerId; + expect(await f.workflow.readChecks()).toEqual({ keeps: false, revision: 0, approvedRequests: 0, approvedAt: null, checks: [] }); + await expect(f.workflow.approveChecks(0, 5)).rejects.toThrow("code_checks_confined"); + f.caller.containerScope = null; f.caller.ceiling = true; + expect(await f.workflow.readChecks()).toEqual({ keeps: false, revision: 0, approvedRequests: 0, approvedAt: null, checks: [] }); + await expect(f.workflow.approveChecks(1, 5)).rejects.toThrow("code_checks_confined"); +}); + +test("a check derived under another budget is never adopted: the workspace checks for itself", async () => { + const f = verificationFixture(); + await (await prepared(f, 0)).confirm(preview); + const from = f.calls.length; + // The fixture's models are all paid, so the free inventory's own draft refuses; what matters is that it ran instead of adopting. + await f.workflow.verifyModels(second, { expectedRevision: 0, budget: "free", selection: null }).catch(() => null); + expect(f.calls.slice(from)).toContain(actionDoor("startInventory")); + expect(f.calls.slice(from)).not.toContain("atyrode.code.adoptCheck"); +}); + +test("a charge spent without asking is spent only while the approval, read again, still covers it", async () => { + const f = verificationFixture(); + await f.workflow.approveChecks(0, 5); + const pending = await prepared(f, 0); + // Withdrawn after the charge was answered: nothing is benchmarked. + await f.workflow.approveChecks(1, 0); + const stopped = await stopOf(pending.confirm(preview, { unasked: true })); + expect([stopped.step, stopped.reason]).toEqual(["benchmark", "code_approval_withdrawn"]); + expect(f.benchmarked).toEqual([]); + // Still covered, it spends. + await f.workflow.approveChecks(2, 5); + const again = await prepared(f, (await f.configuration()).revision); + expect((await again.confirm(preview, { unasked: true })).configuration.active?.provenance).not.toBeNull(); +}); + function sessionFixture() { const catalog = compileCatalog({ schemaVersion: 1, models: ([1, 2, 3] as const).map(tier => ({ key: `model-${tier}`, provider: "anthropic", id: `model-${tier}`, api: "anthropic-messages", tier,