Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/support/warehouseRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
7 changes: 6 additions & 1 deletion src/support/web.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// ============================================
// OpenSwarm - Web Interface
// ============================================
Expand Down Expand Up @@ -378,7 +378,12 @@
} 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();
Expand Down
6 changes: 6 additions & 0 deletions src/support/webShellStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
10 changes: 10 additions & 0 deletions src/support/webShellStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -31,8 +37,10 @@ export interface ShellStatusRunner {
export async function buildShellStatus(deps: {
runner?: ShellStatusRunner;
pendingEvents: () => readonly CoordinationEvent[];
linearConfigured?: () => boolean;
now?: () => number;
}): Promise<ShellStatus> {
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.
Expand All @@ -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,
};
}
Expand All @@ -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,
};
}
61 changes: 61 additions & 0 deletions tests/web/issueBoard.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>) {
document.body.innerHTML = `
<span id="count"></span><div id="list"><div class="empty">Select a repository to load its issues.</div></div>
<span id="summary"></span><button id="deploy"></button>`;
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<HTMLAnchorElement>('.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');
});
});
34 changes: 34 additions & 0 deletions tests/web/warehouseSetup.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<section id="setup" hidden><code id="setup-folder"></code></section>
<section class="browser">
<button id="up"></button><nav id="breadcrumbs"></nav><button id="refresh"></button>
<table><tbody id="entries"></tbody></table>
</section>
<aside class="upload">
<input id="file" type="file"><span id="file-label"></span>
<input id="overwrite" type="checkbox"><button id="upload"></button>
<span id="upload-directory"></span><p id="status"></p>
</aside>`;
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);
});
});
7 changes: 7 additions & 0 deletions web/static/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
7 changes: 7 additions & 0 deletions web/static/css/warehouse.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
51 changes: 51 additions & 0 deletions web/static/js/issueBoard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion web/static/js/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 21 additions & 1 deletion web/static/js/warehouse.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 = `<tr><td colspan="5" class="empty error"></td></tr>`;
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';
Expand Down
11 changes: 11 additions & 0 deletions web/static/warehouse.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@
</header>

<main class="warehouse-main">
<!-- First run: shown by warehouse.mjs when the folder does not exist. -->
<section id="setup" class="setup" aria-labelledby="setup-title" hidden>
<h2 id="setup-title">Set up the warehouse</h2>
<p>The warehouse is a folder you share with agents: they read it, you upload to it here. This daemon looks for it at <code id="setup-folder">/warehouse</code>, which does not exist yet.</p>
<ol>
<li>Create that folder, or choose another one with <code>OPENSWARM_WAREHOUSE_ROOT</code> in the daemon's environment.</li>
<li>To let this page upload while agents keep read-only access, set <code>OPENSWARM_WAREHOUSE_WRITE_ROOT</code> to the same folder seen from the daemon's side (in Docker, the writable mount).</li>
<li>Restart the daemon, then refresh this page.</li>
</ol>
</section>

<section class="browser" aria-label="Warehouse files">
<div class="toolbar">
<button id="up" type="button" class="btn btn-secondary btn-sm">Parent folder</button>
Expand Down
Loading