From 6ed8830675caf6beab503a8fc3f4166ae659c1f6 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Wed, 9 Sep 2026 15:02:34 +0000 Subject: [PATCH] feat: add audit and fix mode to recommended maintenance runs --- client/src/components/cos/tabs/ScheduleTab.jsx | 2 +- .../cos/tabs/schedule/MaintenanceRunForm.jsx | 13 ++++++++++++- .../tabs/schedule/MaintenanceRunForm.test.jsx | 14 +++++++++++++- server/lib/README.md | 2 +- server/lib/maintenanceSequence.js | 17 ++++++++++------- server/routes/cosScheduleRoutes.js | 1 + server/routes/cosScheduleRoutes.test.js | 7 +++++++ server/services/maintenanceRun.js | 4 ++-- server/services/maintenanceRun.test.js | 17 ++++++++++++++++- 9 files changed, 63 insertions(+), 14 deletions(-) diff --git a/client/src/components/cos/tabs/ScheduleTab.jsx b/client/src/components/cos/tabs/ScheduleTab.jsx index ef9e07e2fc..47a5639175 100644 --- a/client/src/components/cos/tabs/ScheduleTab.jsx +++ b/client/src/components/cos/tabs/ScheduleTab.jsx @@ -190,7 +190,7 @@ export default function ScheduleTab({ apps, providers, providersLoaded, activePr

{MAINTENANCE_ORDER_GUIDANCE}

-

Resolve findings between audits, then document the resulting code. Run the whole sequence now from here, or schedule it under quota gates in Quota Burn; both drain claim-issue between steps.

+

Resolve findings between audits, then document the resulting code. Run the whole sequence now from here, or schedule it under quota gates in Quota Burn; Run now offers issue filing with claim-issue between audits, or audit and fix with one final claim-issue drain.

Run maintenance now diff --git a/client/src/components/cos/tabs/schedule/MaintenanceRunForm.jsx b/client/src/components/cos/tabs/schedule/MaintenanceRunForm.jsx index 48296dae41..7cd757a5d6 100644 --- a/client/src/components/cos/tabs/schedule/MaintenanceRunForm.jsx +++ b/client/src/components/cos/tabs/schedule/MaintenanceRunForm.jsx @@ -18,6 +18,7 @@ export default function MaintenanceRunForm({ schedule, apps = [], providers = [] const [providerId, setProviderId] = useState(''); const [model, setModel] = useState(''); const [effort, setEffort] = useState(''); + const [mode, setMode] = useState('file-issues'); const [consent, setConsent] = useState(false); const [busy, setBusy] = useState(false); const [preparing, setPreparing] = useState(false); @@ -104,7 +105,7 @@ export default function MaintenanceRunForm({ schedule, apps = [], providers = [] if (busy || blocked || !ready || !provider || !model || !consent) return; setBusy(true); setMessage('Starting maintenance…'); - const response = await api.startMaintenanceRun({ appId, providerId, model, effort: effort || null }, { silent: true }).catch(error => { + const response = await api.startMaintenanceRun({ appId, providerId, model, effort: effort || null, mode }, { silent: true }).catch(error => { setMessage(`Could not start maintenance: ${error.message}`); return null; }); @@ -152,6 +153,16 @@ export default function MaintenanceRunForm({ schedule, apps = [], providers = [] {apps.filter(app => app.archived !== true).map(app => )} + +

{mode === 'fix' + ? 'Audits fix findings directly, then documentation runs, followed by one final claim-issue drain for remaining issues.' + : 'Audits file issues, with a claim-issue drain between audits to resolve findings before the next step.'}

{ await select(user); await user.click(screen.getByRole('button', { name: 'Run now' })); expect(screen.getByRole('button', { name: 'Starting…' })).toBeDisabled(); - expect(api.startMaintenanceRun).toHaveBeenCalledWith({ appId: 'example', providerId: 'claude', model: 'sonnet', effort: 'high' }, { silent: true }); + expect(api.startMaintenanceRun).toHaveBeenCalledWith({ appId: 'example', providerId: 'claude', model: 'sonnet', effort: 'high', mode: 'file-issues' }, { silent: true }); finishStart({ run: runRecord(), result: { dispatched: true, taskType: 'better-structural-drift' } }); expect(await screen.findByText(/Maintenance started with better-structural-drift/)).toBeInTheDocument(); const row = screen.getByRole('list', { name: 'Maintenance runs' }); @@ -174,3 +174,15 @@ it('keeps a newer live update when an older initial fetch resolves late, and ref await act(async () => socket.on.mock.calls.find(([name]) => name === 'connect')[1]()); expect(screen.getByText(/completed · 1\/13 steps/)).toBeInTheDocument(); }); + +it('launches fix mode only after renewed consent', async () => { + const user = userEvent.setup(); + show(); + await select(user); + await user.selectOptions(screen.getByLabelText('Audit mode'), 'fix'); + expect(screen.getByRole('button', { name: 'Run now' })).toBeDisabled(); + expect(screen.getByText(/one final claim-issue drain/)).toBeInTheDocument(); + await user.click(screen.getByRole('checkbox')); + await user.click(screen.getByRole('button', { name: 'Run now' })); + expect(api.startMaintenanceRun).toHaveBeenCalledWith(expect.objectContaining({ mode: 'fix' }), { silent: true }); +}); diff --git a/server/lib/README.md b/server/lib/README.md index 5a98815c13..6d93179ac3 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -181,7 +181,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | `mediaModelBuckets.js` | Names and resolves the video registry's two model buckets (#4142). The split is by RUNTIME FAMILY — `mlx` (Apple MLX runtimes) vs `cuda` (plain torch+CUDA, which runs on Windows *and* Linux) — not by operating system, which is why `activeVideoBucket()` keys on `process.platform === 'darwin'` instead of on "is this Windows". `readVideoBucket` / `readVideoDefault` / `matchesVideoBucket` resolve the canonical key first and fall back to the pre-#4142 `macos` / `windows` / `defaultMacos` / `defaultWindows` spellings, so any registry written by an older install still loads untouched; `canonicalizeVideoBuckets` performs the one-time rename (migration 270 and the load-time twin in `mediaModels.js`). Kept separate from `mediaModels.js` so `scripts/migrations/` can share the alias resolution without importing the registry loader, which seeds `data/` as a side effect. | | `mediaModels.js` | Single source of truth for image/video model metadata. Entry fields are documented in the module docblock — note `repoFiles[]`, which narrows a model's own repo to an explicit file list for an aggregate repo that holds far more than the runner loads (MiniMax H3 CUDA). | | `videoFailure.js` | `normalizeVideoFailure` classifies and scrubs local video causes; `createVideoDiagnosticTail` retains bounded stdout/stderr diagnostics without grouping bare exit codes. | -| `maintenanceSequence.js` | The maintenance ladder shared by both runners: `MAINTENANCE_TASK_ORDER` / `MAINTENANCE_ORDER_GUIDANCE`, the interleaved `MAINTENANCE_SEQUENCE_TYPES` (a perpetual `claim-issue` drain between every pair of audits), `maintenanceStepParams` (audits file issues, documentation does the work), and `buildMaintenanceSteps` which emits quota-burn-shaped run-once steps so `quotaBurnInvoke.js` can run a step from the Schedule tab's manual run or a Quota Burn family plan alike. Pure. | +| `maintenanceSequence.js` | The maintenance ladder shared by both runners: `MAINTENANCE_TASK_ORDER` / `MAINTENANCE_ORDER_GUIDANCE`, the interleaved `MAINTENANCE_SEQUENCE_TYPES` (a perpetual `claim-issue` drain between every pair of audits), `maintenanceStepParams` (audits file issues, documentation does the work), and `buildMaintenanceSteps` (optional fix mode runs audits directly with one final drain) which emits quota-burn-shaped run-once steps so `quotaBurnInvoke.js` can run a step from the Schedule tab's manual run or a Quota Burn family plan alike. Pure. | | `meetingUrl.js` | Selects the one cacheable join URL (`selectMeetingUrl`) out of a raw Google Calendar event — first http(s) `video` entry point, else `hangoutLink` — and owns the shared `MEETING_URL_MAX` bound. Three-state: `undefined` = the producer never described conferencing, `null` = described with none usable. | | `minimaxH3Memory.js` | The declared weight-placement table for the three MiniMax H3 entries (#5420) — H3 is the one video model family whose components fit nowhere unassisted, so "does this box have enough" is a render gate, not a UI fact. `MINIMAX_H3_MEMORY_PROFILES` maps entry id → `{ shippedRepo, shippedRevision, profiles }`, each profile carrying an honest `minMemoryGb` host floor and (CUDA only) a `minVramGb` device floor, ordered best-first. Every capacity number is HOISTED from what already existed — the CUDA tiers are `resolve_offload_profile()`'s own thresholds in `scripts/generate_minimax_h3_cuda.py`, the host floors are the entries' `memoryGb` — the sole new number being `MINIMAX_H3_HOST_RESERVE_GB`, a policy reserve held back for the OS. `applyMiniMaxH3MemoryProfiles(list)` is the load-time backfill (twin of migration 317) and guards BOTH `repo` and `revision`, like the speed-profile decorator. `selectMiniMaxH3MemoryProfile({ model, totalMemoryGb })` picks the best profile the HOST can hold (VRAM is the runner's call — the server has no synchronous device view); `miniMaxH3MemoryDeclineReason()` RETURNS the fail-closed reason so the render path can 400 it and a status surface can show it, and returns `null` on an UNMEASURED host — "not measured" is a deferral to the runner, never the same as zero. `validateMiniMaxH3MemoryProfileTable` / `sanitizeMiniMaxH3MemoryProfiles` warn + strip a hand-edited table (NaN floor, duplicate/reserved id, mis-ordered tiers) at load. | | `videoContinuity.js` | How chunk N+1 of a chained video render is conditioned on chunk N. `resolveContextFrames(requested)` normalizes the tail-window size (absent → `DEFAULT_CONTEXT_FRAMES` = 22 ≈ 1s @ 24fps; an explicit `0` is preserved as "last frame only", NOT collapsed into the default) and clamps to `MIN_CONTEXT_FRAMES`..`MAX_CONTEXT_FRAMES`. `resolveContinuityStrategy({model, contextFrames})` picks `'window'` (LTX-2 `extend_from_video` conditioned on the prior chunk's last N frames — motion, not just a pose) or `'frame'` (extract the last frame, run i2v), degrading to `'frame'` on any runtime outside `CONTEXT_WINDOW_RUNTIMES` rather than rejecting. `extendLatentFrames` / `extendedPixelFrames` convert across the VAE's `LATENT_FRAME_STRIDE` (8 pixel frames per latent), `contextPrefixFrames({totalFrames, extendLatents})` measures how much of an extend render is echoed context to trim back off before stitching (0 = leave it alone), and `tailWindowStartFrame` gives the cut index for the window itself. Pure — importable from `prepareParams.js` without dragging in `local.js`. Mirrored for the picker in `client/src/lib/videoGenParams.js`, pinned by `videoContinuity.parity.test.js`. | diff --git a/server/lib/maintenanceSequence.js b/server/lib/maintenanceSequence.js index f96704fda6..07e4ee2bb3 100644 --- a/server/lib/maintenanceSequence.js +++ b/server/lib/maintenanceSequence.js @@ -1,7 +1,8 @@ /** * The maintenance ladder: the ordered audits PortOS recommends running against a * managed app, with a perpetual `claim-issue` drain between every pair so each - * audit's findings are resolved before the next audit reads the code. + * audit's findings are resolved before the next audit reads the code. Fix mode + * instead resolves findings in each audit and runs one final drain. * * ONE definition, shared by both runners. `services/maintenanceRun.js` walks * these steps directly for the Schedule tab's "Run now" — no quota gates, no @@ -27,18 +28,19 @@ export const MAINTENANCE_ORDER_GUIDANCE = 'better-structural-drift → simplify /** The drain that separates every pair of audits. */ export const MAINTENANCE_DRAIN_TASK = 'claim-issue'; -/** Every scheduled task the ladder references, drain included, in ladder order. */ +/** The default issue-filing ladder, including interleaved drains. */ export const MAINTENANCE_SEQUENCE_TYPES = Object.freeze(MAINTENANCE_TASK_ORDER.flatMap((type, index) => (index ? [MAINTENANCE_DRAIN_TASK, type] : [type]))); /** * The run params an audit step pins. The first six audits explicitly FILE * issues (which the drain then claims); documentation explicitly does the work. + * Fix mode runs audits directly and drains once at the end. * A drain pins nothing — it inherits the app's saved claim filters. */ -export const maintenanceStepParams = (taskType) => (taskType === MAINTENANCE_DRAIN_TASK +export const maintenanceStepParams = (taskType, mode = 'file-issues') => (taskType === MAINTENANCE_DRAIN_TASK ? {} - : { fileIssues: taskType !== 'documentation' }); + : { fileIssues: mode !== 'fix' && taskType !== 'documentation' }); /** * Build the ladder as run-once steps targeting `appId`, every one pinned to the @@ -46,8 +48,9 @@ export const maintenanceStepParams = (taskType) => (taskType === MAINTENANCE_DRA * task's saved effort. Ids are `${idPrefix}-${index}`, so a fresh prefix per * invocation yields fresh step identities. */ -export function buildMaintenanceSteps({ appId, idPrefix, providerId = null, model = null, effort = null }) { - return MAINTENANCE_SEQUENCE_TYPES.map((taskType, index) => ({ +export function buildMaintenanceSteps({ appId, idPrefix, providerId = null, model = null, effort = null, mode = 'file-issues' }) { + const types = mode === 'fix' ? [...MAINTENANCE_TASK_ORDER, MAINTENANCE_DRAIN_TASK] : MAINTENANCE_SEQUENCE_TYPES; + return types.map((taskType, index) => ({ id: `${idPrefix}-${index}`, enabled: true, label: '', @@ -55,6 +58,6 @@ export function buildMaintenanceSteps({ appId, idPrefix, providerId = null, mode jobType: null, runOnce: true, drain: taskType === MAINTENANCE_DRAIN_TASK, - overrides: { providerId, model, effort, params: maintenanceStepParams(taskType) }, + overrides: { providerId, model, effort, params: maintenanceStepParams(taskType, mode) }, })); } diff --git a/server/routes/cosScheduleRoutes.js b/server/routes/cosScheduleRoutes.js index 07890cf54c..9d16c72106 100644 --- a/server/routes/cosScheduleRoutes.js +++ b/server/routes/cosScheduleRoutes.js @@ -35,6 +35,7 @@ const suggestedAfterSchema = z.array(z.string()).max(SUGGESTED_AFTER_MAX); // model up front (AGENTS.md AI-policy: the click IS the consent). Blank effort // inherits each scheduled task's saved effort. const maintenanceRunStartSchema = z.object({ + mode: z.enum(['file-issues', 'fix']).optional(), appId: z.string().trim().min(1), providerId: z.string().trim().min(1), model: z.string().trim().min(1), diff --git a/server/routes/cosScheduleRoutes.test.js b/server/routes/cosScheduleRoutes.test.js index 7cb47fe778..6f701fb1c0 100644 --- a/server/routes/cosScheduleRoutes.test.js +++ b/server/routes/cosScheduleRoutes.test.js @@ -75,6 +75,13 @@ describe('CoS Schedule Routes', () => { }); describe('manual maintenance runs', () => { + it('accepts fix mode and rejects unknown modes', async () => { + maintenance.startMaintenanceRun.mockResolvedValue({ run: { id: 'maint-1' } }); + const body = { appId: 'app-1', providerId: 'codex', model: 'gpt-5', mode: 'fix' }; + expect((await request(app).post('/api/cos/schedule/maintenance-runs').send(body)).status).toBe(201); + expect(maintenance.startMaintenanceRun).toHaveBeenCalledWith(body); + expect((await request(app).post('/api/cos/schedule/maintenance-runs').send({ ...body, mode: 'unknown' })).status).toBe(400); + }); it('starts a run from a validated body and reports the first dispatch', async () => { maintenance.startMaintenanceRun.mockResolvedValue({ run: { id: 'maint-1', status: 'running' }, result: { dispatched: true, taskType: 'better-structural-drift' } }); const response = await request(app).post('/api/cos/schedule/maintenance-runs').send({ appId: 'app-1', providerId: 'codex', model: 'gpt-5', effort: null }); diff --git a/server/services/maintenanceRun.js b/server/services/maintenanceRun.js index a8c98c6b75..3e53461396 100644 --- a/server/services/maintenanceRun.js +++ b/server/services/maintenanceRun.js @@ -138,7 +138,7 @@ async function assertNoRunningRun(appId) { * The first evaluation runs before this returns, so the caller learns whether * step one actually went out (or why it is holding) in the same response. */ -export async function startMaintenanceRun({ appId, providerId, model = null, effort = null }) { +export async function startMaintenanceRun({ appId, providerId, model = null, effort = null, mode = 'file-issues' }) { const [{ getAppById }, { getProviderById }, { resolveBurnProvider }] = await Promise.all([ import('./apps.js'), import('./providers.js'), import('./scheduledHandlers/providerPick.js'), ]); @@ -155,7 +155,7 @@ export async function startMaintenanceRun({ appId, providerId, model = null, eff const run = await insertRun({ id, appId, familyId, ...pins, status: MAINTENANCE_RUN_STATUS.RUNNING, - steps: buildMaintenanceSteps({ appId, idPrefix: id, ...pins }), + steps: buildMaintenanceSteps({ appId, idPrefix: id, ...pins, mode }), completed: {}, active: null, reason: null, diff --git a/server/services/maintenanceRun.test.js b/server/services/maintenanceRun.test.js index 9f0470dda7..f0c75cebbb 100644 --- a/server/services/maintenanceRun.test.js +++ b/server/services/maintenanceRun.test.js @@ -2,7 +2,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { rm } from 'fs/promises'; import { join } from 'path'; import { mockPathsDataRoot } from '../lib/mockPathsDataRoot.js'; -import { MAINTENANCE_SEQUENCE_TYPES } from '../lib/maintenanceSequence.js'; +import { MAINTENANCE_SEQUENCE_TYPES, MAINTENANCE_TASK_ORDER } from '../lib/maintenanceSequence.js'; const { tempRoot, makeProxy, cleanup } = mockPathsDataRoot({ prefix: 'portos-maintenance-run-' }); vi.mock('../lib/fileUtils.js', async () => makeProxy(await vi.importActual('../lib/fileUtils.js'))); @@ -184,3 +184,18 @@ it('publishes queued, running, and completed progress with the active agent link expect(updates.at(-1).active).not.toHaveProperty('agentId'); cosEvents.off('maintenance:updated', listener); }); + +it('runs fixes consecutively and drains remaining issues only after documentation', async () => { + const { run } = await startMaintenanceRun({ appId: 'app-1', providerId: 'codex', model: 'gpt-5', mode: 'fix' }); + expect(run.steps.map(step => step.taskRef.taskType)).toEqual([...MAINTENANCE_TASK_ORDER, 'claim-issue']); + for (let index = 0; index < MAINTENANCE_TASK_ORDER.length; index++) { + expect(state.invoked.at(-1).step.overrides.params).toEqual({ fileIssues: false }); + await __onMaintenanceAgentCompleted(agentFor(run, index)); + } + expect(dispatchedTypes()).toEqual([...MAINTENANCE_TASK_ORDER, 'claim-issue']); + await __onMaintenanceAgentCompleted(agentFor(run, MAINTENANCE_TASK_ORDER.length)); + expect(dispatchedTypes().slice(-2)).toEqual(['claim-issue', 'claim-issue']); + state.probe = { drained: true }; + await __onMaintenanceAgentCompleted(agentFor(run, MAINTENANCE_TASK_ORDER.length)); + expect(await getMaintenanceRun(run.id)).toMatchObject({ status: 'completed' }); +});