CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 20s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m10s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 2m10s
CI and images / smoke-web (push) Successful in 52s
CI and images / promote (push) Skipped
Operator, 2026-09-23, on the screenshot: *"I feel that we can probably combine the two sections into a single table and to format it in such a way that it appears more bounded and less free-form or open. also there's nothing to describe what 'auto' means or why their needs to be or should be on/off toggles. almost all of it always needs to run there's only one optional piece and it is killed by moving the 'cap' to zero."* Three separate things, all correct. ## The four lanes were listed twice The roster (milestone 365) said "ML tagging is running", and four hundred pixels below it the lanes pane said "ML tagging · 1/1 busy". Two answers to one question from two endpoints, free to disagree on screen. I moved the second pane onto this tab yesterday and did not notice it duplicated the first. Now one row per part, with controls on the rows that have a lane and none on the rows that do not. The join is on the QUEUE SET, because that is what `service_roster` keys a celery part on — as a set, not as a string, so neither side has to agree about order. It lives in `utils/systemParts.js` rather than inline, and has a spec, because its failure is SILENT and is the exact thing it exists to prevent: a lane that stops matching its part does not throw, it grows a second row for the same worker. The duplication, returning through the code that removed it. ## Bounded, not free-form A real table — header, column rules, one bordered card — instead of dotted rows floating on the page background with nothing saying where the list began or what a column meant. ## The dial is the switch There was an `On` switch per lane beside the slots dial. Of four lanes, three must run for the application to work at all, so that switch offered a choice that was never real — and for the one lane that IS optional, "off" and "zero slots" were two ways of saying the same thing that could disagree with each other. So `enabled` is now DERIVED from the number: `set_lane` sets it from `slots > 0` when the caller did not say. It stays on the API and in the model — it is still the mechanism, and a drain-before-restart may still want a lane holding its process with consumers cancelled without destroying the operator's slot count to say so. Two things fell out that a test now pins: - The consumer command is sent on the CHANGE, not on the field being present. Otherwise every slots write re-sends a command that changes nothing — lesson #4183's churn, arriving through the new derivation. - The model fetch fires on the off→on TRANSITION. It used to test `enabled is True`, the field having been sent. The UI no longer sends it, so the download that makes the ML lane usable would simply never have fired and the lane would have come on to consume a queue it had no model for. ## And Auto now says what it is A legend under the table, in the operator's terms: what a slot is, that zero turns a lane off, that three of the four are not optional, what `of N` means, and that Auto lets a lane add slots by itself when its queue is backed up AND every slot is busy — with why it is off by default, since it is the only thing on the page that acts without being asked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
194 lines
7.5 KiB
JavaScript
194 lines
7.5 KiB
JavaScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import { setActivePinia, createPinia } from 'pinia'
|
|
import { laneStuckFor, 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 System tab polls this every 15s. 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 table 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')
|
|
})
|
|
})
|
|
|
|
// --- the long-running-task warning (step 7) ----------------------------------
|
|
//
|
|
// The operator asked for "a task runs for x concurrent time" to TRIGGER
|
|
// growth. It became a warning: extra slots do not make a running task finish
|
|
// sooner. What is pinned here is the AND — a warning that fires on half its
|
|
// condition is one people learn to ignore, and at that point it is worse than
|
|
// not having it.
|
|
|
|
describe('laneStuckFor', () => {
|
|
const lane = (over = {}) => ({
|
|
oldest_running_minutes: 40,
|
|
live: { present: true, pool: 4, active: 4 },
|
|
...over,
|
|
})
|
|
|
|
it('warns when every slot is busy and the oldest task is old', () => {
|
|
expect(laneStuckFor(lane())).toBe('40 minutes')
|
|
})
|
|
|
|
it('says nothing when the lane has a free slot', () => {
|
|
expect(laneStuckFor(lane({ live: { present: true, pool: 4, active: 3 } })))
|
|
.toBeNull()
|
|
})
|
|
|
|
it('says nothing for a saturated lane whose tasks are young', () => {
|
|
expect(laneStuckFor(lane({ oldest_running_minutes: 2 }))).toBeNull()
|
|
})
|
|
|
|
it('says nothing when nothing is running', () => {
|
|
expect(laneStuckFor(lane({ oldest_running_minutes: null }))).toBeNull()
|
|
})
|
|
|
|
it('says nothing about a lane that is not answering', () => {
|
|
// Unknown is not busy. A lane nothing answered for has no live reading to
|
|
// call saturated, and asserting one would be a verdict from an unswept
|
|
// read — the same distinction `present` exists for everywhere else here.
|
|
expect(laneStuckFor(lane({ live: { present: false, pool: null, active: 0 } })))
|
|
.toBeNull()
|
|
})
|
|
|
|
it('says nothing when the pool is zero', () => {
|
|
// A lane sized to zero is not "fully busy at zero" — it is off.
|
|
expect(laneStuckFor(lane({ live: { present: true, pool: 0, active: 0 } })))
|
|
.toBeNull()
|
|
})
|
|
|
|
it('switches to hours once minutes stop being readable', () => {
|
|
expect(laneStuckFor(lane({ oldest_running_minutes: 195 }))).toBe('3 hours')
|
|
})
|
|
})
|