CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 4s
Build images / sign-extension (push) Successful in 5s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 34s
Build images / build-web (push) Successful in 59s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m19s
Milestone 422 step 4. Rule 27: no UI, no ship. This is where the previous three steps become usable. REACHABLE AT: Settings -> Activity -> Worker lanes, directly under the "Queues + workers" pane. Written from opening the view, not from memory of having built it — lesson #4282, and #3463 is the same trap landing inside milestone 365, where the System page shipped with no navigation to it. Under that pane deliberately, not in the System tab. System answers "is everything running", where every control would be about something broken. This is about something working that should work harder, and it belongs beside the backlog it reacts to: you watch a queue grow and give that lane another slot without leaving the pane. Per lane: queues, pending, busy, a stepper, an enable switch. - PENDING is depth + reserved, not LLEN. Celery prefetches, so LLEN alone reads 0 while a worker holds tasks in memory — the number that would make someone think a buried lane was idle. - NOT ANSWERING, never "stopped". present=false means nothing replied; saying stopped would send the operator looking for a crash that has not happened. - THE CEILING IS ON SCREEN, with "(memory)" on the ML lane. It is the one number here the operator cannot change, so it has to justify itself; a greyed stepper with no explanation reads as a bug. - `busy` is per lane, so adjusting one does not freeze the others. TWO OUTCOMES THAT MUST NOT COLLAPSE INTO ONE MESSAGE. A stored-but-unpushed change (applied:false — the lane is restarting) is information: the value is saved and the reconcile will carry it, so the card says so and invites waiting. A refused value (400) is an error and shows the endpoint's own sentence. Collapsing them would make one of the two invite the wrong action. A BUG CAUGHT BEFORE COMMIT: the card read `e.detail?.detail`, but ApiError puts the parsed body on `.body` and `.message` on the short `error` key. It would have shown the operator the bare word "refused" with no reason — the exact failure that line exists to prevent. The test would not have caught it either: `rejects.toThrow()` passes whether the sentence is reachable or not. It now asserts `err.body.detail` specifically. Also: queueOptions in SystemActivityTab was a fourth hand-kept copy of the queue list and had drifted — `maintenance_long` was missing, so activity on that lane could not be filtered for at all despite four task routes pointing there. Added, and DELIBERATELY left as a written-out list rather than derived like the other three were: this filters task_run HISTORY, so a derived list would hide the filter for any queue that has rows but no longer has a lane — precisely when someone is looking — and would be empty whenever the endpoint is down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
142 lines
5.6 KiB
JavaScript
142 lines
5.6 KiB
JavaScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import { setActivePinia, createPinia } from 'pinia'
|
|
import { useSystemActivityStore } from '../src/stores/systemActivity.js'
|
|
|
|
// Milestone 422 step 4. Covers the store half of the worker-lane dial — the
|
|
// part that decides what the card can tell the operator.
|
|
//
|
|
// The distinction being protected: a change that was STORED but not pushed
|
|
// (`applied: false`, because the lane is restarting) is not a failure, and a
|
|
// REFUSED value (400) is. Collapsing those two into one message is how a
|
|
// control stops being trustworthy — one invites waiting, the other invites
|
|
// changing what you asked for.
|
|
|
|
function stubFetch(handler) {
|
|
globalThis.fetch = vi.fn(async (url, init) => {
|
|
const { status, body } = handler(url, init)
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
statusText: String(status),
|
|
text: async () => (body == null ? '' : JSON.stringify(body)),
|
|
}
|
|
})
|
|
}
|
|
|
|
const LANES_BODY = {
|
|
lanes: [
|
|
{
|
|
name: 'worker', display_name: 'Worker',
|
|
queues: ['default', 'import', 'thumbnail', 'download'],
|
|
slots: 1, slots_cap: 4, ceiling: 8, enabled: true, memory_bound: false,
|
|
live: { present: true, replicas: 1, pool: 1, active: 0, reserved: 3 },
|
|
queue_depth: 5, pending: 8,
|
|
},
|
|
{
|
|
name: 'ml', display_name: 'ML tagging', queues: ['ml'],
|
|
slots: 0, slots_cap: 1, ceiling: 2, enabled: false, memory_bound: true,
|
|
live: { present: false, replicas: 0, pool: null, active: 0, reserved: 0 },
|
|
queue_depth: null, pending: null,
|
|
},
|
|
],
|
|
fetched_at: '2026-09-22T12:00:00Z',
|
|
}
|
|
|
|
describe('worker lanes store', () => {
|
|
beforeEach(() => setActivePinia(createPinia()))
|
|
afterEach(() => vi.restoreAllMocks())
|
|
|
|
it('loads the lanes', async () => {
|
|
stubFetch(() => ({ status: 200, body: LANES_BODY }))
|
|
const s = useSystemActivityStore()
|
|
await s.loadLanes()
|
|
expect(s.lanes.lanes.map((l) => l.name)).toEqual(['worker', 'ml'])
|
|
})
|
|
|
|
it('a load failure records the error rather than throwing at the caller', async () => {
|
|
// The card polls this every 3s. An unhandled rejection per tick would
|
|
// drown the console and stop the other pollers in the same function.
|
|
stubFetch(() => ({ status: 500, body: { error: 'boom' } }))
|
|
const s = useSystemActivityStore()
|
|
await expect(s.loadLanes()).resolves.toBeUndefined()
|
|
expect(s.lastError).toBeTruthy()
|
|
})
|
|
|
|
it('setLane posts only the fields it was given', async () => {
|
|
// Partial update: the stepper sends slots without restating a cap it did
|
|
// not touch. Sending the whole row back would make two operators editing
|
|
// different fields clobber each other.
|
|
const calls = []
|
|
stubFetch((url, init) => {
|
|
calls.push({ url, init })
|
|
if (init?.method === 'POST') {
|
|
return { status: 200, body: { name: 'worker', slots: 2, applied: true } }
|
|
}
|
|
return { status: 200, body: LANES_BODY }
|
|
})
|
|
const s = useSystemActivityStore()
|
|
await s.setLane('worker', { slots: 2 })
|
|
|
|
const post = calls.find((c) => c.init?.method === 'POST')
|
|
expect(post.url).toContain('/api/system/workers/worker')
|
|
expect(JSON.parse(post.init.body)).toEqual({ slots: 2 })
|
|
})
|
|
|
|
it('setLane refetches so the card shows the server truth, not the guess', async () => {
|
|
// The reply is one lane; the card renders all of them plus live pool and
|
|
// pending. Patching the local row from the reply would leave every other
|
|
// column stale and eventually wrong.
|
|
let gets = 0
|
|
stubFetch((url, init) => {
|
|
if (init?.method === 'POST') return { status: 200, body: { applied: true } }
|
|
gets += 1
|
|
return { status: 200, body: LANES_BODY }
|
|
})
|
|
const s = useSystemActivityStore()
|
|
await s.setLane('worker', { slots: 2 })
|
|
expect(gets).toBe(1)
|
|
})
|
|
|
|
it('a stored-but-unapplied change comes back as applied:false, not an error', async () => {
|
|
// The lane is restarting. The value IS saved and the reconcile will carry
|
|
// it — so this must reach the card as information, not as a failure that
|
|
// invites the operator to set it again.
|
|
stubFetch((url, init) => {
|
|
if (init?.method === 'POST') {
|
|
return {
|
|
status: 200,
|
|
body: { applied: false, apply_error: 'lane is not running', slots: 2 },
|
|
}
|
|
}
|
|
return { status: 200, body: LANES_BODY }
|
|
})
|
|
const s = useSystemActivityStore()
|
|
const reply = await s.setLane('worker', { slots: 2 })
|
|
expect(reply.applied).toBe(false)
|
|
expect(reply.apply_error).toContain('not running')
|
|
})
|
|
|
|
it('a refused value throws so the card can show the reason', async () => {
|
|
// Deliberately NOT swallowed. The detail is written to be read by a person
|
|
// ("above what this container can hold"), and a control that silently does
|
|
// nothing is worse than one that refuses out loud.
|
|
stubFetch((url, init) => {
|
|
if (init?.method === 'POST') {
|
|
return {
|
|
status: 400,
|
|
body: { error: 'refused', detail: 'cap 10000 is above what this container can hold (2 for ML tagging)' },
|
|
}
|
|
}
|
|
return { status: 200, body: LANES_BODY }
|
|
})
|
|
const s = useSystemActivityStore()
|
|
// Assert the REASON is reachable, not merely that it threw. `toThrow()`
|
|
// alone passes whether the card can read the sentence or not — which is
|
|
// how the first version of the card shipped reading `e.detail` (always
|
|
// undefined) and would have shown the operator the bare word "refused".
|
|
const err = await s.setLane('ml', { slots_cap: 10000 }).catch((e) => e)
|
|
expect(err.status).toBe(400)
|
|
expect(err.body.detail).toContain('container can hold')
|
|
})
|
|
})
|