diff --git a/server/lib/README.md b/server/lib/README.md index e6d007e9a5..033212fb2e 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -65,6 +65,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `quotaBurnConfig.js` | Quota-burn plan shape: the provider families, the burn-job type alphabet + catalog the config page renders its form from, `QUOTA_BURN_BOUNDS` (the one bounds table the normalizer clamps to, the Zod schemas reject against, and the catalog descriptors publish as min/max), and total normalization (`normalizeQuotaBurnConfig`). Owns the dispatch-cap sentinel too (`QUOTA_BURN_UNLIMITED_DISPATCHES` / `isUnlimitedDispatchCap`) — -1 means the window is not counted, and is the default. Also owns the queued burn task's description shape (`burnTaskDescription` / `quotaBurnFamilyOfDescription`), shared with migration 225, and the `run once` vocabulary (`quotaBurnJobKey` / `jobIsSpent`, plus the two family predicates `familyIsConfigured` and `familyHasRunnableJobs`). Pure — no storage, no provider I/O. | | `quotaBurnPresets.js` | `QUOTA_BURN_PROMPT_PRESETS` — ready-made single-focus audit prompts for `agent-prompt` burn jobs (UX, a11y, mobile, failure paths, perf, test gaps, dead code, data safety, docs, security), each filing GitHub issues and changing no code. Templates: picking one COPIES its prompt into the job, so editing them never rewrites a configured job. `findQuotaBurnPreset(id)`. | | `auditCatalog.js` | Shared catalog of scheduled AUDIT task types (`AUDIT_DEFINITIONS` / `AUDIT_TASK_TYPES`) that can either implement a fix or file tracker issues. `isFileIssuesMode`, `auditDoWorkRequiresWorktree`, `getAuditFilingPreset`, `modeContractFor`, `applyAuditModeWrapper`. Each quota-burn audit preset maps to a scheduled type here (guarded by `auditCatalog.test.js`). Pure — no I/O. | +| `quotaBurnTaskRef.js` | The quota-burn STEP model: a discriminated scheduled-task reference (`builtin` task type + validated target app, or `custom` app-job id whose own scope is authoritative) plus the per-invocation `overrides` bag (provider/model/effort/params — unset inherits the task's saved settings, and nothing written here edits the schedule). `normalizeQuotaBurnTaskRef` / `normalizeQuotaBurnOverrides` / `normalizeQuotaBurnParams`, the `QUOTA_BURN_UNAVAILABLE` reason codes, `resolveQuotaBurnStepAvailability` (catalog passed IN, so this stays pure) / `applyQuotaBurnAvailability`, and `quotaBurnStepIsDispatchable`. | | `quotaBurnValidation.js` | Zod schemas for the Quota Burn routes (partial config PUT, manual-run body, `run once` re-arm body). | | `quotaReset.js` | `parseHumanReset` turns a provider CLI's human reset string into ISO 8601 (call it from the adapter); `normalizeResetAt`/`hoursUntilReset` compute time remaining without treating unknown values as imminent resets; `parseObservedReset` pulls the reset a provider stated in its own refusal text, and `isObservedBlockActive` is the shared "is that refusal still holding?" predicate every observed-refusal ledger uses. | | `quotaWindows.js` | Classifies a quota window by PERIOD (`windowPeriodHours`); `classifyWindows` splits one card's windows into the weekly allowance that expires unused and the 5-hour one that refuses first, in one pass. `windowLabelOf` names a window. Pure. | @@ -181,7 +182,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `subscriptionSavings.js` | Subscription-vs-API savings math for the usage page — `resolveSavingsWindow` (clamps an open-ended report range to today / first activity day), `prorateMonthlyCost` (monthly plan price → this window's share, `DAYS_PER_MONTH`, capped by `MAX_MONTHLY_COST`), `savingsPercent` / `costMultiplier` (null, never 0, when the comparison is undefined), `attributeReportCostToFamilies` (groups report rows by their stamped `family`), `roundCents` (the one money rounder), and `buildSubscriptionSavings({ entries, range, unmatchedApiCost })` → per-family rows + totals. Pure. | | `credentialRegistry.js` | Pure catalog of PortOS credentials (`CREDENTIALS`, `CREDENTIAL_IDS`, `CREDENTIAL_TIERS`) — one entry per key/token an install can use (`id`, `label`, `unlocks`, `tier`, `getUrl`, `envVars`, `settingsPath`, `configurePath`, optional `feature`). Sits beside `instanceFeatureRegistry.js` so the two lists stay greppable together. Runtime resolution (settings / repo `.env` / inherited `process.env` / CLI / instance config) lives in `services/credentialInventory.js`. The Settings > Credentials page never receives a value or masked prefix. | | `instanceFeatureRegistry.js` | The registry of optional per-install features (`INSTANCE_FEATURES`, `INSTANCE_FEATURE_IDS`, `APP_FEATURE_IDS`) — pure data, so `validation.js` derives its feature schemas from it and `navManifest.js` can be checked against it without a service→lib inversion. Runtime resolution (stored override → auto-detection → `defaultEnabled`) lives in `services/instanceFeatures.js`. A feature id tagged on a nav entry hides that page from ⌘K and the sidebar when the feature is off. | -| `providerFamilies.js` | Subscription-quota FAMILY identity — `PROVIDER_FAMILIES` (`{ id, label, matches }` for claude/codex/agy/grok), `PROVIDER_FAMILY_IDS`, `familyLabel`, `familyForProvider(config)` → family id or null (local-runtime wrappers and API-only providers belong to none). The pure half of the registry `services/providerUsage.js` attaches quota `fetch`ers to, so cost attribution and route validation can ask "which plan is this provider on?" without importing the PTY-scrape graph. Distinct from `providerVendors.js`, which is argv-shaped and includes vendors with no subscription quota. | +| `providerFamilies.js` | Subscription-quota FAMILY identity — `PROVIDER_FAMILIES` (`{ id, label, idPattern, matches }` for claude/codex/agy/grok), `PROVIDER_FAMILY_IDS`, `familyLabel`, `familyForProvider(config)`, `familyForProviderId(id)` (the weaker id-only classifier a Zod schema can use, null when the id names no family) → family id or null (local-runtime wrappers and API-only providers belong to none). The pure half of the registry `services/providerUsage.js` attaches quota `fetch`ers to, so cost attribution and route validation can ask "which plan is this provider on?" without importing the PTY-scrape graph. Distinct from `providerVendors.js`, which is argv-shaped and includes vendors with no subscription quota. | | `fleetQuotas.js` | Unifies subscription-quota readings across federated instances — one plan, several machines, each able to read only its own local CLI. `sanitizeQuotaCards` bounds a peer-supplied payload to the wire shape; `mergeFleetQuotaCards(localCards, peerEntries)` folds every peer's reading into this install's cards, taking the FRESHEST reading per limit key (the meters are account-wide, so summing them would multiply one allowance) and SUMMING activity counts (those are per-machine, which is why the provider captions them "does not include other devices"); `fleetNote` writes the caption naming what was combined. `metrics[]` is left local — its values are prose, not addends. Fed by `services/providerQuotaShare.js` (this machine's readings, persisted) and `services/peerUsage.js` (the `usage` sync category that carries them). | | `harnessOutput.js` | Parsers for what a coding-agent HARNESS prints about itself: `parseHarnessVersion(stdout)` (the one semver run in a `--version` banner, `null` when unparseable), `compareHarnessVersions(a, b)` (the null-guarding wrapper around `versionUtils.js#compareSemver` — `null` when either side is unparseable, so a version that did not parse never reads as "out of date"), `parseHarnessModels(harnessId, stdout)` + `HARNESS_MODEL_PARSER_IDS` (OpenCode's `provider/model` lines and Grok's bulleted list are parsed here; Antigravity and Cursor DELEGATE to `antigravity.js#parseAntigravityModelList` / `aiToolkit/internal/cursor.js#parseCursorModelList`, which the provider-card refresh has used for far longer), `MAX_MODELS`, and `parseNpmLatestVersion`. Pure: the service layer runs the child and hands the captured stdout here, so the vendor output shapes are pinned by table-driven tests instead of by running six real binaries in CI. Model ids come back in the exact spelling `--model` takes — namespaces kept where the vendor keeps them. Consumed by `services/providerRuntimeInstaller.js` and `services/harnesses.js`. | | `providerGateways.js` | `PROVIDER_GATEWAYS` — one row per hosted OpenAI-compatible gateway an OpenCode CLI/TUI wrapper can front-end (`orcarouter`, `openrouter`), plus `PROVIDER_GATEWAY_IDS`, `gatewayById`, `isGatewayNamespace(ns)` and `gatewayForProvider(config)` → row or null. Each row's `id` is simultaneously the OpenCode provider namespace, the `gatewayBacked` marker value, and the id of the sibling `api` record that owns the key — so the sibling lookup is `providers[gateway.id]` and an OrcaRouter key can never satisfy an OpenRouter wrapper. Replaces the `orcarouterBacked` boolean + literal `'orcarouter'` that had been hand-copied across ~15 server and client files (namespace resolution, the OpenCode config builder, both zod schemas, the model-fetcher table, the sibling-key attach, the prerequisite check, and the two "not a local runtime" carve-outs in `cliChildEnv.js`/`localProviderRuntime.js`). Reads the legacy per-gateway boolean FOREVER, so stored records are never rewritten. Distinct from a local runtime (`ollamaBacked`, `vllmBacked`, …): remote, always authenticating, and no thinking toggle. Deliberately mirrored in `aiToolkit/internal/gateways.js` (the vendored toolkit may not import out) and `client/src/utils/providerGateways.js` (the browser cannot import server code) — `providerGateways.parity.test.js` fails when the first two drift. Dependency-light: imports nothing. | @@ -381,6 +382,7 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and | `taskRequeue.js` | The REQUEUE stamp (#3376) — pure metadata helpers for the one BACKWARD lifecycle step (`in_progress → pending`, performed by the orphan sweep and the retry-hold release). `REQUEUED_AT_KEY` / `LAST_SPAWNED_AT_KEY` name the two stamps; `isPostSpawnRequeue(pendingTask, inProgressTask)` answers whether the requeue happened strictly AFTER that spawn, which is how the federated merge tells a real requeue apart from an ordinary edit landing on a peer's stale `pending` copy. Returns false when either stamp is missing, so callers fall back to the lifecycle rank. | | `taskRetryHold.js` | The failed-task RETRY HOLD state (#3373) — pure metadata helpers shared by the failure verdict, the post-cleanup release, the spawn guard, and the orphan sweep. `retryHoldMetadata(agentId, now)` arms it (task stays `in_progress`, so no dequeue tier can claim the retry before its resume pointer is resolved); `clearedRetryHoldMetadata()` releases it in the same write that flips the task to `pending`; `isRetryHeld(metadata)` / `isRetryHoldOwner(metadata, agentId)` gate the spawn and the owner-scoped release; `isStaleRetryHold(metadata, now, graceMs)` + `RETRY_HOLD_GRACE_MS` let the orphan sweep finish a transition whose process died. | | `taskTargetBranch.js` | Pure task-branch contract: `resolveTaskTargetBranch(metadata)` reads a retry's legacy `existingBranch` or a review-loop follow-up's canonical `reviewLoopPRBranch`; `shouldStripTaskTargetBranch(metadata)` identifies only retry-owned pointers for terminal cleanup. | +| `taskTargetScope.js` | Target scope for a scheduled task type: `INSTALL_WIDE_TASK_TYPES`, `MANAGED_APP_TARGET_TASK_TYPES`, `PROGRAMMATIC_SCHEDULED_TASK_TYPES`, `isProgrammaticScheduledTaskType`, `requiresManagedAppTarget`, `requiresInstallWideTarget`. Declared here (not in `services/taskScheduleRegistry.js`, which re-exports it) so `quotaBurnValidation.js` can reject a burn reference with the wrong target scope without importing upward into services. | | `taxonomyTally.js` | Generic taxonomy tally + top-N-line render engine shared by the two Layered Intelligence leaf taxonomies (`services/layeredIntelligenceRejections.js`, `layeredIntelligenceExecutionFailures.js`). `createTaxonomyTally({predicate, select, field, vocabulary, sentinel, glossFn, gapWording})` is the single composed seam — it binds a taxonomy config into `{summarize, format}`, where `summarize(records)` yields the three-bucket `{entries, unknown, unclassified, diagnosed, total}` tally (commonest-first + taxonomy-order tie-break) and `format(records, limit)` renders one prompt line naming every non-zero gap. Also exports the two leaf utilities the classifiers use directly: `normalizeToken(value)` (lowercase + separator-collapse for label/category matching) and `formatTaxonomyToken(token, labels)` (gloss-map render, nullish→'', unglossed passthrough). Pure leaf — imports nothing from the LI graph. | | `worktreeOwnership.js` | Pure ownership gate for destructive worktree operations. `worktreeOwnershipReason()` applies root, agent-id, lock, active-agent, liveness and claim policies IN THAT ORDER, so an unconditional hold always outranks a claim and callers never re-derive the precedence from the slug; the claim hold has two opt-outs (`allowStaleClaim` for reapers, `allowLiveClaim` for the dispatch side). `worktreeAgentId()` is separator-safe; `isHumanClaimWorktree()` and `isAgentWorktreeId()` make the protected namespaces explicit; `worktreeHoldExpiresAt()` names when a hold lapses on its own (the stale-claim window) so callers can wait exactly that long. | | `xmlEntities.js` | Shared dependency-free XML/HTML entity decoder. `decodeXmlEntities(str, extraEntities?)` — single-pass (double-decode-safe) decode of the five predefined named entities + decimal/hex numeric refs, with an optional caller-supplied extra-entity map (e.g. `{ nbsp: ' ', zwnj: '' }`). Unknown/out-of-range refs left untouched. Used by the Apple Health XML parser, Claude changelog feed, Pinterest RSS, generic feeds, and Gmail HTML-to-text. | diff --git a/server/lib/importScoping.test.js b/server/lib/importScoping.test.js index 01f44733b2..de2c8338bc 100644 --- a/server/lib/importScoping.test.js +++ b/server/lib/importScoping.test.js @@ -179,7 +179,16 @@ describe('deferred imports stay deferred (#6156)', () => { // Deferring catalog/version parsers removes 296 instantiations (88,360 → // 88,064). Restore the documented ~1.5k allowance for ordinary leaf growth; // keep the negative runtime-installer guard above so eager parsing cannot return. -const MAX_STATIC_INSTANTIATIONS = 89500; +// +// #6377 measures 89,889. Its share is ~389, and it is the tolerated shape, not +// the one this budget exists to catch: two new LEAF vocabulary modules with no +// subtree behind them — `lib/taskTargetScope.js` (four constants, zero imports) +// and `lib/quotaBurnTaskRef.js` (pure shape + resolver, importing only +// `objects.js`, which every reacher already had). They are reached by the ~200 +// suites that cross `lib/quotaBurnConfig.js`, so a leaf costs ~200 apiece with +// nothing to defer. Restore the ~1.5k allowance again rather than inching the +// number up by a few hundred per PR. +const MAX_STATIC_INSTANTIATIONS = 91400; const SKIP_DIRS = new Set(['node_modules', 'coverage', 'dist', 'data']); const serverTestFiles = (dir = SERVER_DIR, out = []) => { diff --git a/server/lib/index.js b/server/lib/index.js index da226fee86..b5128644a4 100644 --- a/server/lib/index.js +++ b/server/lib/index.js @@ -188,6 +188,7 @@ export * as reviewerConfig from './reviewerConfig.js'; export * from './quotaBurnConfig.js'; export * from './quotaBurnPresets.js'; export * from './auditCatalog.js'; +export * from './quotaBurnTaskRef.js'; export * from './quotaBurnValidation.js'; export * from './quotaReset.js'; export * from './quotaWindows.js'; @@ -350,6 +351,7 @@ export * from './taskBlockCategories.js'; export * from './taskRequeue.js'; export * from './taskRetryHold.js'; export * from './taskTargetBranch.js'; +export * from './taskTargetScope.js'; export * from './taxonomyTally.js'; export * from './worktreeOwnership.js'; export * from './xmlEntities.js'; diff --git a/server/lib/providerFamilies.js b/server/lib/providerFamilies.js index bcf2e3f79c..a6215fd8b1 100644 --- a/server/lib/providerFamilies.js +++ b/server/lib/providerFamilies.js @@ -25,21 +25,25 @@ export const PROVIDER_FAMILIES = [ { id: 'claude', label: 'Claude Code', + idPattern: /claude/i, matches: (p) => (p.type === 'cli' || p.type === 'tui') && isClaudeCommand(p.command) }, { id: 'codex', label: 'Codex', + idPattern: /codex/i, matches: (p) => commandBasename(p.command) === 'codex' }, { id: 'agy', label: 'Antigravity', + idPattern: /antigravity|(^|[-_])agy([-_]|$)/i, matches: (p) => commandBasename(p.command) === 'agy' || /antigravity/i.test(p.id || '') }, { id: 'grok', label: 'Grok', + idPattern: /grok/i, matches: (p) => isGrokCommand(p.command) || /grok/i.test(p.id || '') } ]; @@ -60,3 +64,21 @@ export function familyForProvider(provider) { if (!provider || provider.ollamaBacked === true || provider.lmstudioBacked === true || provider.mtplxBacked === true || provider.llamaBacked === true || provider.vllmBacked === true || provider.sglangBacked === true) return null; return PROVIDER_FAMILIES.find((f) => f.matches(provider))?.id ?? null; } + +/** + * The family a provider ID alone identifies, or `null` when the id says nothing. + * + * Strictly weaker than `familyForProvider`, and deliberately so: the BINARY is + * what really identifies a family, and a schema validating a stored config has + * only the id — the provider list is service state a Zod schema cannot reach. + * So this answers the one question that CAN be settled from an id, and answers + * `null` for everything else, letting the caller fall through to the live + * check. Used by `quotaBurnValidation.js` to reject a burn step pinned to + * another family's subscription before it reaches disk; a provider whose id + * names no family is accepted there and resolved at dispatch time instead. + */ +export function familyForProviderId(id) { + const value = typeof id === 'string' ? id.trim() : ''; + if (!value) return null; + return PROVIDER_FAMILIES.find((f) => f.idPattern.test(value))?.id ?? null; +} diff --git a/server/lib/quotaBurnConfig.js b/server/lib/quotaBurnConfig.js index 57d38c527f..b7333ddf71 100644 --- a/server/lib/quotaBurnConfig.js +++ b/server/lib/quotaBurnConfig.js @@ -22,7 +22,13 @@ * the wrong burn job spends real quota on work nobody asked for). */ -import { isPlainObject, POLLUTING_KEYS } from './objects.js'; +import { isPlainObject } from './objects.js'; +import { + QUOTA_BURN_UNAVAILABLE, + normalizeQuotaBurnOverrides, + normalizeQuotaBurnParams, + normalizeQuotaBurnTaskRef, +} from './quotaBurnTaskRef.js'; import { BIBLE_DESCRIBE_DEPTHS, BIBLE_DESCRIBE_SCOPES } from './universeBibleCompleteness.js'; /** Provider quota families a burn plan may target. Mirrors `providerUsage.js`'s card ids. */ @@ -111,9 +117,18 @@ const BOUNDS = QUOTA_BURN_BOUNDS; export const isUnlimitedDispatchCap = (cap) => Number(cap) < 0; /** - * Burn job types. `agent-prompt` is the original behavior (spawn a CoS agent in - * a managed app with a custom prompt); everything else is a PROGRAMMATIC job - * that PortOS performs itself with no agent in the loop. + * LEGACY burn job types — a COMPATIBILITY INPUT, frozen. + * + * A step's work is now named by a scheduled-task reference + * (`quotaBurnTaskRef.js`), not by a quota-only job type. This enum, the catalog + * below, and `quotaBurnPresets.js` stay readable so a plan written before the + * reference model still loads and migration (#6381) can convert it — they are no + * longer the canonical definition of new work, and nothing new belongs in them. + * A new burn action ships as an on-demand SCHEDULED task and is referenced. + * + * `agent-prompt` was the original behavior (spawn a CoS agent in a managed app + * with a copied prompt); the other two are PROGRAMMATIC jobs PortOS performs + * itself with no agent in the loop. */ export const QUOTA_BURN_JOB_TYPE = Object.freeze({ AGENT_PROMPT: 'agent-prompt', @@ -123,7 +138,9 @@ export const QUOTA_BURN_JOB_TYPE = Object.freeze({ export const QUOTA_BURN_JOB_TYPES = Object.freeze(Object.values(QUOTA_BURN_JOB_TYPE)); /** - * Catalog rendered by the config page so the job picker can describe each type + * LEGACY catalog, frozen alongside `QUOTA_BURN_JOB_TYPE` above. + * + * Rendered by the config page so the job picker can describe each type * without the client re-encoding what a job does. `params` names the per-job * fields the runner reads — the client builds its form from this list, so a new * job type needs no client change beyond a field renderer for a novel kind. @@ -224,49 +241,97 @@ const nullableString = (value, max) => { }; /** - * A job's `params` bag stays free-form on purpose — each job type owns its own - * contract, and the runner's registry validates the keys it actually reads. - * What is enforced here is that it's a plain object of JSON-ish scalars, so a - * hand-edited config file can't smuggle a prototype or a nested blob into it. + * A step's run `params` stay free-form on purpose — each task type owns its own + * contract, and the handler validates the keys it actually reads. What is + * enforced is that it's a plain object of JSON-ish scalars, so a hand-edited + * config file can't smuggle a prototype or a nested blob into it. The rule + * itself lives in `quotaBurnTaskRef.js` so the overrides bag applies the same + * one; the BOUND stays here, with every other bound. */ -function normalizeParams(raw) { - if (!isPlainObject(raw)) return {}; - const clean = {}; - for (const [key, value] of Object.entries(raw)) { - if (POLLUTING_KEYS.has(key)) continue; - if (typeof value === 'string') clean[key] = value.slice(0, BOUNDS.paramLength.max); - else if (typeof value === 'number' && Number.isFinite(value)) clean[key] = value; - else if (typeof value === 'boolean' || value === null) clean[key] = value; - } - return clean; -} +const normalizeParams = (raw) => normalizeQuotaBurnParams(raw, BOUNDS.paramLength.max); /** - * Normalize one burn job. Returns `null` for a job whose type is unknown — the - * caller DROPS it rather than substituting a default type: a job that runs the - * wrong work spends real subscription quota on something the user never asked - * for, which is strictly worse than the job disappearing from the list. + * Normalize one burn STEP. + * + * A step names its work in one of two ways, and normalization keeps them + * strictly apart rather than translating between them: + * + * - a `taskRef` — the current model: a reference to a scheduled task the user + * already owns, plus per-invocation `overrides`; + * - a legacy `jobType` — a copied prompt in a free-form params bag. It still + * LOADS (an install upgrading across the reference model must not lose its + * plan), but it normalizes to unavailable with a migration reason. It is + * never rewritten into a reference here: guessing which scheduled task a + * hand-edited prompt meant would either strand the user's edits or duplicate + * an automation, so #6381's conversion service owns that, once. * - * `index` seeds a stable id for a job written before ids existed (or by a + * Returns `null` for a payload that names NEITHER — the caller drops it rather + * than substituting a default: a step that runs the wrong work spends real + * subscription quota on something the user never asked for, which is strictly + * worse than the step disappearing from the list. + * + * `index` seeds a stable id for a step written before ids existed (or by a * hand-edited file), so the client's list keys and the run ledger have * something to key on. + * + * **Overrides and their compat mirrors.** `overrides` is the canonical home for + * provider / model / effort / params, but the shipped editor still reads and + * writes them at the TOP level (it moves to `overrides` in #6382), so both are + * emitted and kept in lockstep here. The tie-break is presence, not truthiness: + * a payload that CARRIES a top-level key wins with it — including when the user + * just cleared it to `null` — and only a payload with no top-level key at all + * falls through to `overrides`. Reading truthiness instead would resurrect the + * stale override every time the editor cleared a pinned model. */ export function normalizeQuotaBurnJob(raw, index = 0) { if (!isPlainObject(raw)) return null; + const taskRef = normalizeQuotaBurnTaskRef(raw.taskRef); const jobType = typeof raw.jobType === 'string' ? raw.jobType : ''; - if (!QUOTA_BURN_JOB_TYPES.includes(jobType)) return null; + const legacyType = QUOTA_BURN_JOB_TYPES.includes(jobType) ? jobType : null; + if (!taskRef && !legacyType) return null; + + const stored = normalizeQuotaBurnOverrides(raw.overrides, { + maxParamLength: BOUNDS.paramLength.max, + maxFieldLength: BOUNDS.labelLength.max, + }); + const override = (key) => (Object.hasOwn(raw, key) + ? nullableString(raw[key], BOUNDS.labelLength.max) + : stored[key]); + const overrides = { + providerId: override('providerId'), + model: override('model'), + effort: override('effort'), + params: Object.hasOwn(raw, 'params') ? normalizeParams(raw.params) : stored.params, + }; + return { id: trimString(raw.id, BOUNDS.idLength.max) || `job-${index + 1}`, enabled: raw.enabled !== false, label: trimString(raw.label, BOUNDS.labelLength.max), - jobType, - model: nullableString(raw.model, BOUNDS.labelLength.max), - providerId: nullableString(raw.providerId, BOUNDS.labelLength.max), - effort: nullableString(raw.effort, BOUNDS.labelLength.max), + taskRef, + // Only a legacy step keeps a `jobType`; a reference step reports null so no + // reader can mistake a stale field for the step's identity. + jobType: taskRef ? null : legacyType, + overrides, + // Derived, never trusted from the payload: what the CATALOG says right now + // is stamped by `resolveQuotaBurnStepAvailability`, which normalization (a + // pure function with no store access) cannot ask. The one verdict decidable + // from the payload alone is the un-migrated legacy step. + unavailable: taskRef ? null : { + code: QUOTA_BURN_UNAVAILABLE.LEGACY_UNMIGRATED, + reason: `legacy "${legacyType}" step is waiting to be migrated to a scheduled task`, + }, // Opt-IN, and absent reads as `false`, so every plan written before this // field existed keeps repeating exactly as it did. See `jobIsSpent`. runOnce: raw.runOnce === true, - params: normalizeParams(raw.params), + // Compat mirrors of `overrides` for the shipped editor and the legacy + // executor. Derived on every read (normalization is total), so they cannot + // drift from the canonical bag the way two independently-written fields + // would. Removed with the editor rewrite in #6382. + model: overrides.model, + providerId: overrides.providerId, + effort: overrides.effort, + params: overrides.params, }; } diff --git a/server/lib/quotaBurnConfig.test.js b/server/lib/quotaBurnConfig.test.js index 388eae7d50..2e7977b320 100644 --- a/server/lib/quotaBurnConfig.test.js +++ b/server/lib/quotaBurnConfig.test.js @@ -12,6 +12,7 @@ import { normalizeQuotaBurnJob, quotaBurnJobKey, } from './quotaBurnConfig.js'; +import { QUOTA_BURN_UNAVAILABLE, quotaBurnStepIsDispatchable } from './quotaBurnTaskRef.js'; describe('normalizeQuotaBurnConfig', () => { it('materializes every family so absent is never confused with off', () => { @@ -102,6 +103,80 @@ describe('normalizeQuotaBurnJob', () => { }); }); +describe('normalizeQuotaBurnJob — scheduled-task references', () => { + const ref = { kind: 'builtin', taskType: 'ux', appId: 'app-1' }; + + it('round-trips a reference step through save and reload unchanged', () => { + // The whole point of the reference model: identity survives a reload, so a + // step stays the step the user configured — no prompt-text re-derivation. + const stored = { + enabled: true, + jobs: [{ + id: 'step-1', enabled: false, label: 'Nightly UX sweep', runOnce: true, + taskRef: ref, + overrides: { providerId: 'claude-code-tui', model: 'opus', effort: 'high', params: { fileIssues: true, maxEntries: 3 } }, + }], + }; + const once = normalizeQuotaBurnConfig({ families: { claude: stored } }).families.claude; + const twice = normalizeQuotaBurnConfig({ families: { claude: once } }).families.claude; + expect(twice).toEqual(once); + expect(once.jobs[0]).toMatchObject({ + id: 'step-1', enabled: false, label: 'Nightly UX sweep', runOnce: true, jobType: null, unavailable: null, + taskRef: ref, + overrides: { providerId: 'claude-code-tui', model: 'opus', effort: 'high', params: { fileIssues: true, maxEntries: 3 } }, + }); + }); + + it('drops a payload that names neither a reference nor a known legacy type', () => { + expect(normalizeQuotaBurnJob({ label: 'orphan' })).toBeNull(); + expect(normalizeQuotaBurnJob({ taskRef: { kind: 'nonsense', taskType: 'ux' } })).toBeNull(); + }); + + it('mirrors the overrides bag onto the fields the shipped editor still writes', () => { + const job = normalizeQuotaBurnJob({ taskRef: ref, overrides: { model: 'opus', effort: 'high', params: { a: 1 } } }); + expect(job).toMatchObject({ model: 'opus', effort: 'high', params: { a: 1 } }); + }); + + it('lets a top-level clear win over a stale override rather than resurrecting it', () => { + // The editor spreads the whole step and edits the top-level field, so a + // truthiness test would put the old pin back every time the user cleared it. + const job = normalizeQuotaBurnJob({ + taskRef: ref, model: null, providerId: null, effort: null, params: {}, + overrides: { model: 'opus', providerId: 'claude-code-tui', effort: 'high', params: { stale: true } }, + }); + expect(job.overrides).toEqual({ model: null, providerId: null, effort: null, params: {} }); + expect(job.model).toBeNull(); + }); +}); + +describe('normalizeQuotaBurnJob — legacy compatibility', () => { + it('loads an un-migrated legacy step but marks it for migration', () => { + const job = normalizeQuotaBurnJob({ + id: 'legacy-1', label: 'UX audit', runOnce: true, jobType: 'agent-prompt', + model: 'opus', providerId: 'claude-code-tui', effort: 'high', + params: { appId: 'app-1', prompt: 'audit the UX' }, + }); + // Every stored setting survives — a migration that loses the user's prompt + // or their one-shot state is worse than one that has not run yet. + expect(job).toMatchObject({ + id: 'legacy-1', label: 'UX audit', runOnce: true, jobType: 'agent-prompt', taskRef: null, + overrides: { model: 'opus', providerId: 'claude-code-tui', effort: 'high', params: { appId: 'app-1', prompt: 'audit the UX' } }, + }); + expect(job.unavailable.code).toBe(QUOTA_BURN_UNAVAILABLE.LEGACY_UNMIGRATED); + expect(quotaBurnStepIsDispatchable(job)).toBe(false); + }); + + it('never rewrites a legacy step into a reference, and never duplicates it', () => { + const family = normalizeQuotaBurnConfig({ + families: { grok: { jobs: [{ id: 'legacy-1', jobType: 'agent-prompt', params: { appId: 'app-1', prompt: 'x' } }] } }, + }).families.grok; + expect(family.jobs).toHaveLength(1); + expect(family.jobs[0].taskRef).toBeNull(); + // Re-normalizing is idempotent: a repeated load cannot mint a second step. + expect(normalizeQuotaBurnConfig({ families: { grok: family } }).families.grok).toEqual(family); + }); +}); + describe('jobIsSpent', () => { const ran = { 'grok:j1': '2026-08-01T00:00:00.000Z' }; diff --git a/server/lib/quotaBurnTaskRef.js b/server/lib/quotaBurnTaskRef.js new file mode 100644 index 0000000000..de55d8c46d --- /dev/null +++ b/server/lib/quotaBurnTaskRef.js @@ -0,0 +1,220 @@ +/** + * Quota-burn step → scheduled-task REFERENCE, plus its per-invocation overrides. + * + * A burn step used to BE its work: a copied prompt in a free-form `params` bag + * keyed by a `jobType` from a quota-only enum, with no durable link back to the + * scheduled task it was cloned from. Preset identity was never persisted at all + * — both `quotaBurnPresets.js` and the client's `matchedPreset` re-derived it by + * comparing prompt text, so editing one word of a shipped prompt orphaned the + * step. This module replaces that with a reference the step actually stores. + * + * Two reference kinds, discriminated on `kind`: + * - `builtin` — a PortOS scheduled task TYPE (`ux`, `security`, …), plus the + * managed app it targets when the type requires one. Scope rules come from + * `taskTargetScope.js`, the same contract the schedule's own request gate + * reads. + * - `custom` — an app custom scheduled job, addressed by its id. The job + * record owns its app scope, so the reference deliberately stores no + * `appId`: a second copy could disagree with the job it points at. + * + * Everything the burn wants to do DIFFERENTLY from the task's saved settings + * lives in `overrides` (provider / model / effort / run params). An unset + * override inherits; nothing here ever writes back to the schedule. + * + * Pure: shape, normalization, and a resolver that takes the catalog as an + * argument. No storage, no provider I/O, no imports out of `lib/`. + */ + +import { isPlainObject, POLLUTING_KEYS } from './objects.js'; +import { requiresInstallWideTarget, requiresManagedAppTarget } from './taskTargetScope.js'; + +/** The two things a burn step may point at. */ +export const QUOTA_BURN_TASK_REF_KIND = Object.freeze({ + BUILTIN: 'builtin', + CUSTOM: 'custom', +}); + +/** + * Why a step is retained but not dispatchable. A code so the client can react + * (offer "pick another task", "re-enable it in Scheduled Tasks") and a + * human-readable `reason` so the row can say it without a lookup table. + * + * A step is never DELETED for being unavailable — its label, order, overrides + * and run-once state are the user's, and a task that comes back should find its + * step exactly as it left it. + */ +export const QUOTA_BURN_UNAVAILABLE = Object.freeze({ + /** A legacy `jobType` payload that migration has not converted to a reference yet. */ + LEGACY_UNMIGRATED: 'legacy-unmigrated', + /** A `builtin` reference naming a task type this install does not ship. */ + UNKNOWN_TASK: 'unknown-task', + /** A `custom` reference whose job id no longer exists. */ + DANGLING_JOB: 'dangling-job', + /** The referenced task/job exists but is switched off. */ + DISABLED: 'disabled', + /** A type that requires a managed app, with none named. */ + MISSING_APP: 'missing-app', + /** An app named that the type cannot target (install-wide type, or an app that is gone). */ + WRONG_SCOPE: 'wrong-scope', + /** The task exists and is enabled, but is not something a burn may invoke. */ + INCOMPATIBLE: 'incompatible', +}); + +const MAX_REF_FIELD = 64; + +const trimmed = (value, max) => (typeof value === 'string' ? value.trim().slice(0, max) : ''); +const nullable = (value, max) => trimmed(value, max) || null; + +/** + * Normalize a stored/submitted task reference. Returns `null` when the payload + * carries no usable reference — the caller then decides whether that is a + * legacy step (keep it, mark it for migration) or nothing at all. + * + * Never GUESSES a kind: a payload missing `kind`, or naming one that is not in + * the enum, is not a reference. Inferring one from the presence of `taskType` + * would be exactly the "silently downgrade a reference" failure the reference + * model exists to remove. + */ +export function normalizeQuotaBurnTaskRef(raw) { + if (!isPlainObject(raw)) return null; + if (raw.kind === QUOTA_BURN_TASK_REF_KIND.BUILTIN) { + const taskType = trimmed(raw.taskType, MAX_REF_FIELD); + if (!taskType) return null; + return { kind: QUOTA_BURN_TASK_REF_KIND.BUILTIN, taskType, appId: nullable(raw.appId, MAX_REF_FIELD) }; + } + if (raw.kind === QUOTA_BURN_TASK_REF_KIND.CUSTOM) { + const jobId = trimmed(raw.jobId, MAX_REF_FIELD); + if (!jobId) return null; + return { kind: QUOTA_BURN_TASK_REF_KIND.CUSTOM, jobId }; + } + return null; +} + +/** + * A step's per-invocation overrides bag, normalized to JSON-ish scalars. + * + * `params` stays free-form for the same reason the legacy bag did — each task + * type owns which run parameters it reads — but its DEPTH is enforced, so a + * hand-edited config cannot smuggle a prototype or a nested blob through. + * + * The two length caps are REQUIRED, and injected rather than imported: this + * module has to stay free of `quotaBurnConfig.js` (which imports it, not the + * other way round), and restating its numbers as defaults here would be a + * second bounds table — exactly what `QUOTA_BURN_BOUNDS` exists to prevent. + */ +export function normalizeQuotaBurnOverrides(raw, { maxParamLength, maxFieldLength }) { + const value = isPlainObject(raw) ? raw : {}; + return { + providerId: nullable(value.providerId, maxFieldLength), + model: nullable(value.model, maxFieldLength), + effort: nullable(value.effort, maxFieldLength), + params: normalizeQuotaBurnParams(value.params, maxParamLength), + }; +} + +/** + * The scalar-only `params` map shared by the overrides bag and the legacy job + * bag. `maxParamLength` comes from `QUOTA_BURN_BOUNDS`, per the note above. + */ +export function normalizeQuotaBurnParams(raw, maxParamLength) { + if (!isPlainObject(raw)) return {}; + const clean = {}; + for (const [key, value] of Object.entries(raw)) { + if (POLLUTING_KEYS.has(key)) continue; + if (typeof value === 'string') clean[key] = value.slice(0, maxParamLength); + else if (typeof value === 'number' && Number.isFinite(value)) clean[key] = value; + else if (typeof value === 'boolean' || value === null) clean[key] = value; + } + return clean; +} + +const unavailable = (code, reason) => ({ code, reason }); + +/** + * Resolve one normalized step against the live task catalog and return its + * `{ code, reason }` unavailability, or `null` when the step is good to run. + * + * The catalog is an ARGUMENT, not an import: resolution needs the schedule + * store and the per-app job store, which are services, and this module must + * stay in `lib/`. Callers pass plain lookup maps: + * + * builtin: { [taskType]: { enabled, eligible, appIds } } + * `appIds` — the managed apps this type may target, or `null`/absent for + * "any app on the install". `eligible` — false for a type a burn may not + * invoke (shell jobs, system-managed-only actions); absent reads as true. + * custom: { [jobId]: { enabled, eligible, appId } } + * + * An EMPTY catalog answers only the questions that need one. Existence, enabled + * state and eligibility all go unjudged — with no `builtin`/`custom` map the + * step keeps whatever the normalizer already decided rather than being declared + * dangling, so a caller reading the plan before the schedule store is up cannot + * mass-orphan a user's plan. Target SCOPE is the exception, and deliberately so: + * it is a property of the reference itself, decidable from the payload alone, so + * it is judged either way. + */ +export function resolveQuotaBurnStepAvailability(step, catalog = {}) { + // A legacy step's verdict is decided by the payload itself, not by the + // catalog — the reference it will eventually point at does not exist yet. + if (step?.unavailable?.code === QUOTA_BURN_UNAVAILABLE.LEGACY_UNMIGRATED) return step.unavailable; + const ref = step?.taskRef; + if (!ref) return step?.unavailable || null; + + if (ref.kind === QUOTA_BURN_TASK_REF_KIND.CUSTOM) { + const jobs = isPlainObject(catalog.custom) ? catalog.custom : null; + if (!jobs) return null; + const job = Object.hasOwn(jobs, ref.jobId) ? jobs[ref.jobId] : null; + if (!job) return unavailable(QUOTA_BURN_UNAVAILABLE.DANGLING_JOB, `custom scheduled job "${ref.jobId}" no longer exists`); + if (job.eligible === false) return unavailable(QUOTA_BURN_UNAVAILABLE.INCOMPATIBLE, `custom scheduled job "${ref.jobId}" cannot be invoked by a quota burn`); + if (job.enabled === false) return unavailable(QUOTA_BURN_UNAVAILABLE.DISABLED, `custom scheduled job "${ref.jobId}" is disabled`); + return null; + } + + // Scope runs BEFORE the catalog lookup so a hand-edited config that dropped a + // required app reports the missing app rather than a confusing "unknown task" + // — the type is fine, the target is not — and so it still reports at all when + // the caller has no catalog to hand. + if (requiresManagedAppTarget(ref.taskType) && !ref.appId) { + return unavailable(QUOTA_BURN_UNAVAILABLE.MISSING_APP, `scheduled task "${ref.taskType}" must name a managed app`); + } + if (requiresInstallWideTarget(ref.taskType) && ref.appId) { + return unavailable(QUOTA_BURN_UNAVAILABLE.WRONG_SCOPE, `scheduled task "${ref.taskType}" runs install-wide and cannot target one app`); + } + + const types = isPlainObject(catalog.builtin) ? catalog.builtin : null; + if (!types) return null; + const entry = Object.hasOwn(types, ref.taskType) ? types[ref.taskType] : null; + if (!entry) return unavailable(QUOTA_BURN_UNAVAILABLE.UNKNOWN_TASK, `scheduled task "${ref.taskType}" is not available on this install`); + if (entry.eligible === false) return unavailable(QUOTA_BURN_UNAVAILABLE.INCOMPATIBLE, `scheduled task "${ref.taskType}" cannot be invoked by a quota burn`); + if (entry.enabled === false) return unavailable(QUOTA_BURN_UNAVAILABLE.DISABLED, `scheduled task "${ref.taskType}" is disabled`); + if (ref.appId && Array.isArray(entry.appIds) && !entry.appIds.includes(ref.appId)) { + return unavailable(QUOTA_BURN_UNAVAILABLE.WRONG_SCOPE, `scheduled task "${ref.taskType}" is not configured for app "${ref.appId}"`); + } + return null; +} + +/** + * The same resolution applied across a whole normalized config, returning a new + * config. Non-mutating, so a status read can stamp availability for the page + * without the derived verdict ever reaching disk — it is a fact about the + * catalog RIGHT NOW, and persisting it would go stale the moment a task is + * re-enabled. + */ +export function applyQuotaBurnAvailability(config, catalog = {}) { + const families = Object.fromEntries(Object.entries(config?.families || {}).map(([id, family]) => [ + id, + { ...family, jobs: (family?.jobs || []).map((job) => ({ ...job, unavailable: resolveQuotaBurnStepAvailability(job, catalog) })) }, + ])); + return { ...config, families }; +} + +/** + * Whether a step may be dispatched through the reference path. + * + * Three independent gates, deliberately spelled out rather than folded into + * `enabled`: the user switched it off, the catalog says it cannot run, or it + * has no reference to run at all (an un-migrated legacy step — which the legacy + * `JOB_MODULES` executor still handles until #6381 retires it). + */ +export function quotaBurnStepIsDispatchable(step) { + return step?.enabled !== false && !step?.unavailable && Boolean(step?.taskRef); +} diff --git a/server/lib/quotaBurnTaskRef.test.js b/server/lib/quotaBurnTaskRef.test.js new file mode 100644 index 0000000000..283b0fa31b --- /dev/null +++ b/server/lib/quotaBurnTaskRef.test.js @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { + QUOTA_BURN_TASK_REF_KIND, + QUOTA_BURN_UNAVAILABLE, + applyQuotaBurnAvailability, + normalizeQuotaBurnTaskRef, + quotaBurnStepIsDispatchable, + resolveQuotaBurnStepAvailability, +} from './quotaBurnTaskRef.js'; +import { normalizeQuotaBurnConfig } from './quotaBurnConfig.js'; + +const builtin = (taskType, appId = null) => ({ kind: 'builtin', taskType, appId }); + +describe('normalizeQuotaBurnTaskRef', () => { + it('never infers a kind from the fields that happen to be present', () => { + // Guessing is the failure this model exists to remove: a payload that names + // a task type but no kind is an unconverted legacy step, not a reference. + expect(normalizeQuotaBurnTaskRef({ taskType: 'ux', appId: 'app-1' })).toBeNull(); + expect(normalizeQuotaBurnTaskRef({ kind: 'scheduled', taskType: 'ux' })).toBeNull(); + expect(normalizeQuotaBurnTaskRef({ kind: 'builtin' })).toBeNull(); + expect(normalizeQuotaBurnTaskRef({ kind: 'custom', jobId: ' ' })).toBeNull(); + }); + + it('keeps a custom reference free of an app scope the job itself owns', () => { + expect(normalizeQuotaBurnTaskRef({ kind: 'custom', jobId: 'job-7', appId: 'app-1' })) + .toEqual({ kind: QUOTA_BURN_TASK_REF_KIND.CUSTOM, jobId: 'job-7' }); + }); +}); + +describe('resolveQuotaBurnStepAvailability', () => { + const catalog = { + builtin: { + ux: { enabled: true }, + security: { enabled: false }, + 'stash-cleanup': { enabled: true, eligible: false }, + 'pr-reviewer': { enabled: true, appIds: ['app-1'] }, + 'model-comparison-refresh': { enabled: true }, + }, + custom: { + 'job-1': { enabled: true, appId: 'app-1' }, + 'job-2': { enabled: false, appId: 'app-1' }, + }, + }; + const step = (taskRef) => ({ id: 's1', enabled: true, taskRef, unavailable: null }); + + it('clears a healthy reference of either kind', () => { + expect(resolveQuotaBurnStepAvailability(step(builtin('ux')), catalog)).toBeNull(); + expect(resolveQuotaBurnStepAvailability(step({ kind: 'custom', jobId: 'job-1' }), catalog)).toBeNull(); + }); + + it('names why a step cannot run, one code per distinct cause', () => { + const code = (ref) => resolveQuotaBurnStepAvailability(step(ref), catalog)?.code; + expect(code(builtin('does-not-exist'))).toBe(QUOTA_BURN_UNAVAILABLE.UNKNOWN_TASK); + expect(code(builtin('security'))).toBe(QUOTA_BURN_UNAVAILABLE.DISABLED); + expect(code(builtin('stash-cleanup'))).toBe(QUOTA_BURN_UNAVAILABLE.INCOMPATIBLE); + expect(code(builtin('pr-reviewer'))).toBe(QUOTA_BURN_UNAVAILABLE.MISSING_APP); + expect(code(builtin('pr-reviewer', 'app-9'))).toBe(QUOTA_BURN_UNAVAILABLE.WRONG_SCOPE); + expect(code(builtin('model-comparison-refresh', 'app-1'))).toBe(QUOTA_BURN_UNAVAILABLE.WRONG_SCOPE); + expect(code({ kind: 'custom', jobId: 'job-gone' })).toBe(QUOTA_BURN_UNAVAILABLE.DANGLING_JOB); + expect(code({ kind: 'custom', jobId: 'job-2' })).toBe(QUOTA_BURN_UNAVAILABLE.DISABLED); + }); + + it('withholds every catalog-dependent verdict when the catalog is missing', () => { + // A caller reading the plan before the schedule store is up must not + // declare every step dangling and wipe the page's plan. + expect(resolveQuotaBurnStepAvailability(step(builtin('ux')), {})).toBeNull(); + expect(resolveQuotaBurnStepAvailability(step({ kind: 'custom', jobId: 'job-1' }), {})).toBeNull(); + // Scope is the exception: it is a property of the reference itself, so a + // hand-edited step that dropped its required app is broken with or without + // a catalog and still says so. + expect(resolveQuotaBurnStepAvailability(step(builtin('pr-reviewer')), {})?.code) + .toBe(QUOTA_BURN_UNAVAILABLE.MISSING_APP); + }); + + it('leaves an un-migrated legacy step on its migration reason', () => { + const legacy = { id: 's1', enabled: true, taskRef: null, unavailable: { code: QUOTA_BURN_UNAVAILABLE.LEGACY_UNMIGRATED, reason: 'waiting' } }; + expect(resolveQuotaBurnStepAvailability(legacy, catalog)).toEqual(legacy.unavailable); + }); + + it('stamps a whole config without mutating it or reaching disk', () => { + const config = normalizeQuotaBurnConfig({ + families: { claude: { enabled: true, jobs: [{ id: 'gone', taskRef: builtin('does-not-exist'), label: 'Nightly sweep', runOnce: true }] } }, + }); + const stamped = applyQuotaBurnAvailability(config, catalog); + expect(stamped.families.claude.jobs[0]).toMatchObject({ + id: 'gone', label: 'Nightly sweep', runOnce: true, + unavailable: { code: QUOTA_BURN_UNAVAILABLE.UNKNOWN_TASK }, + }); + expect(config.families.claude.jobs[0].unavailable).toBeNull(); + expect(quotaBurnStepIsDispatchable(stamped.families.claude.jobs[0])).toBe(false); + }); +}); + +describe('quotaBurnStepIsDispatchable', () => { + it('requires a reference, an unavailability-free verdict, and the user switch', () => { + const ok = { enabled: true, taskRef: builtin('ux'), unavailable: null }; + expect(quotaBurnStepIsDispatchable(ok)).toBe(true); + expect(quotaBurnStepIsDispatchable({ ...ok, enabled: false })).toBe(false); + expect(quotaBurnStepIsDispatchable({ ...ok, unavailable: { code: 'disabled' } })).toBe(false); + expect(quotaBurnStepIsDispatchable({ ...ok, taskRef: null })).toBe(false); + }); +}); diff --git a/server/lib/quotaBurnValidation.js b/server/lib/quotaBurnValidation.js index 0f0f25d732..c2fb87aed0 100644 --- a/server/lib/quotaBurnValidation.js +++ b/server/lib/quotaBurnValidation.js @@ -14,26 +14,86 @@ */ import { z } from 'zod'; +import { familyForProviderId } from './providerFamilies.js'; import { QUOTA_BURN_BOUNDS, QUOTA_BURN_FAMILIES, QUOTA_BURN_JOB_TYPES, QUOTA_BURN_UNLIMITED_DISPATCHES, } from './quotaBurnConfig.js'; +import { QUOTA_BURN_TASK_REF_KIND } from './quotaBurnTaskRef.js'; +import { requiresInstallWideTarget, requiresManagedAppTarget } from './taskTargetScope.js'; const B = QUOTA_BURN_BOUNDS; -// A job's params bag is per-job-type, so it stays a flat scalar map here — the -// job module owns which keys it reads. Depth is what's rejected: a nested blob -// in a config file is either a mistake or an attempt to smuggle state past the -// normalizer. +// A step's run params are per-task-type, so they stay a flat scalar map here — +// the task handler owns which keys it reads. Depth is what's rejected: a nested +// blob in a config file is either a mistake or an attempt to smuggle state past +// the normalizer. const paramValueSchema = z.union([z.string().max(B.paramLength.max), z.number().finite(), z.boolean(), z.null()]); +const refFieldSchema = z.string().min(1).max(B.idLength.max); + +/** + * What a burn step points at. Discriminated on `kind` rather than sniffed from + * which field is present, so an ambiguous payload is a 400 instead of a guess. + * + * A `custom` reference stores NO `appId` — the job record owns its app scope, + * and a second copy here could disagree with the job it points at. + */ +const quotaBurnTaskRefSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal(QUOTA_BURN_TASK_REF_KIND.BUILTIN), + taskType: refFieldSchema, + appId: z.string().max(B.idLength.max).nullable().optional(), + }).strict(), + z.object({ + kind: z.literal(QUOTA_BURN_TASK_REF_KIND.CUSTOM), + jobId: refFieldSchema, + }).strict(), +]).superRefine((ref, ctx) => { + if (ref.kind !== QUOTA_BURN_TASK_REF_KIND.BUILTIN) return; + if (requiresManagedAppTarget(ref.taskType) && !ref.appId) { + ctx.addIssue({ code: 'custom', path: ['appId'], message: `scheduled task "${ref.taskType}" must name a target app` }); + } + if (requiresInstallWideTarget(ref.taskType) && ref.appId) { + ctx.addIssue({ code: 'custom', path: ['appId'], message: `scheduled task "${ref.taskType}" runs install-wide and cannot target one app` }); + } +}); + +/** + * Per-invocation overrides. Everything is optional — an unset key INHERITS the + * referenced task's saved setting, and nothing written here ever edits that + * task. Provider pins are additionally checked against the step's own family in + * `familySchemaFor` below, which is the only place the family id is known. + */ +const quotaBurnOverridesSchema = z.object({ + providerId: z.string().max(B.labelLength.max).nullable().optional(), + model: z.string().max(B.labelLength.max).nullable().optional(), + effort: z.string().max(B.labelLength.max).nullable().optional(), + params: z.record(paramValueSchema).optional(), +}).strict(); + +// Derived by the normalizer from the live task catalog, never authored. Accepted +// (and ignored) because the page round-trips whole step objects: rejecting a +// field the GET just handed the client would 400 every save. +const quotaBurnUnavailableSchema = z.object({ + code: z.string().max(B.idLength.max), + reason: z.string().max(B.labelLength.max * 2).optional(), +}).strict().nullable().optional(); + const quotaBurnJobSchema = z.object({ id: z.string().max(B.idLength.max).optional(), enabled: z.boolean().optional(), label: z.string().max(B.labelLength.max).optional(), - jobType: z.enum(QUOTA_BURN_JOB_TYPES), + taskRef: quotaBurnTaskRefSchema.nullable().optional(), + // LEGACY, and a compatibility input only: a plan written before the reference + // model still saves, and the normalizer marks it for migration rather than + // guessing which scheduled task its copied prompt meant. + jobType: z.enum(QUOTA_BURN_JOB_TYPES).nullable().optional(), + overrides: quotaBurnOverridesSchema.optional(), + unavailable: quotaBurnUnavailableSchema, + // Compat mirrors of the overrides bag, still written by the shipped editor. model: z.string().max(B.labelLength.max).nullable().optional(), providerId: z.string().max(B.labelLength.max).nullable().optional(), effort: z.string().max(B.labelLength.max).nullable().optional(), @@ -42,7 +102,31 @@ const quotaBurnJobSchema = z.object({ // written before this field keep repeating. runOnce: z.boolean().optional(), params: z.record(paramValueSchema).optional(), -}).strict(); +}).strict().superRefine((job, ctx) => { + // Exactly one identity. Both would leave the normalizer choosing between a + // reference and a copied prompt; neither is a step with no work at all. + if (job.taskRef && job.jobType) { + ctx.addIssue({ code: 'custom', path: ['jobType'], message: 'a step referencing a scheduled task must not also carry a legacy jobType' }); + } + if (!job.taskRef && !job.jobType) { + ctx.addIssue({ code: 'custom', path: ['taskRef'], message: 'a step must reference a scheduled task' }); + } +}); + +/** + * Reject a provider pin that belongs to a DIFFERENT quota family than the plan + * it sits in — burning the codex window from the claude plan is never what the + * user meant, and the runner would happily spend it. + * + * Only ids that unambiguously name another family are rejected + * (`familyForProviderId` answers null for anything else): the binary is what + * really decides a family, and a schema cannot read the provider list. A pin + * that survives here is still resolved within the family at dispatch time. + */ +const pinnedOutOfFamily = (familyId, pin) => { + const pinned = familyForProviderId(pin); + return pinned && pinned !== familyId ? pinned : null; +}; const quotaBurnFamilySchema = z.object({ enabled: z.boolean().optional(), @@ -61,13 +145,32 @@ const quotaBurnFamilySchema = z.object({ jobs: z.array(quotaBurnJobSchema).max(B.jobsPerFamily.max).optional(), }).strict(); +/** + * The family schema bound to ONE family id, so a step's provider pin can be + * checked against the subscription it is actually spending. The id only exists + * as the key in `families`, so this is the innermost scope that knows it. + */ +const familySchemaFor = (familyId) => quotaBurnFamilySchema.superRefine((family, ctx) => { + (family.jobs || []).forEach((job, index) => { + for (const [path, pin] of [ + [['jobs', index, 'providerId'], job.providerId], + [['jobs', index, 'overrides', 'providerId'], job.overrides?.providerId], + ]) { + const other = pinnedOutOfFamily(familyId, pin); + if (other) { + ctx.addIssue({ code: 'custom', path, message: `provider "${pin}" belongs to the ${other} family, not ${familyId}` }); + } + } + }); +}); + // Spelled out per family rather than z.record so an unknown family key is a 400 // (a typo'd card id would otherwise round-trip and silently never burn). export const quotaBurnConfigUpdateSchema = z.object({ enabled: z.boolean().optional(), checkIntervalMinutes: z.number().int().min(B.checkIntervalMinutes.min).max(B.checkIntervalMinutes.max).optional(), families: z.object( - Object.fromEntries(QUOTA_BURN_FAMILIES.map((id) => [id, quotaBurnFamilySchema.optional()])), + Object.fromEntries(QUOTA_BURN_FAMILIES.map((id) => [id, familySchemaFor(id).optional()])), ).strict().optional(), }).strict(); diff --git a/server/lib/taskTargetScope.js b/server/lib/taskTargetScope.js new file mode 100644 index 0000000000..d0be926e70 --- /dev/null +++ b/server/lib/taskTargetScope.js @@ -0,0 +1,70 @@ +/** + * Target-scope vocabulary for scheduled task types: does a type act on ONE + * managed app, on the whole install, or on either? + * + * Lives in `lib/` (pure data + two predicates, no imports) because three tiers + * need the same answer and only one of them is a service: + * - `services/taskScheduleRegistry.js` re-exports these, so the on-demand + * request gate and the global generator keep one target-scope contract; + * - `lib/quotaBurnTaskRef.js` resolves a burn step's reference against it; + * - `lib/quotaBurnValidation.js` rejects a reference whose scope is wrong + * BEFORE it reaches disk. + * + * The last two are `server/lib` modules, which may not import upward into + * `server/services` (see `lib/layering.test.js`), so the vocabulary belongs + * here and the registry keeps re-exporting it for its existing callers. + */ + +/** + * Task types whose "Run Now" with NO app is the REAL run — they sweep every + * managed app in one dispatch rather than acting on one. Surfaced per task on + * `getScheduleStatus()` so the schedule UI can offer an "All apps" entry + * instead of forcing every run through the app picker (which would make the + * install-wide lane unreachable on any install that has apps). + */ +export const INSTALL_WIDE_TASK_TYPES = new Set(['repo-sync', 'user-action-review', 'model-comparison-refresh']); + +// Task types that only make sense when pointed at a managed app. Keeping this +// alongside the install-wide registry gives both the on-demand request gate +// and the global generator one target-scope contract; neither has to infer +// scope from a task name or from which generator happened to receive a call. +export const MANAGED_APP_TARGET_TASK_TYPES = new Set(['pr-reviewer', 'issue-watcher', 'pr-watcher', 'issue-reconcile']); + +export function requiresManagedAppTarget(taskType) { + return MANAGED_APP_TARGET_TASK_TYPES.has(taskType); +} + +/** + * Task types PortOS executes ITSELF through a programmatic handler + * (`services/scheduledHandlers/`) — no agent, no CoS task, no spawn slot. + * + * Written out rather than derived from `SCHEDULED_HANDLER_MODULES` on purpose: + * the registry that re-exports this is reached by a large share of the server + * suite, so it must not pay an import to learn two strings (server/AGENTS.md, + * "Import scoping"). `taskScheduleRegistry.programmatic.test.js` asserts this + * list matches the handler registry exactly, so the two cannot drift. It lives + * here rather than in the registry because `requiresInstallWideTarget` below + * needs it, and `lib` may not import upward into `services`. + */ +export const PROGRAMMATIC_SCHEDULED_TASK_TYPES = Object.freeze([ + 'universe-bible-describe', + 'universe-bible-images', +]); +const PROGRAMMATIC_SCHEDULED_TASK_TYPE_SET = new Set(PROGRAMMATIC_SCHEDULED_TASK_TYPES); + +export const isProgrammaticScheduledTaskType = (taskType) => + PROGRAMMATIC_SCHEDULED_TASK_TYPE_SET.has(taskType); + +// Task types that must NOT be pointed at a managed app. Unlike repo-sync, model +// research has no meaningful per-app variant: its catalog and API live in the +// PortOS install, never another app's checkout. The programmatic bible handlers +// are the same shape for a different reason — a universe is PortOS's own record, +// not any repo's — so an appId on their request is a caller bug, not a scope. +const INSTALL_WIDE_ONLY_TASK_TYPES = new Set([ + 'model-comparison-refresh', + ...PROGRAMMATIC_SCHEDULED_TASK_TYPES, +]); + +export function requiresInstallWideTarget(taskType) { + return INSTALL_WIDE_ONLY_TASK_TYPES.has(taskType); +} diff --git a/server/routes/quotaBurn.test.js b/server/routes/quotaBurn.test.js index 6e391c9b05..ddc1ac4970 100644 --- a/server/routes/quotaBurn.test.js +++ b/server/routes/quotaBurn.test.js @@ -114,6 +114,54 @@ describe('PUT /api/quota-burn', () => { expect((await request(app).put('/api/quota-burn').send({ checkIntervalMinutes: 1 })).status).toBe(400); expect(saveQuotaBurnConfig).not.toHaveBeenCalled(); }); + + it('saves a scheduled-task reference step with per-invocation overrides', async () => { + saveQuotaBurnConfig.mockResolvedValue({ enabled: true }); + const jobs = [{ + id: 'step-1', label: 'Nightly UX sweep', runOnce: true, + taskRef: { kind: 'builtin', taskType: 'ux', appId: 'a1' }, + overrides: { providerId: 'claude-code-tui', model: 'opus', effort: 'high', params: { fileIssues: true } }, + }]; + const res = await request(buildApp()).put('/api/quota-burn').send({ families: { claude: { enabled: true, jobs } } }); + expect(res.status).toBe(200); + expect(saveQuotaBurnConfig).toHaveBeenCalledWith({ families: { claude: { enabled: true, jobs } } }); + }); + + it('names the offending field when a reference is malformed', async () => { + const app = buildApp(); + const reject = async (families) => { + const res = await request(app).put('/api/quota-burn').send({ families }); + expect(res.status).toBe(400); + return (res.body.context?.details || []).map((d) => d.path); + }; + // A type that only makes sense against one managed app, with none named. + expect(await reject({ claude: { jobs: [{ taskRef: { kind: 'builtin', taskType: 'pr-reviewer' } }] } })) + .toContain('families.claude.jobs.0.taskRef.appId'); + // A custom reference addressed by something that is not a job id. + expect(await reject({ claude: { jobs: [{ taskRef: { kind: 'custom', jobId: 7 } }] } })) + .toContain('families.claude.jobs.0.taskRef.jobId'); + // A pin on another family's subscription — the runner would spend it. + expect(await reject({ claude: { jobs: [{ taskRef: { kind: 'custom', jobId: 'j1' }, overrides: { providerId: 'codex-tui' } }] } })) + .toContain('families.claude.jobs.0.overrides.providerId'); + // A step that names no work at all, and one that names two kinds of it. + expect(await reject({ claude: { jobs: [{ label: 'orphan' }] } })) + .toContain('families.claude.jobs.0.taskRef'); + expect(await reject({ claude: { jobs: [{ jobType: 'agent-prompt', taskRef: { kind: 'custom', jobId: 'j1' } }] } })) + .toContain('families.claude.jobs.0.jobType'); + expect(saveQuotaBurnConfig).not.toHaveBeenCalled(); + }); + + it('accepts an in-family pin, and one whose id names no family at all', async () => { + // Only an id that unambiguously names ANOTHER family is rejected: the binary + // decides the family, and a schema cannot read the provider list. + saveQuotaBurnConfig.mockResolvedValue({ enabled: true }); + const app = buildApp(); + for (const providerId of ['claude-code-tui', 'my-own-wrapper']) { + const res = await request(app).put('/api/quota-burn') + .send({ families: { claude: { jobs: [{ taskRef: { kind: 'custom', jobId: 'j1' }, overrides: { providerId } }] } } }); + expect(res.status, providerId).toBe(200); + } + }); }); describe('POST /api/quota-burn/run', () => { diff --git a/server/services/scheduledHandlers/providerPick.test.js b/server/services/scheduledHandlers/providerPick.test.js index 365c0f2b00..87e4a0d3f4 100644 --- a/server/services/scheduledHandlers/providerPick.test.js +++ b/server/services/scheduledHandlers/providerPick.test.js @@ -57,6 +57,21 @@ describe('providerForFamily', () => { expect(providerForFamily(providers, { familyId: 'codex' })).toBeNull(); }); + // A burn step's provider pin is optional, and an unset one INHERITS. What it + // must never inherit is another family's subscription: the plan says "spend + // the codex window", so a codex step with no codex provider registered has to + // report nothing to burn rather than quietly draining the claude plan. + it('resolves an unpinned step inside its own family, never falling back to another', () => { + const providers = [ + { id: 'claude-code-tui', type: 'tui', enabled: true, command: 'claude' }, + { id: 'claude-code', type: 'cli', enabled: true, command: 'claude' }, + { id: 'antigravity-tui', type: 'tui', enabled: true, command: 'agy' }, + ]; + expect(providerForFamily(providers, { familyId: 'claude' })?.id).toBe('claude-code-tui'); + expect(providerForFamily(providers, { familyId: 'codex' })).toBeNull(); + expect(providerForFamily(providers, { familyId: 'grok', prefer: 'cli' })).toBeNull(); + }); + it('never selects an ollama-backed wrapper — a local model has no window to burn', () => { // `claude-ollama-tui` matches the `claude` family and IS a TUI, so the // preference above would reach for it. It runs a local model: nothing diff --git a/server/services/taskScheduleRegistry.js b/server/services/taskScheduleRegistry.js index 7c11298cb1..18622fb6fc 100644 --- a/server/services/taskScheduleRegistry.js +++ b/server/services/taskScheduleRegistry.js @@ -13,24 +13,16 @@ import { } from '../lib/agentExecutionProfiles.js'; import { INTERVAL_TYPES } from './taskScheduleConstants.js'; -/** - * Task types PortOS executes ITSELF through a programmatic handler - * (`services/scheduledHandlers/`) — no agent, no CoS task, no spawn slot. - * - * Written out rather than derived from `SCHEDULED_HANDLER_MODULES` on purpose: - * this registry is reached by a large share of the server suite, so it must not - * pay an import to learn two strings (server/AGENTS.md, "Import scoping"). - * `taskScheduleRegistry.programmatic.test.js` asserts this list matches the - * handler registry exactly, so the two cannot drift. - */ -export const PROGRAMMATIC_SCHEDULED_TASK_TYPES = Object.freeze([ - 'universe-bible-describe', - 'universe-bible-images', -]); -const PROGRAMMATIC_SCHEDULED_TASK_TYPE_SET = new Set(PROGRAMMATIC_SCHEDULED_TASK_TYPES); +// Programmatic handler types + target-scope vocabulary live in +// `lib/taskTargetScope.js` (see that file for why); re-exported here because +// the on-demand request gate, the global generator, and the schedule UI have +// always read them off the registry. +import { + isProgrammaticScheduledTaskType, + PROGRAMMATIC_SCHEDULED_TASK_TYPES, +} from '../lib/taskTargetScope.js'; -export const isProgrammaticScheduledTaskType = (taskType) => - PROGRAMMATIC_SCHEDULED_TASK_TYPE_SET.has(taskType); +export { isProgrammaticScheduledTaskType, PROGRAMMATIC_SCHEDULED_TASK_TYPES }; export const SELF_IMPROVEMENT_TASK_TYPES = [ 'model-comparison-refresh', @@ -226,34 +218,20 @@ export const PERPETUAL_DRAIN_DISPATCH_CAP = 5; export const DEFAULT_BRANCHES_PER_AGENT = 3; /** - * Task types whose "Run Now" with NO app is the REAL run — they sweep every - * managed app in one dispatch rather than acting on one. Surfaced per task on - * `getScheduleStatus()` so the schedule UI can offer an "All apps" entry - * instead of forcing every run through the app picker (which would make the - * install-wide lane unreachable on any install that has apps). + * Target scope for a task type — which app (if any) a run must name. + * + * The vocabulary itself lives in `lib/taskTargetScope.js` so the quota-burn + * reference schemas can reach it: `server/lib` may not import upward into + * `server/services` (`lib/layering.test.js`). Re-exported here because the + * on-demand request gate, the global generator, and the schedule UI have always + * read it off the registry, and splitting that import would gain nothing. */ -export const INSTALL_WIDE_TASK_TYPES = new Set(['repo-sync', 'user-action-review', 'model-comparison-refresh']); - -// Task types that only make sense when pointed at a managed app. Keeping this -// alongside the install-wide registry gives both the on-demand request gate -// and the global generator one target-scope contract; neither has to infer -// scope from a task name or from which generator happened to receive a call. -export const MANAGED_APP_TARGET_TASK_TYPES = new Set(['pr-reviewer', 'issue-watcher', 'pr-watcher', 'issue-reconcile']); - -export function requiresManagedAppTarget(taskType) { - return MANAGED_APP_TARGET_TASK_TYPES.has(taskType); -} - -// Task types that must NOT be pointed at a managed app. Unlike repo-sync, model -// research has no meaningful per-app variant: its catalog and API live in the -// PortOS install, never another app's checkout. The programmatic bible handlers -// are the same shape for a different reason — a universe is PortOS's own record, -// not any repo's — so an appId on their request is a caller bug, not a scope. -const INSTALL_WIDE_ONLY_TASK_TYPES = new Set(['model-comparison-refresh', ...PROGRAMMATIC_SCHEDULED_TASK_TYPES]); - -export function requiresInstallWideTarget(taskType) { - return INSTALL_WIDE_ONLY_TASK_TYPES.has(taskType); -} +export { + INSTALL_WIDE_TASK_TYPES, + MANAGED_APP_TARGET_TASK_TYPES, + requiresInstallWideTarget, + requiresManagedAppTarget, +} from '../lib/taskTargetScope.js'; // The pr-reviewer pipeline is a trust boundary, not three interchangeable // prompt tabs. Keep the shipped role/profile pairing in one place so the