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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"postbuild": "chmod +x dist/cli.js && node -e \"const fs=require('node:fs');fs.rmSync('dist/web-static',{recursive:true,force:true});fs.cpSync('web/static','dist/web-static',{recursive:true})\"",
"start": "node --env-file=.env dist/index.js",
"stop": "pkill -f 'node --env-file=.env.*openswarm' 2>/dev/null || echo 'No process running'",
"lint": "oxlint src/",
"lint": "oxlint src/ web/static/js/",
"typecheck": "tsc --noEmit -p tsconfig.check.json",
"test": "node --experimental-vm-modules node_modules/vitest/vitest.mjs run",
"test:coverage": "node --experimental-vm-modules node_modules/vitest/vitest.mjs run --coverage",
Expand Down
1 change: 1 addition & 0 deletions src/support/dashboardHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
<a href="/orchestration">Orchestration</a>
<a href="/threads">Threads</a>
<a href="/warehouse">Warehouse</a>
<a href="/usage">Usage</a>
<a href="/issues">Issues</a>
</nav>
<div class="topbar-spacer"></div>
Expand Down
11 changes: 10 additions & 1 deletion src/support/staticAssets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
contentTypeFor, readAppShell, readStaticAsset, readThreadBoardShell, resolveStaticRoot, StaticAssetError,
contentTypeFor, readAppShell, readStaticAsset, readThreadBoardShell, readUsageShell,
resolveStaticRoot, StaticAssetError,
} from './staticAssets.js';

