From 738abe2fedf0dde019f761ac7e154f206f5e73b4 Mon Sep 17 00:00:00 2001 From: unohee Date: Mon, 19 Oct 2026 15:34:11 +0900 Subject: [PATCH] feat(web): first-run guidance for the warehouse and a local-tracker Cockpit - Warehouse: a missing warehouse folder now shows setup steps (the folder the daemon expects, OPENSWARM_WAREHOUSE_ROOT / _WRITE_ROOT, restart) instead of a red 'not provisioned' error and an upload form that could only fail. - Cockpit: /api/shell-status reports which tracker the daemon works from. On a local-tracker daemon the Issues panel explains that Cockpit deploys Linear issues, links to the local /issues board, and names the Linear settings; the 503 'Linear is not configured' becomes the same note. --- src/support/warehouseRoutes.ts | 3 +- src/support/web.ts | 7 +++- src/support/webShellStatus.test.ts | 6 +++ src/support/webShellStatus.ts | 10 +++++ tests/web/issueBoard.test.ts | 61 ++++++++++++++++++++++++++++++ tests/web/warehouseSetup.test.ts | 34 +++++++++++++++++ web/static/css/app.css | 7 ++++ web/static/css/warehouse.css | 7 ++++ web/static/js/issueBoard.mjs | 51 +++++++++++++++++++++++++ web/static/js/main.mjs | 4 +- web/static/js/warehouse.mjs | 22 ++++++++++- web/static/warehouse.html | 11 ++++++ 12 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 tests/web/issueBoard.test.ts create mode 100644 tests/web/warehouseSetup.test.ts diff --git a/src/support/warehouseRoutes.ts b/src/support/warehouseRoutes.ts index c911ff71..2b6e3dff 100644 --- a/src/support/warehouseRoutes.ts +++ b/src/support/warehouseRoutes.ts @@ -45,7 +45,8 @@ function safeRelativePath(raw: string | null, allowEmpty: boolean): string { } function existingRoot(root: string): string { - if (!existsSync(root)) throw new WarehouseHttpError(503, 'Warehouse is not provisioned'); + // Name the folder: the page turns this into setup steps (redesign step 9). + if (!existsSync(root)) throw new WarehouseHttpError(503, `Warehouse is not provisioned: ${root} does not exist`); return realpathSync(root); } diff --git a/src/support/web.ts b/src/support/web.ts index 09740016..4dbc79c1 100644 --- a/src/support/web.ts +++ b/src/support/web.ts @@ -378,7 +378,12 @@ export async function startWebServer(port: number = 3847): Promise { } else if (url === '/api/shell-status' && req.method === 'GET') { const { buildShellStatus } = await import('./webShellStatus.js'); const { getCoordinationStore } = await import('../coordination/coordinationStore.js'); - writeJson(res, 200, await buildShellStatus({ runner: runnerRef, pendingEvents: () => getCoordinationStore().snapshot().pending })); + const { isLinearInitialized } = await import('../linear/linear.js'); + writeJson(res, 200, await buildShellStatus({ + runner: runnerRef, + pendingEvents: () => getCoordinationStore().snapshot().pending, + linearConfigured: isLinearInitialized, + })); } else if (url === '/api/stats') { const stats = runnerRef?.getStats(); diff --git a/src/support/webShellStatus.test.ts b/src/support/webShellStatus.test.ts index 12b29d19..295bb041 100644 --- a/src/support/webShellStatus.test.ts +++ b/src/support/webShellStatus.test.ts @@ -22,10 +22,16 @@ describe('buildShellStatus', () => { daemon: { state: 'dashboard-only', reason: 'No daemon runner is attached to this dashboard', uptimeMs: null }, provider: null, needsYou: { questions: 1, parked: 0, total: 1 }, + tracker: 'local', generatedAt: 5_000, }); }); + it('names the tracker the daemon reads issues from', async () => { + expect((await buildShellStatus({ pendingEvents: () => [], linearConfigured: () => true })).tracker).toBe('linear'); + expect((await buildShellStatus({ runner: runner(), pendingEvents: () => [], linearConfigured: () => false })).tracker).toBe('local'); + }); + it('counts parked runs and open human questions, not other pending exchanges', async () => { const status = await buildShellStatus({ runner: runner({ parked: 2 }), diff --git a/src/support/webShellStatus.ts b/src/support/webShellStatus.ts index b5360f51..c2e68485 100644 --- a/src/support/webShellStatus.ts +++ b/src/support/webShellStatus.ts @@ -18,6 +18,12 @@ export interface ShellStatus { daemon: { state: DaemonState; reason: string | null; uptimeMs: number | null }; provider: { adapter: string; model: string | null } | null; needsYou: { questions: number; parked: number; total: number }; + /** + * Where the daemon reads issues from: Linear when a key is configured, + * otherwise its built-in local tracker (selectTaskSource). First-run copy + * depends on it โ€” Cockpit lists Linear issues only. + */ + tracker: 'linear' | 'local'; generatedAt: number; } @@ -31,8 +37,10 @@ export interface ShellStatusRunner { export async function buildShellStatus(deps: { runner?: ShellStatusRunner; pendingEvents: () => readonly CoordinationEvent[]; + linearConfigured?: () => boolean; now?: () => number; }): Promise { + const tracker = deps.linearConfigured?.() ? 'linear' : 'local'; const now = deps.now?.() ?? Date.now(); // A human question is the one pending exchange that waits on the operator; // the board holds it until a human-answer or an expiry settles it. @@ -43,6 +51,7 @@ export async function buildShellStatus(deps: { daemon: { state: 'dashboard-only', reason: 'No daemon runner is attached to this dashboard', uptimeMs: null }, provider: null, needsYou: { questions, parked: 0, total: questions }, + tracker, generatedAt: now, }; } @@ -62,6 +71,7 @@ export async function buildShellStatus(deps: { }, provider: summary ? { adapter: summary.defaultAdapter, model: summary.worker?.model ?? null } : null, needsYou: { questions, parked, total: questions + parked }, + tracker, generatedAt: now, }; } diff --git a/tests/web/issueBoard.test.ts b/tests/web/issueBoard.test.ts new file mode 100644 index 00000000..a29a6fe3 --- /dev/null +++ b/tests/web/issueBoard.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +// +// Cockpit first run (redesign step 9): a daemon without Linear works from the +// local tracker, and Cockpit's issue list used to answer that with a bare +// "Linear is not configured on this daemon". It now explains where the local +// issues live. +import { describe, expect, it, vi } from 'vitest'; +// @ts-expect-error โ€” browser ESM asset without type declarations +import { IssueBoard } from '../../web/static/js/issueBoard.mjs'; + +function mount(fetchIssues: (path: string) => Promise) { + document.body.innerHTML = ` +
Select a repository to load its issues.
+ `; + const board = new IssueBoard({ + listEl: document.getElementById('list'), + countEl: document.getElementById('count'), + summaryEl: document.getElementById('summary'), + deployBtn: document.getElementById('deploy'), + fetchIssues, + dispatch: vi.fn(), + }); + return { board, list: document.getElementById('list')! }; +} + +function linearMissing() { + return Object.assign(new Error('Linear is not configured on this daemon'), { status: 503 }); +} + +describe('issue board on a local-tracker daemon', () => { + it('explains the local tracker up front instead of asking for a repository', () => { + const { board, list } = mount(vi.fn()); + board.setTracker('local'); + expect(list.querySelector('.tracker-help strong')!.textContent).toMatch(/local tracker/); + expect(list.querySelector('.tracker-help a')!.getAttribute('href')).toBe('/issues'); + expect(list.textContent).toContain('LINEAR_API_KEY'); + }); + + it('keeps the repository prompt when the daemon works from Linear', () => { + const { board, list } = mount(vi.fn()); + board.setTracker('linear'); + expect(list.querySelector('.tracker-help')).toBeNull(); + expect(list.textContent).toContain('Select a repository'); + }); + + it('turns the 503 into the same explanation rather than the raw error', async () => { + const { board, list } = mount(vi.fn(async () => { throw linearMissing(); })); + await board.loadFor('/repo'); + expect(list.querySelector('.tracker-help')).not.toBeNull(); + expect(list.textContent).not.toContain('Linear is not configured on this daemon'); + }); + + it('still shows other failures as they are', async () => { + const { board, list } = mount(vi.fn(async () => { + throw Object.assign(new Error('Repository not found'), { status: 404 }); + })); + await board.loadFor('/repo'); + expect(list.querySelector('.tracker-help')).toBeNull(); + expect(list.textContent).toBe('Repository not found'); + }); +}); diff --git a/tests/web/warehouseSetup.test.ts b/tests/web/warehouseSetup.test.ts new file mode 100644 index 00000000..00d25419 --- /dev/null +++ b/tests/web/warehouseSetup.test.ts @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +// +// First run (redesign step 9): a missing warehouse folder used to show a red +// "Warehouse is not provisioned" and an upload form that could only fail. The +// page now says what the folder is for and how to create it. +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +beforeAll(async () => { + document.body.innerHTML = ` + +
+ +
+
+ `; + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Warehouse is not provisioned: /srv/warehouse does not exist' }), + { status: 503, headers: { 'Content-Type': 'application/json' } }, + ))); + await import('../../web/static/js/warehouse.mjs'); +}); + +describe('warehouse first run', () => { + it('replaces the error with setup steps naming the folder the daemon expects', async () => { + await vi.waitFor(() => expect((document.getElementById('setup') as HTMLElement).hidden).toBe(false)); + expect(document.getElementById('setup-folder')!.textContent).toBe('/srv/warehouse'); + expect((document.querySelector('.browser') as HTMLElement).hidden).toBe(true); + expect((document.querySelector('.upload') as HTMLElement).hidden).toBe(true); + }); +}); diff --git a/web/static/css/app.css b/web/static/css/app.css index 8f9db835..90201e5b 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -72,6 +72,13 @@ cursor: pointer; user-select: none; } +/* Local-tracker explanation (redesign step 9): reads as a short note, not a + centred placeholder, so the measure stays narrow and the hint quieter. */ +.tracker-help { max-width: 46ch; margin: var(--space-6) auto 0; gap: var(--space-3); } +.tracker-help strong { color: var(--fg-primary); font-size: var(--text-base); } +.tracker-help p { margin: 0; line-height: 1.5; } +.tracker-help .hint { font-size: var(--text-xs); } +.tracker-help code { font-size: inherit; } .issue-row:hover { background: var(--bg-hover); } .issue-row.selected { background: var(--accent-subtle); } .issue-row input[type="checkbox"] { accent-color: var(--accent); } diff --git a/web/static/css/warehouse.css b/web/static/css/warehouse.css index e6b0112e..7517c5b5 100644 --- a/web/static/css/warehouse.css +++ b/web/static/css/warehouse.css @@ -110,3 +110,10 @@ td.empty { padding: var(--space-12); text-align: center; white-space: normal; } .overwrite { min-height: 44px; } th:nth-child(2), td:nth-child(2), th:nth-child(4), td:nth-child(4) { display: none; } } + +/* First-run setup, in place of the browser and upload panels. */ +.setup { max-width: 64ch; padding: var(--space-6) 0; } +.setup[hidden], .browser[hidden], .upload[hidden] { display: none; } +.setup h2 { margin: 0 0 var(--space-2); font-size: var(--text-lg); font-weight: 600; color: var(--fg-primary); } +.setup p, .setup li { color: var(--fg-secondary); line-height: var(--leading-body); } +.setup ol { margin: var(--space-3) 0 0; padding-left: var(--space-6); display: grid; gap: var(--space-2); } diff --git a/web/static/js/issueBoard.mjs b/web/static/js/issueBoard.mjs index a8eeac68..219c2d64 100644 --- a/web/static/js/issueBoard.mjs +++ b/web/static/js/issueBoard.mjs @@ -18,6 +18,7 @@ export class IssueBoard { #renderSequence = 0; #armed = false; #armTimer = null; + #tracker = ''; constructor({ listEl, countEl, summaryEl, deployBtn, fetchIssues, dispatch, onDeployed }) { this.#listEl = listEl; @@ -31,6 +32,17 @@ export class IssueBoard { this.#deployBtn.addEventListener('click', () => this.#deploy()); } + /** + * Which tracker the daemon works from ('linear' | 'local'), read from + * /api/shell-status. A local-tracker daemon has nothing for Cockpit to + * deploy, so say so before the operator picks a repository and waits for + * an error (redesign step 9). + */ + setTracker(tracker) { + this.#tracker = tracker ?? ''; + if (this.#tracker === 'local' && !this.#projectPath) this.#renderLocalTracker(); + } + async loadFor(projectPath) { this.#projectPath = projectPath; this.#selected.clear(); @@ -41,6 +53,10 @@ export class IssueBoard { payload = await this.#fetchIssues(projectPath); } catch (err) { if (seq !== this.#renderSequence) return; + if (err?.status === 503 && /Linear is not configured/.test(err?.message ?? '')) { + this.#renderLocalTracker(); + return; + } this.#renderMessage(err?.message ?? 'Failed to load issues'); return; } @@ -108,6 +124,41 @@ export class IssueBoard { this.#listEl.replaceChildren(div); } + // Cockpit lists and deploys Linear issues only. Without Linear the daemon + // takes work from its local tracker by itself, and those issues live on the + // /issues board, so point there instead of showing the bare 503. + #renderLocalTracker() { + const div = document.createElement('div'); + div.className = 'empty tracker-help'; + + const head = document.createElement('strong'); + head.textContent = 'This daemon works from the local tracker'; + + const why = document.createElement('p'); + why.textContent = 'Cockpit deploys Linear issues. Without Linear, the daemon picks up ' + + 'issues from its local tracker on its own โ€” there is nothing to deploy from here.'; + + const link = document.createElement('a'); + link.className = 'btn'; + link.href = '/issues'; + link.textContent = 'Open the local issue board'; + + const how = document.createElement('p'); + how.className = 'hint'; + how.append('To deploy Linear issues here, set '); + for (const [i, name] of ['LINEAR_API_KEY', 'LINEAR_TEAM_ID'].entries()) { + if (i) how.append(' and '); + const code = document.createElement('code'); + code.textContent = name; + how.append(code); + } + how.append(' for the daemon and restart it.'); + + div.append(head, why, link, how); + this.#listEl.replaceChildren(div); + this.#countEl.textContent = ''; + } + #syncFooter() { this.#disarm(); const n = this.#selected.size; diff --git a/web/static/js/main.mjs b/web/static/js/main.mjs index c53ff88f..3750c1c1 100644 --- a/web/static/js/main.mjs +++ b/web/static/js/main.mjs @@ -17,7 +17,7 @@ import { Nav } from './nav.mjs'; import { resolveSelection } from './sessionSelection.mjs'; import { mountShell } from './shell.mjs'; -mountShell(document); +const shell = mountShell(document); const statusDot = document.querySelector('#daemon-status .dot'); const statusLabel = document.querySelector('#daemon-status .label'); @@ -170,6 +170,8 @@ const board = new IssueBoard({ }, }); +shell.ready.then((outcome) => board.setTracker(outcome?.status?.tracker)); + const picker = new RepoPicker(document.getElementById('repo-picker'), { onSelect: (path) => { board.loadFor(path); diff --git a/web/static/js/warehouse.mjs b/web/static/js/warehouse.mjs index 362ba440..34d09099 100644 --- a/web/static/js/warehouse.mjs +++ b/web/static/js/warehouse.mjs @@ -44,7 +44,9 @@ async function api(url, options) { if (!response.ok) { let message = `HTTP ${response.status}`; try { message = (await response.json()).error || message; } catch { /* non-JSON */ } - throw new Error(message); + const error = new Error(message); + error.status = response.status; + throw error; } return response; } @@ -148,11 +150,29 @@ async function loadTree(path) { renderBreadcrumbs(); renderEntries(payload.entries); } catch (error) { + if (error.status === 503 && /not provisioned/.test(error.message) && showSetup(error.message)) return; entriesBody.innerHTML = ``; entriesBody.querySelector('td').textContent = error.message; } } +/** + * First run: the folder does not exist yet. A red "Warehouse is not + * provisioned" told the operator something was wrong but not what to do, and + * left an upload form that could only fail. + */ +function showSetup(message) { + const folder = message.match(/: (.+) does not exist$/)?.[1] ?? '/warehouse'; + const setup = document.querySelector('#setup'); + // A page from before the setup panel existed (a stale cache) keeps the + // plain error instead of throwing. + if (!setup) return false; + setup.querySelector('#setup-folder').textContent = folder; + setup.hidden = false; + for (const panel of document.querySelectorAll('.browser, .upload')) panel.hidden = true; + return true; +} + fileInput.addEventListener('change', () => { const file = fileInput.files?.[0]; fileLabel.textContent = file ? `${file.name} ยท ${formatBytes(file.size)}` : 'Choose a file'; diff --git a/web/static/warehouse.html b/web/static/warehouse.html index e2b5c552..6cb8e116 100644 --- a/web/static/warehouse.html +++ b/web/static/warehouse.html @@ -15,6 +15,17 @@
+ + +