diff --git a/app/serve-studio.mjs b/app/serve-studio.mjs index 9b9952c..a910de0 100644 --- a/app/serve-studio.mjs +++ b/app/serve-studio.mjs @@ -581,8 +581,16 @@ function factoryState() { const attachment = typeof d.attached === "boolean" ? { attached: d.attached, reason: d.reason ?? null, detail: d.detail ?? null } : {}; + // Finished work, when the generator sends it (optional; see the contract). Absent stays absent ("this provider doesn't send + // finished work"). Sent but not in the declared shape (rows not a list, a row that isn't an item with a string id) is carried as + // unreadable, never as "none finished", and never at the cost of the open work beside it. + const okRow = (r) => !!r && typeof r === "object" && !Array.isArray(r) && typeof r.id === "string"; + const done = d.done === undefined ? {} + : d.done && typeof d.done === "object" && Array.isArray(d.done.rows) && d.done.rows.every(okRow) + ? { done: { rows: d.done.rows, coverage: d.done.coverage && typeof d.done.coverage === "object" && !Array.isArray(d.done.coverage) ? d.done.coverage : {} } } + : { done: { rows: [], coverage: { unreadable: "the finished work this box sent wasn't in the declared shape" } } }; return { ok: true, rig: d.rig || "fixture", generatedAt: new Date().toISOString(), ...attachment, - seats: Array.isArray(d.seats) ? d.seats : [], queue: Array.isArray(d.queue) ? d.queue : [] }; + seats: Array.isArray(d.seats) ? d.seats : [], queue: Array.isArray(d.queue) ? d.queue : [], ...done }; } // ---- stream: SSE change-signal (signal-only contract) ---------------------- diff --git a/app/surfaces/floor.html b/app/surfaces/floor.html index 3654608..dbe6143 100644 --- a/app/surfaces/floor.html +++ b/app/surfaces/floor.html @@ -78,6 +78,16 @@ .queue-title { color: var(--text); font-weight: 600; } .queue-state { color: var(--idle); font-size: 10px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; } .route { color: var(--muted); overflow-wrap: anywhere; } + .item-id { display: block; margin-top: 3px; color: var(--muted); font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; } + .result { max-width: 40ch; white-space: pre-wrap; overflow-wrap: anywhere; } + .artifact { color: var(--muted); overflow-wrap: break-word; } + .unset { color: var(--muted); font-style: italic; } + .coverage { margin: -4px 0 10px; color: var(--muted); font-size: 11px; } + /* An element marked hidden stays hidden, whatever display a class gives it (the find box is display:flex). */ + [hidden] { display: none !important; } + .find { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin: 0 0 18px; } + .find input { flex: 1 1 260px; max-width: 420px; min-width: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); color: var(--text); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; } + .find output { color: var(--muted); font-size: 11px; } .notice { padding: 18px; color: var(--blocked); } footer { margin-top: 22px; color: var(--muted); font-size: 11px; } @media (max-width: 600px) { @@ -93,6 +103,7 @@
Live activity

Rig floor

connecting
+
Reading the runtime contract…
@@ -156,13 +167,56 @@ ${seat.ageMinutes == null ? 'unknown' : `${escapeHtml(seat.ageMinutes)}m`} ${escapeHtml(seat.pendingWork)} `).join('') || 'No seats reported.'; + // Ids and the find box come with finished work: a generator that sends no `done` gets the floor exactly as before. + const showIds = !!(state.done && Array.isArray(state.done.rows)); + document.querySelector('#findbox').hidden = !showIds; const queue = state.queue.map(item => ` - - ${escapeHtml(item.title)} + + ${escapeHtml(item.title)}${showIds ? `${escapeHtml(item.id)}` : ''} ${escapeHtml(item.state)} ${escapeHtml(item.source)} ${escapeHtml(item.destination)} `).join('') || 'Queue is clear.'; + // While a find is on, a table with no matching row says so instead of standing empty under its count. + const queueRows = queue + (showIds && state.queue.length ? 'No match in the queue.' : ''); + + // Work that finished, from a provider that sends `done` (an older one doesn't, and then this section isn't drawn). The answer + // a seat closed the work with is its result; a field the work doesn't carry reads "not recorded", and a result the provider + // couldn't read says so instead of looking empty. + const unset = (text) => `${text}`; + // A closure in plain words; the queue's own word stays in the tooltip. + const SAY = { 'no-follow-on': 'Done, nothing follows', handed_off_to: 'Handed off to', blocked_on: 'Blocked on', denied: 'Denied', canceled: 'Canceled', escalation: 'Escalated' }; + const decisionOf = (d) => !d?.reason ? unset('not recorded') + : `${escapeHtml(SAY[d.reason] || d.reason)}${d.target ? `${SAY[d.reason] && /to$|on$/.test(SAY[d.reason]) ? ' ' : ': '}${escapeHtml(d.target)}` : ''}`; + // An artifact reference may break after "@", ":", "/" or "#", not inside a word. + // Split the raw text first and escape each part: inserting after escaping would cut an entity (') apart. + const breakable = (t) => String(t).split(/(?<=[@:/#])/).map(escapeHtml).join(''); + const done = state.done && Array.isArray(state.done.rows) ? state.done : null; + const doneRows = done ? done.rows.map(item => { + if (!item || typeof item !== 'object') return ''; + const decision = decisionOf(item.decision); + const answer = item.result ?? item.resolution; + const result = item.resultRead === false ? unset("couldn't be read") : typeof answer === 'string' && answer !== '' ? escapeHtml(answer) : unset('not recorded'); + const closed = Date.parse(item.closedAt || ''); + return ` + + ${escapeHtml(item.title)}${escapeHtml(item.id)} + ${result} + ${decision} + ${item.artifact ? breakable(item.artifact) : unset('not recorded')} + ${Number.isFinite(closed) ? escapeHtml(new Date(closed).toLocaleString(undefined, { timeZoneName: 'short' })) : unset('unknown')} + `; }).join('') || `None of this rig's work among the newest ${escapeHtml(done.coverage?.looked ?? 0)} finished items on this box.` : ''; + const cov = done?.coverage || {}; + const doneSection = !done ? '' : cov.unreadable + ? `

Done recently

Finished work couldn't be read: ${escapeHtml(cov.unreadable)}.
` + : `
+

Done recently · ${done.rows.length}

+

The newest ${escapeHtml(done.rows.length)} of this rig's finished work, from the newest ${escapeHtml(cov.looked ?? 0)} finished items on this box.

+
+ + ${doneRows}${done.rows.length ? '' : ''} +
WorkResultDecisionArtifactClosed
+
`; content.innerHTML = ` ${welcomeHtml()} @@ -182,12 +236,37 @@

Queue · ${state.queue.length}

- ${queue} + ${queueRows}
WorkStateSourceDestination
- `; + ${doneSection}`; + lastState = state; + findWork(); updated.textContent = `Runtime snapshot · ${new Date(state.generatedAt).toLocaleString()}`; } + // Find by id: only among the work this floor already holds (this rig's open work and its newest finished work); it reads + // nothing else. Kept outside #content so a refresh doesn't clear what was typed, and applied again after every refresh. + let lastState = null; + const findInput = document.querySelector('#find'), found = document.querySelector('#found'); + // "This rig's" work is work with one of this floor's seats at either end: a generator that still sends other rigs' open work + // can't make the find box call that ours. + function findWork() { + const q = findInput.value.trim(); + const seats = new Set((lastState?.seats || []).map(s => s.seat)); + const ours = item => item && (seats.has(item.source) || seats.has(item.destination)); + const hit = item => ours(item) && String(item.id).includes(q); + const openHits = (lastState?.queue || []).filter(hit), doneHits = (lastState?.done?.rows || []).filter(hit); + const shown = new Set([...openHits, ...doneHits].map(item => String(item.id))); + for (const row of content.querySelectorAll('tr[data-id]')) row.hidden = !!q && !shown.has(row.dataset.id); + for (const [sel, hits] of [['#nomatch-open', openHits], ['#nomatch-done', doneHits]]) { const r = document.querySelector(sel); if (r) r.hidden = !q || hits.length > 0; } + if (!q) { found.textContent = ''; return; } + const done = lastState?.done; + found.textContent = openHits.length || doneHits.length ? `${openHits.length} open, ${doneHits.length} finished` + : done?.coverage?.unreadable ? "Not in this rig's open work. Finished work couldn't be read, so this can't say whether it finished." + : `No work with that id in this rig's open work or its newest ${done?.rows?.length ?? 0} finished items (older finished work isn't searched).`; + } + findInput.addEventListener('input', findWork); + // The box's own introduction, declared in its manifest. Absent on a box // that declares none, which is the normal case — this surface ships to // every install and must not assume it is anybody's landing page. diff --git a/contract/runtime-api.md b/contract/runtime-api.md index 1069ab3..152452e 100644 --- a/contract/runtime-api.md +++ b/contract/runtime-api.md @@ -110,6 +110,27 @@ by this contract (declared here, field-by-field), not by any backing system. the nearest minute, or `null` when no activity timestamp is known. It measures quietness, not uptime: a `working` seat can have a nonzero age. - Degraded shape: `{ "ok": false, "degraded": "", "rig": null, "seats": [], "queue": [] }`. +- `done` (optional, provisional): finished work, so work handed to a seat stays findable after it closes. Absent means the + generator doesn't send finished work; `rows: []` means none has finished. `queue` stays open work only. + +```json +"done": { + "rows": [ + { "id": "", "state": "done", "title": "", + "source": "", "destination": "", "closedAt": "", + "decision": { "reason": " | null", "target": " | null" }, + "result": " | null", "resultRead": true, + "resolution": " | null", "artifact": " | null", + "handedOffTo": " | null", "handedOffFrom": " | null" } + ], + "coverage": { "looked": 100, "ours": 12, "shown": 10, "limit": 100 } +} +``` + + A field the item doesn't carry is `null`, never invented. `resultRead: false` means the result couldn't be read (not that + there is none). `coverage` says how much was looked at; `{ "unreadable": "" }` there means finished work couldn't be read. + The runtime carries `done` through only in this shape: a `done` that is sent but isn't in it (rows not a list, a row without a + string `id`) arrives as `{ "rows": [], "coverage": { "unreadable": "" } }`, never as none, and the open work is kept. Its text is shown as written: this runtime has no private display mode. ## stream diff --git a/test/floor-done.test.mjs b/test/floor-done.test.mjs new file mode 100644 index 0000000..7c70f63 --- /dev/null +++ b/test/floor-done.test.mjs @@ -0,0 +1,139 @@ +// FLOOR keeps finished work findable: the runtime carries the generator's optional `done` block in its declared shape +// (contract/runtime-api.md), and the surface shows it as "Done recently" with "not recorded" where a field is missing and a +// find-by-id box that looks only at what the floor already holds. Invented fixture data only. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import net from "node:net"; +import path from "node:path"; +import vm from "node:vm"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const surface = fs.readFileSync(path.join(REPO, "app", "surfaces", "floor.html"), "utf8"); +const DONE = { rows: [ + { id: "qitem-20261023070000-aaaa", state: "done", title: "Fix the README typo.", source: "assistant@lab", destination: "lab-impl@lab", + closedAt: "2026-10-23T07:00:00.000Z", decision: { reason: "no-follow-on", target: null }, result: "Fixed line 12.", resultRead: true, + resolution: null, artifact: "docs/README.md@1a2b3c4", handedOffTo: null, handedOffFrom: null }, + { id: "qitem-20261023065900-bbbb", state: "done", title: "Answer a question", source: "lab-qa@lab", destination: "lab-impl@lab", + closedAt: "2026-10-23T06:59:00.000Z", decision: { reason: null, target: null }, result: null, resultRead: true, resolution: null, artifact: null }, + { id: "qitem-20261023065800-cccc", state: "done", title: "Unread", source: "lab-qa@lab", destination: "lab-impl@lab", + closedAt: "2026-10-23T06:58:00.000Z", decision: { reason: "handed_off_to", target: "qitem-x" }, result: null, resultRead: false, artifact: null }, +], coverage: { looked: 40, ours: 3, shown: 3, limit: 100 } }; +const SEATS = [{ seat: "lab-impl@lab" }, { seat: "lab-qa@lab" }]; +const OPEN = [{ id: "qitem-20261023071000-dddd", state: "pending", destination: "lab-impl@lab", source: "assistant@lab", tags: [], updated: "2026-10-23T07:10:00.000Z", title: "Open work" }]; + +const freePort = () => new Promise((resolve, reject) => { const s = net.createServer(); s.once("error", reject); s.listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => resolve(port)); }); }); +async function stateFrom(fixture) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "floor-done-")); + fs.cpSync(path.join(REPO, "app"), path.join(dir, "app"), { recursive: true }); + fs.cpSync(path.join(REPO, "fixtures"), path.join(dir, "fixtures"), { recursive: true }); + fs.writeFileSync(path.join(dir, "fixtures", "factory-state.json"), JSON.stringify(fixture)); + const port = await freePort(), proc = spawn(process.execPath, [path.join(dir, "app", "serve-studio.mjs"), "--port", String(port), "--fixtures", path.join(dir, "fixtures")], { cwd: dir, stdio: "ignore" }); + try { + for (let i = 0; i < 100; i++) { try { return await (await fetch(`http://127.0.0.1:${port}/api/factory/state`)).json(); } catch { await new Promise((r) => setTimeout(r, 100)); } } + throw new Error("runtime never answered"); + } finally { proc.kill(); fs.rmSync(dir, { recursive: true, force: true }); } +} + +test("the runtime carries `done` through in its declared shape; absent stays absent; a malformed one is carried as unreadable, never as none", async () => { + const s = await stateFrom({ rig: "lab", attached: true, seats: [], queue: OPEN, done: DONE }); + assert.deepEqual(s.done, DONE); assert.deepEqual(s.queue, OPEN, "queue is unchanged"); + assert.equal("done" in await stateFrom({ rig: "lab", attached: true, seats: [], queue: [] }), false, "a generator that sends no done: absent, not empty"); + for (const bad of ["nope", { rows: [null] }, { rows: [{ title: "no id" }] }, { rows: "x" }, null]) { + const s2 = await stateFrom({ rig: "lab", attached: true, seats: [], queue: OPEN, done: bad }); + assert.deepEqual(s2.done, { rows: [], coverage: { unreadable: "the finished work this box sent wasn't in the declared shape" } }, JSON.stringify(bad)); + assert.deepEqual(s2.queue, OPEN, "a bad finished-work block never costs the open work"); + } + assert.deepEqual((await stateFrom({ rig: "lab", attached: true, seats: [], queue: [], done: { rows: [] } })).done, { rows: [], coverage: {} }); +}); + +// The surface's own script, run with just enough of a page for render() and the find box. +function page() { + const el = (sel) => ({ sel, innerHTML: "", textContent: "", value: "", className: "", lastElementChild: { textContent: "" }, hidden: false, + addEventListener() {}, querySelectorAll() { return rowsOf(this.innerHTML); } }); + const els = { "#content": el("#content"), "#rig": el("#rig"), "#updated": el("#updated"), "#connection": el("#connection"), "#find": el("#find"), "#found": el("#found"), "#findbox": el("#findbox"), "#nomatch-open": el("#nomatch-open"), "#nomatch-done": el("#nomatch-done") }; + const rows = new Map(); + const rowsOf = (html) => [...html.matchAll(//g)].map((m) => rows.get(m[1]) || rows.set(m[1], { dataset: { id: m[1] }, hidden: false }).get(m[1])); + const ctx = { document: { querySelector: (s) => els[s] }, fetch: () => new Promise(() => {}), EventSource: function () {}, setInterval, clearInterval, setTimeout, clearTimeout, parent: { postMessage() {} }, console }; + vm.createContext(ctx); + vm.runInContext(surface.match(/