describe('contentTypeFor', () => {
Expand All @@ -26,6 +27,14 @@ describe('resolveStaticRoot', () => {
it('ships the durable repository thread shell', async () => {
expect((await readThreadBoardShell())?.toString()).toContain('Repository threads');
});

it('ships the usage shell, wired to its module (AGT-4289)', async () => {
// A shell that loses its <script> renders an empty page and still 200s,
// so assert the seam, not just that some HTML came back.
const shell = (await readUsageShell())?.toString() ?? '';
expect(shell).toContain('/static/js/usage.mjs');
expect(shell).toContain('/static/js/webToken.js');
});
});

describe('readStaticAsset', () => {
Expand Down
5 changes: 5 additions & 0 deletions src/support/staticAssets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ export async function readThreadBoardShell(): Promise<Buffer | null> {
return readShellFile('threads.html');
}

/** The usage dashboard shell (AGT-4289). */
export async function readUsageShell(): Promise<Buffer | null> {
return readShellFile('usage.html');
}

/** The /app entry document, or null when assets are not present. */
export async function readAppShell(): Promise<Buffer | null> {
return readShellFile('app.html');
Expand Down
67 changes: 67 additions & 0 deletions src/support/webAppRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Page shells were six copies of the same ten lines, one per route (AGT-4289).
// Copies drift: the 404-when-unbuilt path in particular is the one an operator
// meets after a fresh clone, and it existed six times with no test on any of
// them. These drive the single definition that replaced them.

import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ServerResponse } from 'node:http';

import { tryServePageShell } from './webAppRoutes.js';

/** A response that records what the handler did to it. */
function res() {
const sent: { status?: number; headers?: Record<string, string>; body?: unknown } = {};
return {
sent,
res: {
writeHead: (status: number, headers: Record<string, string>) => { sent.status = status; sent.headers = headers; },
end: (body: unknown) => { sent.body = body; },
} as unknown as ServerResponse,
};
}

afterEach(() => { vi.restoreAllMocks(); });

const PAGES = ['/orchestration', '/chat', '/warehouse', '/usage', '/threads', '/app'];

describe('tryServePageShell', () => {
it.each(PAGES)('serves %s as HTML from the built assets', async (url) => {
const { res: r, sent } = res();

await expect(tryServePageShell(r, url)).resolves.toBe(true);

expect(sent.status).toBe(200);
expect(sent.headers?.['Content-Type']).toContain('text/html');
// The shells are a build product; a stale cache would serve yesterday's page.
expect(sent.headers?.['Cache-Control']).toBe('no-cache');
expect(String(sent.body)).toContain('<!doctype html>');
});

it('serves the usage page with its module and token script attached', async () => {
// A shell that loses either one still returns 200 and renders nothing
// useful: no data without the module, no data off-localhost without the
// token wrapper.
const { res: r, sent } = res();
await tryServePageShell(r, '/usage');
expect(String(sent.body)).toContain('/static/js/usage.mjs');
expect(String(sent.body)).toContain('/static/js/webToken.js');
});

it('declines a URL that names no page, so later routes still run', async () => {
const { res: r, sent } = res();
await expect(tryServePageShell(r, '/api/usage')).resolves.toBe(false);
expect(sent.status).toBeUndefined();
});

it('says how to build the assets rather than failing opaquely', async () => {
// What an operator meets on a fresh clone before `npm run build`.
const staticAssets = await import('./staticAssets.js');
vi.spyOn(staticAssets, 'readUsageShell').mockResolvedValue(null);
const { res: r, sent } = res();

await expect(tryServePageShell(r, '/usage')).resolves.toBe(true);

expect(sent.status).toBe(404);
expect(String(sent.body)).toContain('npm run build');
});
});
99 changes: 40 additions & 59 deletions src/support/webAppRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import type { IncomingMessage, ServerResponse } from 'node:http';
import type { AutonomousRunner } from '../automation/autonomousRunner.js';
import { readAppShell, readStaticAsset, StaticAssetError } from './staticAssets.js';
import { readStaticAsset, StaticAssetError } from './staticAssets.js';

function writeJson(res: ServerResponse, statusCode: number, body: unknown): void {
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
Expand All @@ -26,6 +26,44 @@ function messageOf(err: unknown): string {
return 'Internal error';
}

/**
* Page shells: a URL, and the reader that returns its HTML.
*
* These were six copies of the same ten lines. One table and one helper means
* a new page is one entry, and the 404-when-unbuilt behaviour has a single
* definition rather than six that can drift apart.
*/
const PAGE_SHELLS: Record<string, () => Promise<Buffer | null>> = {
'/orchestration': async () => (await import('./staticAssets.js')).readOrchestrationShell(),
'/chat': async () => (await import('./staticAssets.js')).readChatShell(),
'/warehouse': async () => (await import('./staticAssets.js')).readWarehouseShell(),
'/usage': async () => (await import('./staticAssets.js')).readUsageShell(),
'/threads': async () => (await import('./staticAssets.js')).readThreadBoardShell(),
'/app': async () => (await import('./staticAssets.js')).readAppShell(),
};

/**
* Serve the shell registered for this URL. Returns false when the URL names no
* page, so the caller falls through to the routes after it.
*
* A missing shell is 404, not 500: the assets are a build product, and the
* message says how to produce them.
*/
export async function tryServePageShell(res: ServerResponse, url: string): Promise<boolean> {
// hasOwn, not a bare index: `url` is request input, and this must not
// depend on the caller's `/`-prefix invariant surviving a future change.
if (!Object.hasOwn(PAGE_SHELLS, url)) return false;
const load = PAGE_SHELLS[url];
const shell = await load();
if (!shell) {
writeJson(res, 404, { error: 'Static assets not built (run npm run build)' });
} else {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
res.end(shell);
}
return true;
}

/**
* Handle the /app, /static/*, and /api/work* routes. Returns true when the
* request was handled. `readBody` is injected from web.ts so its size limit
Expand Down Expand Up @@ -66,64 +104,7 @@ export async function tryHandleAppRoutes(
if (await tryHandleWorkSessionRoutes(req, res, url, requestUrl, runner)) return true;
}

if (url === '/orchestration') {
const { readOrchestrationShell } = await import('./staticAssets.js');
const shell = await readOrchestrationShell();
if (!shell) {
writeJson(res, 404, { error: 'Static assets not built (run npm run build)' });
} else {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
res.end(shell);
}
return true;
}

if (url === '/chat') {
const { readChatShell } = await import('./staticAssets.js');
const shell = await readChatShell();
if (!shell) {
writeJson(res, 404, { error: 'Static assets not built (run npm run build)' });
} else {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
res.end(shell);
}
return true;
}

if (url === '/warehouse') {
const { readWarehouseShell } = await import('./staticAssets.js');
const shell = await readWarehouseShell();
if (!shell) {
writeJson(res, 404, { error: 'Static assets not built (run npm run build)' });
} else {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
res.end(shell);
}
return true;
}

if (url === '/threads') {
const { readThreadBoardShell } = await import('./staticAssets.js');
const shell = await readThreadBoardShell();
if (!shell) {
writeJson(res, 404, { error: 'Static assets not built (run npm run build)' });
} else {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
res.end(shell);
}
return true;
}

if (url === '/app') {
const shell = await readAppShell();
if (!shell) {
writeJson(res, 404, { error: 'Static assets not built (run npm run build)' });
} else {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
res.end(shell);
}
return true;
}
if (await tryServePageShell(res, url)) return true;

if (url.startsWith('/static/')) {
try {
Expand Down
2 changes: 1 addition & 1 deletion tests/web/tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ describe('page shells (AGT-4201)', () => {
});

it('share one navigation naming every page', () => {
const routes = ['/app', '/chat', '/orchestration', '/threads', '/warehouse'];
const routes = ['/app', '/chat', '/orchestration', '/threads', '/warehouse', '/usage'];
for (const file of html) {
const source = readFileSync(file, 'utf8');
const name = relative(ROOT, file);
Expand Down
Loading
Loading