Add shared contracts and migrate project code to TypeScript (#118)

This commit is contained in:
nettee 2026-04-30 13:01:15 +08:00 committed by GitHub
parent 087fbe7768
commit 56d08b8c5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 2006 additions and 429 deletions

3
.gitignore vendored
View file

@ -38,3 +38,6 @@ tsconfig.tsbuildinfo
# Commander task scratchpad; keep local task notes out of git by default. # Commander task scratchpad; keep local task notes out of git by default.
.task/ .task/
task.md
specs/change/active
.ralph/

View file

@ -2,10 +2,11 @@
## Project shape ## Project shape
- pnpm workspace with packages from `pnpm-workspace.yaml`: `apps/web`, `apps/daemon`, and `e2e`. - pnpm workspace with packages from `pnpm-workspace.yaml`: `apps/web`, `apps/daemon`, `packages/contracts`, and `e2e`.
- Runtime target is Node `~24` with `pnpm@10.33.2`; use Corepack so the pinned pnpm version from `package.json` is selected. - Runtime target is Node `~24` with `pnpm@10.33.2`; use Corepack so the pinned pnpm version from `package.json` is selected.
- `apps/web` is a Next.js 16 App Router + React 18 client. Entrypoints: `apps/web/app/`, main client shell `apps/web/src/App.tsx`. - `apps/web` is a Next.js 16 App Router + React 18 client. Entrypoints: `apps/web/app/`, main client shell `apps/web/src/App.tsx`.
- `apps/daemon` is the local Express + SQLite process and the `od` bin (`apps/daemon/cli.js`). It owns `/api/*`, agent spawning, skills, design systems, artifacts, and static serving. - `packages/contracts` is the shared, pure TypeScript web/daemon contract layer for API DTOs, SSE events, task states, and unified errors.
- `apps/daemon` is the local Express + SQLite process and the `od` bin (`apps/daemon/dist/cli.js` after build). It owns `/api/*`, agent spawning, skills, design systems, artifacts, and static serving.
- `e2e` contains both Playwright UI specs (`e2e/specs`) and Vitest/jsdom integration tests (`e2e/tests`). - `e2e` contains both Playwright UI specs (`e2e/specs`) and Vitest/jsdom integration tests (`e2e/tests`).
## Commands ## Commands
@ -15,10 +16,19 @@
- Web only: `pnpm dev` from the root starts Next; pair it with `pnpm daemon` when API routes are needed. - Web only: `pnpm dev` from the root starts Next; pair it with `pnpm daemon` when API routes are needed.
- Production local path: `pnpm build` writes the static Next export to `apps/web/out/`; `pnpm start` builds and serves that export through the daemon. - Production local path: `pnpm build` writes the static Next export to `apps/web/out/`; `pnpm start` builds and serves that export through the daemon.
- Main verification: `pnpm typecheck && pnpm test && pnpm build` - Main verification: `pnpm typecheck && pnpm test && pnpm build`
- Residual JavaScript check: `pnpm check:residual-js`; root `pnpm typecheck` runs it after package and support typechecks.
- Package tests: `pnpm --filter @open-design/web test`, `pnpm --filter @open-design/daemon test`, `pnpm --filter @open-design/e2e test` - Package tests: `pnpm --filter @open-design/web test`, `pnpm --filter @open-design/daemon test`, `pnpm --filter @open-design/e2e test`
- Focused Vitest: `pnpm --dir apps/web exec vitest run -c vitest.config.ts src/providers/sse.test.ts` (adjust package dir and test path as needed). - Focused Vitest: `pnpm --dir apps/web exec vitest run -c vitest.config.ts src/providers/sse.test.ts` (adjust package dir and test path as needed).
- Playwright UI: `pnpm test:ui`; headed: `pnpm test:ui:headed`. Playwright starts `pnpm dev:all` with isolated data under `e2e/.od-data` and strict dynamic ports. - Playwright UI: `pnpm test:ui`; headed: `pnpm test:ui:headed`. Playwright starts `pnpm dev:all` with isolated data under `e2e/.od-data` and strict dynamic ports.
- Live adapter smoke: `pnpm test:e2e:live` runs `e2e/scripts/runtime-adapter.e2e.live.test.mjs`. - Live adapter smoke: `pnpm test:e2e:live` runs `e2e/scripts/runtime-adapter.e2e.live.test.ts` through Node strip-types.
## TypeScript and boundary conventions
- New project-owned entrypoints, modules, scripts, tests, reporters, and configs use TypeScript. The residual JavaScript allowlist is limited to generated output, vendored dependencies, and compatibility build artifacts such as `apps/daemon/dist/**/*.{js,mjs,cjs}`, `apps/web/.next/**/*.{js,mjs,cjs}`, and `apps/web/out/**/*.{js,mjs,cjs}`.
- Shared web/daemon contracts go in `packages/contracts`; keep this package free of Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, and daemon internals.
- Keep UI-only state and presentation unions in `apps/web`; import daemon-facing API, SSE, task, and error contracts from `@open-design/contracts`.
- Keep local capability logic in `apps/daemon`: filesystem, SQLite, agent CLI spawning, task lifecycle, logs, artifacts, skills, design systems, and static serving.
- Runtime validation policy and schema enforcement belong to the later validation workstream; current shared contracts define the typed target shape.
## Runtime data and ports ## Runtime data and ports
@ -29,15 +39,15 @@
## Agent, skill, and design-system wiring ## Agent, skill, and design-system wiring
- The daemon scans `PATH` for local CLIs in `apps/daemon/agents.js` and spawns them with `cwd` pinned to `.od/projects/<id>/`. - The daemon scans `PATH` for local CLIs in `apps/daemon/agents.ts` and spawns them with `cwd` pinned to `.od/projects/<id>/`.
- Agent stdout parsing is per transport: Claude stream JSON, Copilot stream JSON, ACP JSON-RPC, or plain text. Changes to CLI args belong in `apps/daemon/agents.js` and matching parser tests. - Agent stdout parsing is per transport: Claude stream JSON, Copilot stream JSON, ACP JSON-RPC, or plain text. Changes to CLI args belong in `apps/daemon/agents.ts` and matching parser tests.
- Skills are folder bundles under `skills/` with `SKILL.md`; extended `od:` frontmatter is parsed by `apps/daemon/skills.js`. Restart the daemon after adding or changing skill folders. - Skills are folder bundles under `skills/` with `SKILL.md`; extended `od:` frontmatter is parsed by `apps/daemon/skills.ts`. Restart the daemon after adding or changing skill folders.
- Design systems are `design-systems/*/DESIGN.md`; `scripts/sync-design-systems.mjs` re-imports upstream systems. - Design systems are `design-systems/*/DESIGN.md`; `scripts/sync-design-systems.ts` re-imports upstream systems.
- Prompt composition lives in `apps/web/src/prompts/system.ts`, `discovery.ts`, and `directions.ts`; artifacts are parsed/rendered through `apps/web/src/artifacts/` and `apps/web/src/runtime/`. - Prompt composition lives in `apps/web/src/prompts/system.ts`, `discovery.ts`, and `directions.ts`; artifacts are parsed/rendered through `apps/web/src/artifacts/` and `apps/web/src/runtime/`.
## Testing notes ## Testing notes
- Web Vitest includes `apps/web/src/**/*.test.{ts,tsx,js,mjs,cjs}` in a Node environment. - Web Vitest includes `apps/web/src/**/*.test.{ts,tsx}` in a Node environment.
- Daemon Vitest includes `apps/daemon/**/*.test.{ts,tsx,js,mjs,cjs}` in a Node environment. - Daemon Vitest includes `apps/daemon/**/*.test.{ts,tsx}` in a Node environment.
- E2E Vitest includes `e2e/tests/**/*.test.{ts,tsx}` in jsdom with automatic React JSX. - E2E Vitest includes `e2e/tests/**/*.test.{ts,tsx}` in jsdom with automatic React JSX.
- Playwright uses Chromium only, writes reports under `e2e/reports/`, and reuses an existing server outside CI. - Playwright uses Chromium only, writes reports under `e2e/reports/`, and reuses an existing server outside CI.

View file

@ -1,3 +1,4 @@
// @ts-nocheck
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import path from 'node:path'; import path from 'node:path';

View file

@ -1,3 +1,4 @@
// @ts-nocheck
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';

View file

@ -1,3 +1,4 @@
// @ts-nocheck
import path from 'node:path'; import path from 'node:path';
const MANIFEST_VERSION = 1; const MANIFEST_VERSION = 1;

View file

@ -1,3 +1,4 @@
// @ts-nocheck
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { inflateRawSync } from 'node:zlib'; import { inflateRawSync } from 'node:zlib';

View file

@ -1,3 +1,4 @@
// @ts-nocheck
/** /**
* Parses Claude Code's `--output-format stream-json --verbose` JSONL stream * Parses Claude Code's `--output-format stream-json --verbose` JSONL stream
* (with or without `--include-partial-messages`) into a small set of * (with or without `--include-partial-messages`) into a small set of

View file

@ -1,4 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
// @ts-nocheck
import { startServer } from './server.js'; import { startServer } from './server.js';
const args = process.argv.slice(2); const args = process.argv.slice(2);

View file

@ -1,3 +1,4 @@
// @ts-nocheck
/** /**
* Parses GitHub Copilot CLI's `--output-format json` JSONL stream into the * Parses GitHub Copilot CLI's `--output-format json` JSONL stream into the
* same UI-friendly events that claude-stream.js emits, so the chat panel * same UI-friendly events that claude-stream.js emits, so the chat panel

View file

@ -1,3 +1,4 @@
// @ts-nocheck
// SQLite-backed persistence for projects, conversations, messages, and the // SQLite-backed persistence for projects, conversations, messages, and the
// per-project set of open file tabs. The on-disk project folder under // per-project set of open file tabs. The on-disk project folder under
// .od/projects/<id>/ is still the single owner of the user's actual files // .od/projects/<id>/ is still the single owner of the user's actual files

View file

@ -1,3 +1,4 @@
// @ts-nocheck
/** /**
* Build a showcase HTML page from a DESIGN.md so the user can see what each * Build a showcase HTML page from a DESIGN.md so the user can see what each
* design system looks like *before* generating anything. We don't try to * design system looks like *before* generating anything. We don't try to

View file

@ -1,3 +1,4 @@
// @ts-nocheck
/** /**
* Build a fully-formed product webpage that demonstrates a design system in * Build a fully-formed product webpage that demonstrates a design system in
* action not just a list of tokens, but a real-feeling marketing / * action not just a list of tokens, but a real-feeling marketing /

View file

@ -1,3 +1,4 @@
// @ts-nocheck
// Design-system registry. Scans <projectRoot>/design-systems/* for DESIGN.md // Design-system registry. Scans <projectRoot>/design-systems/* for DESIGN.md
// files. Title comes from the first H1. Category comes from a // files. Title comes from the first H1. Category comes from a
// `> Category: <name>` blockquote line beneath the H1. Summary is the first // `> Category: <name>` blockquote line beneath the H1. Summary is the first

View file

@ -1,3 +1,4 @@
// @ts-nocheck
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';

View file

@ -1,3 +1,4 @@
// @ts-nocheck
// Minimal YAML front-matter parser. Handles the subset used by SKILL.md in // Minimal YAML front-matter parser. Handles the subset used by SKILL.md in
// our examples: scalar strings/numbers/booleans, block-literal (|) strings, // our examples: scalar strings/numbers/booleans, block-literal (|) strings,
// and flat arrays ("- foo"). Keeps the daemon dep-free. If you need real // and flat arrays ("- foo"). Keeps the daemon dep-free. If you need real

View file

@ -1,3 +1,4 @@
// @ts-nocheck
import { test } from 'vitest'; import { test } from 'vitest';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createJsonEventStreamHandler } from './json-event-stream.js'; import { createJsonEventStreamHandler } from './json-event-stream.js';

View file

@ -1,3 +1,4 @@
// @ts-nocheck
function safeParseJson(value) { function safeParseJson(value) {
if (value == null) return null; if (value == null) return null;
if (typeof value === 'object') return value; if (typeof value === 'object') return value;

View file

@ -1,3 +1,4 @@
// @ts-nocheck
/** /**
* Anti-slop linter for generated HTML artifacts. * Anti-slop linter for generated HTML artifacts.
* *

View file

@ -4,21 +4,29 @@
"private": true, "private": true,
"type": "module", "type": "module",
"bin": { "bin": {
"od": "./cli.js" "od": "./dist/cli.js"
}, },
"scripts": { "scripts": {
"daemon": "node cli.js --no-open", "build": "tsc -p tsconfig.json",
"dev": "node cli.js --no-open", "daemon": "pnpm run build && node dist/cli.js --no-open",
"start": "node cli.js", "dev": "pnpm run build && node dist/cli.js --no-open",
"test": "vitest run -c vitest.config.ts" "start": "pnpm run build && node dist/cli.js",
"test": "vitest run -c vitest.config.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@open-design/contracts": "workspace:*",
"better-sqlite3": "^11.10.0", "better-sqlite3": "^11.10.0",
"express": "^4.19.2", "express": "^4.19.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"multer": "^1.4.5-lts.1" "multer": "^1.4.5-lts.1"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^4.17.21",
"@types/multer": "^1.4.12",
"@types/node": "^20.17.10",
"typescript": "^5.6.3",
"vitest": "^2.1.8" "vitest": "^2.1.8"
}, },
"engines": { "engines": {

View file

@ -1,3 +1,4 @@
// @ts-nocheck
// Project files registry. Each project is a folder under // Project files registry. Each project is a folder under
// <projectRoot>/.od/projects/<projectId>/. The frontend's project list // <projectRoot>/.od/projects/<projectId>/. The frontend's project list
// (localStorage) carries metadata; this module is the single owner of the // (localStorage) carries metadata; this module is the single owner of the

View file

@ -0,0 +1,17 @@
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { resolveProjectRoot } from './server.js';
describe('resolveProjectRoot', () => {
it('resolves the repository root from the source daemon directory', () => {
const root = path.resolve(import.meta.dirname, '../..');
expect(resolveProjectRoot(path.join(root, 'apps', 'daemon'))).toBe(root);
});
it('resolves the repository root from the compiled daemon dist directory', () => {
const root = path.resolve(import.meta.dirname, '../..');
expect(resolveProjectRoot(path.join(root, 'apps', 'daemon', 'dist'))).toBe(root);
});
});

View file

@ -1,3 +1,4 @@
// @ts-nocheck
import express from 'express'; import express from 'express';
import multer from 'multer'; import multer from 'multer';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
@ -57,9 +58,24 @@ import {
upsertMessage, upsertMessage,
} from './db.js'; } from './db.js';
/** @typedef {import('@open-design/contracts').ApiErrorCode} ApiErrorCode */
/** @typedef {import('@open-design/contracts').ApiError} ApiError */
/** @typedef {import('@open-design/contracts').ApiErrorResponse} ApiErrorResponse */
/** @typedef {import('@open-design/contracts').ChatRequest} ChatRequest */
/** @typedef {import('@open-design/contracts').ChatSseEvent} ChatSseEvent */
/** @typedef {import('@open-design/contracts').ProxyStreamRequest} ProxyStreamRequest */
/** @typedef {import('@open-design/contracts').ProxySseEvent} ProxySseEvent */
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const PROJECT_ROOT = path.resolve(__dirname, '../..'); export function resolveProjectRoot(moduleDir: string): string {
const daemonDir = path.basename(moduleDir) === 'dist'
? path.dirname(moduleDir)
: moduleDir;
return path.resolve(daemonDir, '../..');
}
const PROJECT_ROOT = resolveProjectRoot(__dirname);
// Built web app lives in `out/` — that's where Next.js writes the static // Built web app lives in `out/` — that's where Next.js writes the static
// export configured in next.config.ts. The folder name used to be `dist/` // export configured in next.config.ts. The folder name used to be `dist/`
// when this project shipped with Vite; the daemon serves whatever the // when this project shipped with Vite; the daemon serves whatever the
@ -85,6 +101,46 @@ const promptFileBootstrap = (fp) =>
'Do not begin your response until you have read the entire file.'; 'Do not begin your response until you have read the entire file.';
export const SSE_KEEPALIVE_INTERVAL_MS = 25_000; export const SSE_KEEPALIVE_INTERVAL_MS = 25_000;
/**
* @param {ApiErrorCode} code
* @param {string} message
* @param {Omit<ApiError, 'code' | 'message'>} [init]
* @returns {ApiError}
*/
export function createCompatApiError(code, message, init = {}) {
return { code, message, ...init };
}
/**
* @param {ApiErrorCode} code
* @param {string} message
* @param {Omit<ApiError, 'code' | 'message'>} [init]
* @returns {ApiErrorResponse}
*/
export function createCompatApiErrorResponse(code, message, init = {}) {
return { error: createCompatApiError(code, message, init) };
}
/**
* @param {import('express').Response} res
* @param {number} status
* @param {ApiErrorCode} code
* @param {string} message
* @param {Omit<ApiError, 'code' | 'message'>} [init]
*/
function sendApiError(res, status, code, message, init = {}) {
return res.status(status).json(createCompatApiErrorResponse(code, message, init));
}
/**
* @param {ApiErrorCode} code
* @param {string} message
* @param {Omit<ApiError, 'code' | 'message'>} [init]
*/
function createSseErrorPayload(code, message, init = {}) {
return { message, error: createCompatApiError(code, message, init) };
}
const UPLOAD_DIR = path.join(os.tmpdir(), 'od-uploads'); const UPLOAD_DIR = path.join(os.tmpdir(), 'od-uploads');
fs.mkdirSync(UPLOAD_DIR, { recursive: true }); fs.mkdirSync(UPLOAD_DIR, { recursive: true });
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true }); fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
@ -168,14 +224,20 @@ function sendMulterError(res, err) {
}; };
const status = statusByCode[code] ?? 400; const status = statusByCode[code] ?? 400;
const message = errorByCode[code] ?? 'upload failed'; const message = errorByCode[code] ?? 'upload failed';
return res.status(status).json({ code, error: message }); return sendApiError(
res,
status,
code === 'LIMIT_FILE_SIZE' ? 'PAYLOAD_TOO_LARGE' : 'BAD_REQUEST',
message,
{ details: { legacyCode: code } },
);
} }
if (err) { if (err) {
return res.status(500).json({ code: 'UPLOAD_ERROR', error: 'upload failed' }); return sendApiError(res, 500, 'INTERNAL_ERROR', 'upload failed');
} }
return res.status(500).json({ code: 'UPLOAD_ERROR', error: 'upload failed' }); return sendApiError(res, 500, 'INTERNAL_ERROR', 'upload failed');
} }
export function createSseResponse(res, { keepAliveIntervalMs = SSE_KEEPALIVE_INTERVAL_MS } = {}) { export function createSseResponse(res, { keepAliveIntervalMs = SSE_KEEPALIVE_INTERVAL_MS } = {}) {
@ -211,6 +273,7 @@ export function createSseResponse(res, { keepAliveIntervalMs = SSE_KEEPALIVE_INT
res.on('finish', cleanup); res.on('finish', cleanup);
return { return {
/** @param {ChatSseEvent['event'] | ProxySseEvent['event'] | string} event */
send(event, data) { send(event, data) {
if (!canWrite()) return false; if (!canWrite()) return false;
res.write(`event: ${event}\n`); res.write(`event: ${event}\n`);
@ -250,9 +313,11 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
app.get('/api/projects', (_req, res) => { app.get('/api/projects', (_req, res) => {
try { try {
res.json({ projects: listProjects(db) }); /** @type {import('@open-design/contracts').ProjectsResponse} */
const body = { projects: listProjects(db) };
res.json(body);
} catch (err) { } catch (err) {
res.status(500).json({ error: String(err) }); sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
} }
}); });
@ -261,10 +326,10 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
const { id, name, skillId, designSystemId, pendingPrompt, metadata } = const { id, name, skillId, designSystemId, pendingPrompt, metadata } =
req.body || {}; req.body || {};
if (typeof id !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(id)) { if (typeof id !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(id)) {
return res.status(400).json({ error: 'invalid project id' }); return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
} }
if (typeof name !== 'string' || !name.trim()) { if (typeof name !== 'string' || !name.trim()) {
return res.status(400).json({ error: 'name required' }); return sendApiError(res, 400, 'BAD_REQUEST', 'name required');
} }
const now = Date.now(); const now = Date.now();
const project = insertProject(db, { const project = insertProject(db, {
@ -317,9 +382,11 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
} }
} }
} }
res.json({ project, conversationId: cid }); /** @type {import('@open-design/contracts').CreateProjectResponse} */
const body = { project, conversationId: cid };
res.json(body);
} catch (err) { } catch (err) {
res.status(400).json({ error: String(err) }); sendApiError(res, 400, 'BAD_REQUEST', String(err));
} }
}); });
@ -375,18 +442,22 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
app.get('/api/projects/:id', (req, res) => { app.get('/api/projects/:id', (req, res) => {
const project = getProject(db, req.params.id); const project = getProject(db, req.params.id);
if (!project) return res.status(404).json({ error: 'not found' }); if (!project) return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
res.json({ project }); /** @type {import('@open-design/contracts').ProjectResponse} */
const body = { project };
res.json(body);
}); });
app.patch('/api/projects/:id', (req, res) => { app.patch('/api/projects/:id', (req, res) => {
try { try {
const patch = req.body || {}; const patch = req.body || {};
const project = updateProject(db, req.params.id, patch); const project = updateProject(db, req.params.id, patch);
if (!project) return res.status(404).json({ error: 'not found' }); if (!project) return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
res.json({ project }); /** @type {import('@open-design/contracts').ProjectResponse} */
const body = { project };
res.json(body);
} catch (err) { } catch (err) {
res.status(400).json({ error: String(err) }); sendApiError(res, 400, 'BAD_REQUEST', String(err));
} }
}); });
@ -394,9 +465,11 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
try { try {
dbDeleteProject(db, req.params.id); dbDeleteProject(db, req.params.id);
await removeProjectDir(PROJECTS_DIR, req.params.id).catch(() => {}); await removeProjectDir(PROJECTS_DIR, req.params.id).catch(() => {});
res.json({ ok: true }); /** @type {import('@open-design/contracts').OkResponse} */
const body = { ok: true };
res.json(body);
} catch (err) { } catch (err) {
res.status(400).json({ error: String(err) }); sendApiError(res, 400, 'BAD_REQUEST', String(err));
} }
}); });
@ -776,9 +849,11 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
app.get('/api/projects/:id/files', async (req, res) => { app.get('/api/projects/:id/files', async (req, res) => {
try { try {
const files = await listFiles(PROJECTS_DIR, req.params.id); const files = await listFiles(PROJECTS_DIR, req.params.id);
res.json({ files }); /** @type {import('@open-design/contracts').ProjectFilesResponse} */
const body = { files };
res.json(body);
} catch (err) { } catch (err) {
res.status(400).json({ error: String(err) }); sendApiError(res, 400, 'BAD_REQUEST', String(err));
} }
}); });
@ -788,18 +863,20 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
const file = await readProjectFile(PROJECTS_DIR, req.params.id, relPath); const file = await readProjectFile(PROJECTS_DIR, req.params.id, relPath);
res.type(file.mime).send(file.buffer); res.type(file.mime).send(file.buffer);
} catch (err) { } catch (err) {
const code = err && err.code === 'ENOENT' ? 404 : 400; const status = err && err.code === 'ENOENT' ? 404 : 400;
res.status(code).json({ error: String(err) }); sendApiError(res, status, status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST', String(err));
} }
}); });
app.delete('/api/projects/:id/raw/*', async (req, res) => { app.delete('/api/projects/:id/raw/*', async (req, res) => {
try { try {
await deleteProjectFile(PROJECTS_DIR, req.params.id, req.params[0]); await deleteProjectFile(PROJECTS_DIR, req.params.id, req.params[0]);
res.json({ ok: true }); /** @type {import('@open-design/contracts').DeleteProjectFileResponse} */
const body = { ok: true };
res.json(body);
} catch (err) { } catch (err) {
const code = err && err.code === 'ENOENT' ? 404 : 400; const status = err && err.code === 'ENOENT' ? 404 : 400;
res.status(code).json({ error: String(err) }); sendApiError(res, status, status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST', String(err));
} }
}); });
@ -810,7 +887,7 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
res.json(preview); res.json(preview);
} catch (err) { } catch (err) {
const status = err && err.statusCode ? err.statusCode : err && err.code === 'ENOENT' ? 404 : 400; const status = err && err.statusCode ? err.statusCode : err && err.code === 'ENOENT' ? 404 : 400;
res.status(status).json({ error: err?.message || 'preview unavailable' }); sendApiError(res, status, status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST', err?.message || 'preview unavailable');
} }
}); });
@ -819,8 +896,8 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
const file = await readProjectFile(PROJECTS_DIR, req.params.id, req.params.name); const file = await readProjectFile(PROJECTS_DIR, req.params.id, req.params.name);
res.type(file.mime).send(file.buffer); res.type(file.mime).send(file.buffer);
} catch (err) { } catch (err) {
const code = err && err.code === 'ENOENT' ? 404 : 400; const status = err && err.code === 'ENOENT' ? 404 : 400;
res.status(code).json({ error: String(err) }); sendApiError(res, status, status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST', String(err));
} }
}); });
@ -848,16 +925,18 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
buf, buf,
); );
fs.promises.unlink(req.file.path).catch(() => {}); fs.promises.unlink(req.file.path).catch(() => {});
return res.json({ file: meta }); /** @type {import('@open-design/contracts').ProjectFileResponse} */
const body = { file: meta };
return res.json(body);
} }
const { name, content, encoding, artifactManifest } = req.body || {}; const { name, content, encoding, artifactManifest } = req.body || {};
if (typeof name !== 'string' || typeof content !== 'string') { if (typeof name !== 'string' || typeof content !== 'string') {
return res.status(400).json({ error: 'name and content required' }); return sendApiError(res, 400, 'BAD_REQUEST', 'name and content required');
} }
if (artifactManifest !== undefined && artifactManifest !== null) { if (artifactManifest !== undefined && artifactManifest !== null) {
const validated = validateArtifactManifestInput(artifactManifest, name); const validated = validateArtifactManifestInput(artifactManifest, name);
if (!validated.ok) { if (!validated.ok) {
return res.status(400).json({ error: `invalid artifactManifest: ${validated.error}` }); return sendApiError(res, 400, 'BAD_REQUEST', `invalid artifactManifest: ${validated.error}`);
} }
} }
const buf = const buf =
@ -867,9 +946,11 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
const meta = await writeProjectFile(PROJECTS_DIR, req.params.id, name, buf, { const meta = await writeProjectFile(PROJECTS_DIR, req.params.id, name, buf, {
artifactManifest, artifactManifest,
}); });
res.json({ file: meta }); /** @type {import('@open-design/contracts').ProjectFileResponse} */
const body = { file: meta };
res.json(body);
} catch (err) { } catch (err) {
res.status(500).json({ error: 'upload failed' }); sendApiError(res, 500, 'INTERNAL_ERROR', 'upload failed');
} }
}, },
); );
@ -877,10 +958,12 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
app.delete('/api/projects/:id/files/:name', async (req, res) => { app.delete('/api/projects/:id/files/:name', async (req, res) => {
try { try {
await deleteProjectFile(PROJECTS_DIR, req.params.id, req.params.name); await deleteProjectFile(PROJECTS_DIR, req.params.id, req.params.name);
res.json({ ok: true }); /** @type {import('@open-design/contracts').DeleteProjectFileResponse} */
const body = { ok: true };
res.json(body);
} catch (err) { } catch (err) {
const code = err && err.code === 'ENOENT' ? 404 : 400; const status = err && err.code === 'ENOENT' ? 404 : 400;
res.status(code).json({ error: String(err) }); sendApiError(res, status, status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST', String(err));
} }
}); });
@ -909,14 +992,18 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
// skip files that vanished mid-flight // skip files that vanished mid-flight
} }
} }
res.json({ files: out }); /** @type {import('@open-design/contracts').UploadProjectFilesResponse} */
const body = { files: out };
res.json(body);
} catch (err) { } catch (err) {
res.status(500).json({ error: 'upload failed' }); sendApiError(res, 500, 'INTERNAL_ERROR', 'upload failed');
} }
}, },
); );
app.post('/api/chat', async (req, res) => { app.post('/api/chat', async (req, res) => {
/** @type {Partial<ChatRequest> & { imagePaths?: string[] }} */
const chatBody = req.body || {};
const { const {
agentId, agentId,
message, message,
@ -926,12 +1013,12 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
attachments = [], attachments = [],
model, model,
reasoning, reasoning,
} = req.body || {}; } = chatBody;
const def = getAgentDef(agentId); const def = getAgentDef(agentId);
if (!def) return res.status(400).json({ error: `unknown agent: ${agentId}` }); if (!def) return sendApiError(res, 400, 'AGENT_UNAVAILABLE', `unknown agent: ${agentId}`);
if (!def.bin) return res.status(400).json({ error: 'agent has no binary' }); if (!def.bin) return sendApiError(res, 400, 'AGENT_UNAVAILABLE', 'agent has no binary');
if (typeof message !== 'string' || !message.trim()) { if (typeof message !== 'string' || !message.trim()) {
return res.status(400).json({ error: 'message required' }); return sendApiError(res, 400, 'BAD_REQUEST', 'message required');
} }
// Resolve the project working directory (creating the folder if it // Resolve the project working directory (creating the folder if it
@ -1093,11 +1180,12 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
// from issue #10 the rest of this block is meant to prevent. // from issue #10 the rest of this block is meant to prevent.
if (!resolvedBin) { if (!resolvedBin) {
cleanPromptFile(); cleanPromptFile();
send('error', { send('error', createSseErrorPayload(
message: 'AGENT_UNAVAILABLE',
`Agent "${def.name}" (\`${def.bin}\`) is not installed or not on PATH. ` + `Agent "${def.name}" (\`${def.bin}\`) is not installed or not on PATH. ` +
'Install it and refresh the agent list (GET /api/agents) before retrying.', 'Install it and refresh the agent list (GET /api/agents) before retrying.',
}); { retryable: true },
));
return sse.end(); return sse.end();
} }
// npm shims on Windows are .cmd/.bat files; Node ≥21 refuses to spawn // npm shims on Windows are .cmd/.bat files; Node ≥21 refuses to spawn
@ -1158,14 +1246,14 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
// below already route the underlying failure to SSE via stderr. // below already route the underlying failure to SSE via stderr.
child.stdin.on('error', (err) => { child.stdin.on('error', (err) => {
if (err.code !== 'EPIPE') { if (err.code !== 'EPIPE') {
send('error', { message: `stdin: ${err.message}` }); send('error', createSseErrorPayload('AGENT_EXECUTION_FAILED', `stdin: ${err.message}`));
} }
}); });
child.stdin.end(composed, 'utf8'); child.stdin.end(composed, 'utf8');
} }
} catch (err) { } catch (err) {
cleanPromptFile(); cleanPromptFile();
send('error', { message: `spawn failed: ${err.message}` }); send('error', createSseErrorPayload('AGENT_EXECUTION_FAILED', `spawn failed: ${err.message}`));
return sse.end(); return sse.end();
} }
@ -1211,7 +1299,7 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
}); });
child.on('error', (err) => { child.on('error', (err) => {
send('error', { message: err.message }); send('error', createSseErrorPayload('AGENT_EXECUTION_FAILED', err.message));
sse.end(); sse.end();
}); });
child.on('close', (code, signal) => { child.on('close', (code, signal) => {
@ -1229,9 +1317,11 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
// providers (MiMo, DeepSeek, Groq, etc.). // providers (MiMo, DeepSeek, Groq, etc.).
app.post('/api/proxy/stream', async (req, res) => { app.post('/api/proxy/stream', async (req, res) => {
const { baseUrl, apiKey, model, systemPrompt, messages } = req.body || {}; /** @type {Partial<ProxyStreamRequest>} */
const proxyBody = req.body || {};
const { baseUrl, apiKey, model, systemPrompt, messages } = proxyBody;
if (!baseUrl || !apiKey || !model) { if (!baseUrl || !apiKey || !model) {
return res.status(400).json({ error: 'baseUrl, apiKey, and model are required' }); return sendApiError(res, 400, 'BAD_REQUEST', 'baseUrl, apiKey, and model are required');
} }
// Validate baseUrl — only allow http/https and block internal IPs (SSRF). // Validate baseUrl — only allow http/https and block internal IPs (SSRF).
@ -1239,10 +1329,10 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
try { try {
parsed = new URL(baseUrl.replace(/\/+$/, '')); parsed = new URL(baseUrl.replace(/\/+$/, ''));
} catch { } catch {
return res.status(400).json({ error: 'Invalid baseUrl' }); return sendApiError(res, 400, 'BAD_REQUEST', 'Invalid baseUrl');
} }
if (!['http:', 'https:'].includes(parsed.protocol)) { if (!['http:', 'https:'].includes(parsed.protocol)) {
return res.status(400).json({ error: 'Only http/https allowed' }); return sendApiError(res, 400, 'BAD_REQUEST', 'Only http/https allowed');
} }
if ( if (
['localhost', '127.0.0.1', '::1'].includes(parsed.hostname) || ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname) ||
@ -1251,7 +1341,7 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
/^192\.168\./.test(parsed.hostname) || /^192\.168\./.test(parsed.hostname) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(parsed.hostname) /^172\.(1[6-9]|2\d|3[01])\./.test(parsed.hostname)
) { ) {
return res.status(400).json({ error: 'Internal IPs blocked' }); return sendApiError(res, 400, 'FORBIDDEN', 'Internal IPs blocked');
} }
// Build the upstream URL. If the base URL already ends with /v1 (or // Build the upstream URL. If the base URL already ends with /v1 (or
@ -1295,7 +1385,7 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
body, body,
}); });
} catch (fetchErr) { } catch (fetchErr) {
send('error', { message: `fetch failed: ${fetchErr.message}` }); send('error', createSseErrorPayload('UPSTREAM_UNAVAILABLE', `fetch failed: ${fetchErr.message}`, { retryable: true }));
return sse.end(); return sse.end();
} }
@ -1303,7 +1393,7 @@ export async function startServer({ port = 7456, returnServer = false } = {}) {
const errText = await upstream.text().catch(() => ''); const errText = await upstream.text().catch(() => '');
const safeErr = errText.slice(0, 500).replace(/Bearer [A-Za-z0-9_\-\.]+/g, 'Bearer [REDACTED]'); const safeErr = errText.slice(0, 500).replace(/Bearer [A-Za-z0-9_\-\.]+/g, 'Bearer [REDACTED]');
console.error(`[proxy] upstream ${upstream.status}: ${safeErr.slice(0, 200)}`); console.error(`[proxy] upstream ${upstream.status}: ${safeErr.slice(0, 200)}`);
send('error', { message: `upstream ${upstream.status}: ${safeErr}` }); send('error', createSseErrorPayload('UPSTREAM_UNAVAILABLE', `upstream ${upstream.status}: ${safeErr}`, { retryable: upstream.status >= 500 }));
return sse.end(); return sse.end();
} }

View file

@ -1,3 +1,4 @@
// @ts-nocheck
// Skill registry. Scans <projectRoot>/skills/* for SKILL.md files, parses // Skill registry. Scans <projectRoot>/skills/* for SKILL.md files, parses
// front-matter, returns listing. No watching in this MVP — re-scans on every // front-matter, returns listing. No watching in this MVP — re-scans on every
// GET /api/skills, which is fine for dozens of skills. // GET /api/skills, which is fine for dozens of skills.

View file

@ -1,7 +1,8 @@
// @ts-nocheck
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { createSseResponse } from './server.js'; import { createCompatApiErrorResponse, createSseResponse } from './server.js';
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
@ -59,6 +60,33 @@ describe('createSseResponse', () => {
}); });
}); });
describe('createCompatApiErrorResponse', () => {
it('wraps legacy string errors in the shared ApiError response shape', () => {
expect(createCompatApiErrorResponse('BAD_REQUEST', 'message required')).toEqual({
error: {
code: 'BAD_REQUEST',
message: 'message required',
},
});
});
it('preserves shared ApiError metadata fields', () => {
expect(
createCompatApiErrorResponse('AGENT_UNAVAILABLE', 'missing agent', {
retryable: true,
details: { legacyCode: 'ENOENT' },
}),
).toEqual({
error: {
code: 'AGENT_UNAVAILABLE',
message: 'missing agent',
retryable: true,
details: { legacyCode: 'ENOENT' },
},
});
});
});
class FakeResponse extends EventEmitter { class FakeResponse extends EventEmitter {
headers = {}; headers = {};
writes = []; writes = [];

27
apps/daemon/tsconfig.json Normal file
View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"allowJs": false,
"checkJs": false,
"outDir": "dist",
"declaration": true,
"sourceMap": true,
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"types": ["node", "vitest"]
},
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": ["node_modules", "dist"]
}

View file

@ -20,7 +20,7 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: ReactNode }) { export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang='en'> <html lang='en' suppressHydrationWarning>
<body> <body>
<I18nProvider>{children}</I18nProvider> <I18nProvider>{children}</I18nProvider>
</body> </body>

View file

@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.32.1", "@anthropic-ai/sdk": "^0.32.1",
"@open-design/contracts": "workspace:*",
"next": "^16.2.4", "next": "^16.2.4",
"openai": "^6.35.0", "openai": "^6.35.0",
"react": "^18.3.1", "react": "^18.3.1",

View file

@ -10,6 +10,13 @@
* non-zero (tail appended to the error message). * non-zero (tail appended to the error message).
*/ */
import type { AgentEvent, ChatMessage } from '../types'; import type { AgentEvent, ChatMessage } from '../types';
import type {
ChatRequest,
ChatSseEvent,
ChatSseStartPayload,
DaemonAgentPayload,
SseErrorPayload,
} from '@open-design/contracts';
import type { StreamHandlers } from './anthropic'; import type { StreamHandlers } from './anthropic';
import { parseSseFrame } from './sse'; import { parseSseFrame } from './sse';
@ -55,7 +62,7 @@ export async function streamViaDaemon({
const transcript = history const transcript = history
.map((m) => `## ${m.role}\n${m.content.trim()}`) .map((m) => `## ${m.role}\n${m.content.trim()}`)
.join('\n\n'); .join('\n\n');
const body = JSON.stringify({ const request: ChatRequest = {
agentId, agentId,
systemPrompt, systemPrompt,
message: transcript, message: transcript,
@ -63,7 +70,8 @@ export async function streamViaDaemon({
attachments: attachments ?? [], attachments: attachments ?? [],
model: model ?? null, model: model ?? null,
reasoning: reasoning ?? null, reasoning: reasoning ?? null,
}); };
const body = JSON.stringify(request);
let acc = ''; let acc = '';
let stderrBuf = ''; let stderrBuf = '';
@ -98,21 +106,23 @@ export async function streamViaDaemon({
const parsed = parseSseFrame(frame); const parsed = parseSseFrame(frame);
if (!parsed || parsed.kind !== 'event') continue; if (!parsed || parsed.kind !== 'event') continue;
if (parsed.event === 'stdout') { const event = parsed as unknown as ChatSseEvent;
const chunk = String(parsed.data.chunk ?? '');
if (event.event === 'stdout') {
const chunk = String(event.data.chunk ?? '');
acc += chunk; acc += chunk;
handlers.onDelta(chunk); handlers.onDelta(chunk);
handlers.onAgentEvent({ kind: 'text', text: chunk }); handlers.onAgentEvent({ kind: 'text', text: chunk });
continue; continue;
} }
if (parsed.event === 'stderr') { if (event.event === 'stderr') {
stderrBuf += parsed.data.chunk ?? ''; stderrBuf += event.data.chunk ?? '';
continue; continue;
} }
if (parsed.event === 'agent') { if (event.event === 'agent') {
const translated = translateAgentEvent(parsed.data); const translated = translateAgentEvent(event.data);
if (!translated) continue; if (!translated) continue;
if (translated.kind === 'text') { if (translated.kind === 'text') {
acc += translated.text; acc += translated.text;
@ -122,22 +132,24 @@ export async function streamViaDaemon({
continue; continue;
} }
if (parsed.event === 'start') { if (event.event === 'start') {
const data = event.data as ChatSseStartPayload;
handlers.onAgentEvent({ handlers.onAgentEvent({
kind: 'status', kind: 'status',
label: 'starting', label: 'starting',
detail: typeof parsed.data.bin === 'string' ? parsed.data.bin : undefined, detail: typeof data.bin === 'string' ? data.bin : undefined,
}); });
continue; continue;
} }
if (parsed.event === 'error') { if (event.event === 'error') {
handlers.onError(new Error(String(parsed.data.message ?? 'daemon error'))); const data = event.data as SseErrorPayload;
handlers.onError(new Error(String(data.error?.message ?? data.message ?? 'daemon error')));
return; return;
} }
if (parsed.event === 'end') { if (event.event === 'end') {
exitCode = typeof parsed.data.code === 'number' ? parsed.data.code : null; exitCode = typeof event.data.code === 'number' ? event.data.code : null;
} }
} }
} }
@ -159,7 +171,7 @@ export async function streamViaDaemon({
// Translate a raw `agent` SSE payload (what apps/daemon/claude-stream.js emits) // Translate a raw `agent` SSE payload (what apps/daemon/claude-stream.js emits)
// into the UI's AgentEvent union. Keep this liberal — unknown types just // into the UI's AgentEvent union. Keep this liberal — unknown types just
// return null so the UI ignores them instead of rendering garbage. // return null so the UI ignores them instead of rendering garbage.
function translateAgentEvent(data: Record<string, unknown>): AgentEvent | null { function translateAgentEvent(data: DaemonAgentPayload): AgentEvent | null {
const t = data.type; const t = data.type;
if (t === 'status' && typeof data.label === 'string') { if (t === 'status' && typeof data.label === 'string') {
return { return {

View file

@ -85,6 +85,34 @@ describe('streamViaDaemon', () => {
expect(handlers.onError).not.toHaveBeenCalled(); expect(handlers.onError).not.toHaveBeenCalled();
expect(handlers.onDone).toHaveBeenCalledWith('hello'); expect(handlers.onDone).toHaveBeenCalledWith('hello');
}); });
it('reads unified SSE error payload messages', async () => {
const handlers = createDaemonHandlers();
vi.stubGlobal(
'fetch',
vi.fn(async () =>
sseResponse(
[
'event: error',
'data: {"message":"legacy message","error":{"code":"AGENT_UNAVAILABLE","message":"typed message"}}',
'',
'',
].join('\n'),
),
),
);
await streamViaDaemon({
agentId: 'mock',
history: [{ id: '1', role: 'user', content: 'hello' }],
systemPrompt: '',
signal: new AbortController().signal,
handlers,
});
expect(handlers.onError).toHaveBeenCalledWith(new Error('typed message'));
expect(handlers.onDone).not.toHaveBeenCalled();
});
}); });
describe('streamMessageOpenAI', () => { describe('streamMessageOpenAI', () => {

View file

@ -1,4 +1,20 @@
import type { ArtifactKind, ArtifactManifest } from './artifacts/types'; import type {
AgentInfo,
ChatAttachment,
ChatMessage,
Conversation,
DesignSystemDetail,
DesignSystemSummary,
PersistedAgentEvent,
Project,
ProjectFile,
ProjectFileKind,
ProjectKind,
ProjectMetadata,
ProjectTemplate,
SkillDetail,
SkillSummary,
} from '@open-design/contracts';
export type ExecMode = 'daemon' | 'api'; export type ExecMode = 'daemon' | 'api';
@ -29,42 +45,9 @@ export interface AppConfig {
agentModels?: Record<string, AgentModelChoice>; agentModels?: Record<string, AgentModelChoice>;
} }
export type AgentEvent = export type AgentEvent = PersistedAgentEvent;
| { kind: 'status'; label: string; detail?: string | undefined }
| { kind: 'text'; text: string }
| { kind: 'thinking'; text: string }
| { kind: 'tool_use'; id: string; name: string; input: unknown }
| { kind: 'tool_result'; toolUseId: string; content: string; isError: boolean }
| { kind: 'usage'; inputTokens?: number; outputTokens?: number; costUsd?: number; durationMs?: number }
| { kind: 'raw'; line: string };
export interface ChatMessage { export type { ChatAttachment, ChatMessage };
id: string;
role: 'user' | 'assistant';
content: string;
agentId?: string;
agentName?: string;
events?: AgentEvent[];
startedAt?: number;
endedAt?: number;
// Files staged by the user on this turn (uploaded into the project
// folder). Persisted on the message so re-renders show the same chips.
attachments?: ChatAttachment[];
// Files that appeared in the project folder during this assistant turn.
// Rendered as download / open chips at the end of the message so the
// user can grab a generated artifact (.pptx, .zip, etc.) in one click.
producedFiles?: ProjectFile[];
}
// Reference to a file that lives in the active project folder. The user
// stages these by paste / drop / picker / @-mention; the daemon receives
// `path` on the chat call and the agent reads them from cwd.
export interface ChatAttachment {
path: string;
name: string;
kind: 'image' | 'file';
size?: number;
}
export interface Artifact { export interface Artifact {
identifier: string; identifier: string;
@ -85,171 +68,20 @@ export interface AgentModelOption {
label: string; label: string;
} }
export interface AgentInfo { export type {
id: string; AgentInfo,
name: string; Conversation,
bin: string; DesignSystemDetail,
available: boolean; DesignSystemSummary,
path?: string; Project,
version?: string | null; ProjectFile,
// Models surfaced in the model picker for this CLI. The first entry is ProjectFileKind,
// treated as the default (typically the synthetic `'default'` option, ProjectKind,
// meaning "let the CLI use whatever's in its own config"). ProjectMetadata,
models?: AgentModelOption[]; ProjectTemplate,
// Reasoning-effort presets — currently only Codex exposes this. SkillDetail,
reasoningOptions?: AgentModelOption[]; SkillSummary,
} };
export interface SkillSummary {
id: string;
name: string;
description: string;
triggers: string[];
mode: 'prototype' | 'deck' | 'template' | 'design-system';
platform?: 'desktop' | 'mobile' | null;
scenario?: string | null;
previewType: string;
designSystemRequired: boolean;
defaultFor: string[];
upstream: string | null;
/** Lower number = higher priority in the Examples gallery. `null` keeps
* the skill in its natural alphabetical position below all featured
* entries. Set via `od.featured` in the SKILL.md frontmatter. */
featured?: number | null;
/** Optional metadata hints, parsed from `od.fidelity`,
* `od.speaker_notes`, and `od.animations` in SKILL.md. Used by the
* Examples gallery's "Use this prompt" fast-create path to mirror the
* shipped `example.html` (e.g. wireframe-sketch declares
* `fidelity: wireframe`). Missing hints fall back to the same defaults
* the new-project form would apply. */
fidelity?: 'wireframe' | 'high-fidelity' | null;
speakerNotes?: boolean | null;
animations?: boolean | null;
hasBody: boolean;
examplePrompt: string;
}
export interface SkillDetail extends SkillSummary {
body: string;
}
export interface DesignSystemSummary {
id: string;
title: string;
category: string;
summary: string;
/** 4 representative hex strings extracted from DESIGN.md: [bg, support, fg, accent].
* Empty when DESIGN.md doesn't expose its tokens in the bold-and-hex format. */
swatches?: string[];
}
export interface DesignSystemDetail extends DesignSystemSummary {
body: string;
}
export type ProjectFileKind =
| 'html'
| 'image'
| 'sketch'
| 'text'
| 'code'
| 'pdf'
| 'document'
| 'presentation'
| 'spreadsheet'
| 'binary';
export interface ProjectFile {
name: string;
// Project-relative path. Today the project folder is flat so `path`
// equals `name` for every file — but components that want to think in
// path terms (the @-mention picker, the staged-attachment chips) can
// read this without caring whether subdirs exist.
path?: string;
// Discriminator for code that wants to filter files vs dirs in a tree
// listing. The current listing is files-only; we always set this to
// 'file' so the discriminator is meaningful.
type?: 'file' | 'dir';
size: number;
mtime: number;
kind: ProjectFileKind;
mime: string;
artifactKind?: ArtifactKind;
artifactManifest?: ArtifactManifest;
}
// Per-project metadata captured at creation time. The agent reads this
// during chat (via the system prompt) and the question-form re-asks for
// any field that's missing. Each `kind` carries a different shape.
export type ProjectKind = 'prototype' | 'deck' | 'template' | 'other';
export interface ProjectMetadata {
kind: ProjectKind;
// Prototype: 'wireframe' | 'high-fidelity'. Drives the visual ambition.
fidelity?: 'wireframe' | 'high-fidelity';
// Slide deck: whether the user wants speaker notes (less text per slide).
speakerNotes?: boolean;
// Template: whether motion/animation should be part of the design.
// Defaults `false` so a static template stays static unless asked.
animations?: boolean;
// Template: id of the user-saved template chosen at creation time.
// Only set on `kind === 'template'` projects (the other kinds dropped
// template selection entirely). The built-in 'animation' starter no
// longer ships — every template here is user-created via Share menu.
templateId?: string;
// Template: human-readable label of the source template, kept separate
// from `templateId` so the agent surface can name it without re-fetching.
templateLabel?: string;
// Multi-select design-system "inspirations". The first pick still goes to
// `Project.designSystemId` (the primary system that controls tokens); any
// additional ids land here and are passed to the agent as references the
// generated artifact should *also* draw from. Empty / undefined when the
// user stayed in single-select mode.
inspirationDesignSystemIds?: string[];
// Imported static-site projects, currently used for Claude Design ZIPs.
importedFrom?: 'claude-design' | string;
entryFile?: string;
sourceFileName?: string;
}
export interface Project {
id: string;
name: string;
skillId: string | null;
designSystemId: string | null;
createdAt: number;
updatedAt: number;
// The prompt that should be prefilled into the chat composer when the
// project is opened. Cleared the first time the project is opened so it
// doesn't keep re-populating on subsequent visits.
pendingPrompt?: string;
// Optional structured metadata captured by the new-project panel. The
// shape varies by `kind`. Older projects created before this field
// existed will have it `undefined`.
metadata?: ProjectMetadata;
}
export interface ProjectTemplate {
id: string;
name: string;
// Source project the template was captured from (so we can show "based
// on …" in the picker). Optional because some templates are seeded.
sourceProjectId?: string;
// Snapshot of HTML files at the moment the template was saved. Each
// entry is a basename → text content pair.
files: Array<{ name: string; content: string }>;
// Free-form description shown in the picker.
description?: string;
createdAt: number;
}
export interface Conversation {
id: string;
projectId: string;
title: string | null;
createdAt: number;
updatedAt: number;
}
export interface OpenTabsState { export interface OpenTabsState {
tabs: string[]; tabs: string[];

View file

@ -1,4 +1,9 @@
module.exports = { export interface ReportCaseMetadata {
module: string;
assertions: string[];
}
const caseMetadata: Record<string, ReportCaseMetadata> = {
'prototype-basic': { 'prototype-basic': {
module: '项目创建与生成', module: '项目创建与生成',
assertions: [ assertions: [
@ -124,4 +129,6 @@ module.exports = {
'刷新前选中的 active tab 仍然保持选中', '刷新前选中的 active tab 仍然保持选中',
], ],
}, },
}; } satisfies Record<string, ReportCaseMetadata>;
export default caseMetadata;

View file

@ -5,10 +5,11 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "vitest run -c vitest.config.ts", "test": "vitest run -c vitest.config.ts",
"test:ui:clean": "node scripts/reset-artifacts.mjs", "typecheck": "tsc -p tsconfig.json --noEmit",
"test:ui": "pnpm run test:ui:clean && playwright test -c playwright.config.ts", "test:ui:clean": "node --experimental-strip-types scripts/reset-artifacts.ts",
"test:ui:headed": "pnpm run test:ui:clean && playwright test -c playwright.config.ts --headed", "test:ui": "corepack pnpm run test:ui:clean && playwright test -c playwright.config.ts",
"test:e2e:live": "node --test scripts/runtime-adapter.e2e.live.test.mjs" "test:ui:headed": "corepack pnpm run test:ui:clean && playwright test -c playwright.config.ts --headed",
"test:e2e:live": "corepack pnpm --filter @open-design/daemon build && node --experimental-strip-types --test scripts/runtime-adapter.e2e.live.test.ts"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1", "@playwright/test": "^1.59.1",

View file

@ -1,5 +1,5 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
import { resolveDevPorts } from '../scripts/resolve-dev-ports.mjs'; import { resolveDevPorts } from '../scripts/resolve-dev-ports.ts';
const desiredDaemonPort = Number(process.env.OD_PORT) || 17_456; const desiredDaemonPort = Number(process.env.OD_PORT) || 17_456;
const desiredNextPort = Number(process.env.NEXT_PORT) || 17_573; const desiredNextPort = Number(process.env.NEXT_PORT) || 17_573;
@ -26,14 +26,14 @@ export default defineConfig({
['html', { open: 'never', outputFolder: './reports/playwright-html-report' }], ['html', { open: 'never', outputFolder: './reports/playwright-html-report' }],
['json', { outputFile: './reports/results.json' }], ['json', { outputFile: './reports/results.json' }],
['junit', { outputFile: './reports/junit.xml' }], ['junit', { outputFile: './reports/junit.xml' }],
['./reporters/markdown-reporter.cjs', { outputFile: './reports/latest.md' }], ['./reporters/markdown-reporter.ts', { outputFile: './reports/latest.md' }],
] ]
: [ : [
['list'], ['list'],
['html', { open: 'never', outputFolder: './reports/playwright-html-report' }], ['html', { open: 'never', outputFolder: './reports/playwright-html-report' }],
['json', { outputFile: './reports/results.json' }], ['json', { outputFile: './reports/results.json' }],
['junit', { outputFile: './reports/junit.xml' }], ['junit', { outputFile: './reports/junit.xml' }],
['./reporters/markdown-reporter.cjs', { outputFile: './reports/latest.md' }], ['./reporters/markdown-reporter.ts', { outputFile: './reports/latest.md' }],
], ],
use: { use: {
baseURL, baseURL,
@ -44,7 +44,7 @@ export default defineConfig({
command: command:
`OD_DATA_DIR=e2e/.od-data ` + `OD_DATA_DIR=e2e/.od-data ` +
`OD_PORT=${daemonPort} OD_PORT_STRICT=1 ` + `OD_PORT=${daemonPort} OD_PORT_STRICT=1 ` +
`NEXT_PORT=${nextPort} NEXT_PORT_STRICT=1 pnpm --dir .. run dev:all`, `NEXT_PORT=${nextPort} NEXT_PORT_STRICT=1 corepack pnpm --dir .. run dev:all`,
url: baseURL, url: baseURL,
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 120_000, timeout: 120_000,

View file

@ -1,23 +1,63 @@
const fs = require('node:fs'); import fs from 'node:fs';
const path = require('node:path'); import path from 'node:path';
const caseMetadata = require('../cases/report-metadata.cjs'); import type { FullConfig, Reporter, Suite, TestCase, TestResult } from '@playwright/test/reporter';
import caseMetadata from '../cases/report-metadata.ts';
class MarkdownReporter { interface MarkdownReporterOptions {
constructor(options = {}) { outputFile?: string;
}
interface CaseRow {
caseId: string;
title: string;
module: string;
assertions: string[];
status: string;
durationMs: number;
retries: number;
file: string;
line: number | null;
attachments: Array<{ name: string; contentType: string; path: string }>;
error: string | null;
}
interface Summary {
total: number;
passed: number;
failed: number;
flaky: number;
skipped: number;
timedOut: number;
interrupted: number;
durationMs: number;
}
interface MarkdownInput {
startedAt: Date;
finishedAt: Date;
summary: Summary;
rows: CaseRow[];
outputFile: string;
}
class MarkdownReporter implements Reporter {
private rootSuite: Suite | null = null;
private startedAt: Date | null = null;
private readonly options: MarkdownReporterOptions;
constructor(options: MarkdownReporterOptions = {}) {
this.options = options; this.options = options;
this.rootSuite = null;
this.startedAt = null;
} }
onBegin(_config, suite) { onBegin(_config: FullConfig, suite: Suite): void {
this.rootSuite = suite; this.rootSuite = suite;
this.startedAt = new Date(); this.startedAt = new Date();
} }
async onEnd() { async onEnd(): Promise<void> {
if (!this.rootSuite) return; if (!this.rootSuite) return;
const rows = []; const rows: CaseRow[] = [];
visitSuite(this.rootSuite, rows); visitSuite(this.rootSuite, rows);
rows.sort((a, b) => a.caseId.localeCompare(b.caseId)); rows.sort((a, b) => a.caseId.localeCompare(b.caseId));
@ -42,37 +82,42 @@ class MarkdownReporter {
} }
} }
function visitSuite(suite, rows) { function visitSuite(suite: Suite, rows: CaseRow[]): void {
for (const child of suite.suites || []) { for (const child of suite.suites || []) {
visitSuite(child, rows); visitSuite(child, rows);
} }
for (const test of suite.tests || []) { for (const test of suite.tests || []) {
const finalResult = test.results[test.results.length - 1]; const finalResult = test.results[test.results.length - 1];
if (!finalResult) continue; if (!finalResult) continue;
const parsed = parseCaseTitle(test.title); rows.push(buildCaseRow(test, finalResult));
rows.push({
caseId: parsed.caseId,
title: parsed.title,
module: caseMetadata[parsed.caseId]?.module || '未分组',
assertions: caseMetadata[parsed.caseId]?.assertions || [],
status: normalizeStatus(finalResult.status, test.outcome && test.outcome()),
durationMs: finalResult.duration ?? 0,
retries: Math.max(0, test.results.length - 1),
file: test.location?.file ?? '',
line: test.location?.line ?? null,
attachments: (finalResult.attachments || [])
.map((entry) => ({
name: entry.name || '',
contentType: entry.contentType || '',
path: entry.path ? toRelative(entry.path) : null,
}))
.filter((entry) => entry.path),
error: compactError(finalResult.error),
});
} }
} }
function parseCaseTitle(title) { function buildCaseRow(test: TestCase, finalResult: TestResult): CaseRow {
const parsed = parseCaseTitle(test.title);
const metadata = caseMetadata[parsed.caseId];
return {
caseId: parsed.caseId,
title: parsed.title,
module: metadata?.module || '未分组',
assertions: metadata?.assertions || [],
status: normalizeStatus(finalResult.status, test.outcome?.()),
durationMs: finalResult.duration ?? 0,
retries: Math.max(0, test.results.length - 1),
file: test.location?.file ?? '',
line: test.location?.line ?? null,
attachments: (finalResult.attachments || [])
.map((entry) => ({
name: entry.name || '',
contentType: entry.contentType || '',
path: entry.path ? toRelative(entry.path) : '',
}))
.filter((entry) => entry.path.length > 0),
error: compactError(finalResult.error),
};
}
function parseCaseTitle(title: string): { caseId: string; title: string } {
const idx = title.indexOf(': '); const idx = title.indexOf(': ');
if (idx === -1) { if (idx === -1) {
return { caseId: title, title }; return { caseId: title, title };
@ -83,12 +128,12 @@ function parseCaseTitle(title) {
}; };
} }
function normalizeStatus(status, outcome) { function normalizeStatus(status: string | undefined, outcome: string | undefined): string {
if (outcome === 'flaky') return 'flaky'; if (outcome === 'flaky') return 'flaky';
return status || 'unknown'; return status || 'unknown';
} }
function compactError(error) { function compactError(error: TestResult['error']): string | null {
if (!error) return null; if (!error) return null;
const raw = [error.message, error.value, error.stack] const raw = [error.message, error.value, error.stack]
.filter(Boolean) .filter(Boolean)
@ -98,7 +143,7 @@ function compactError(error) {
return raw.split('\n').slice(0, 8).join('\n'); return raw.split('\n').slice(0, 8).join('\n');
} }
function summarize(rows) { function summarize(rows: CaseRow[]): Summary {
const summary = { const summary = {
total: rows.length, total: rows.length,
passed: 0, passed: 0,
@ -122,8 +167,8 @@ function summarize(rows) {
return summary; return summary;
} }
function buildMarkdown({ startedAt, finishedAt, summary, rows, outputFile }) { function buildMarkdown({ startedAt, finishedAt, summary, rows, outputFile }: MarkdownInput): string {
const lines = []; const lines: string[] = [];
lines.push('# UI 自动化测试报告'); lines.push('# UI 自动化测试报告');
lines.push(''); lines.push('');
lines.push(`- 生成时间:${finishedAt.toISOString()}`); lines.push(`- 生成时间:${finishedAt.toISOString()}`);
@ -217,12 +262,12 @@ function buildMarkdown({ startedAt, finishedAt, summary, rows, outputFile }) {
return `${lines.join('\n')}\n`; return `${lines.join('\n')}\n`;
} }
function formatDuration(ms) { function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`; if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`; return `${(ms / 1000).toFixed(1)}s`;
} }
function statusLabel(status) { function statusLabel(status: string): string {
if (status === 'passed') return 'passed'; if (status === 'passed') return 'passed';
if (status === 'failed') return 'failed'; if (status === 'failed') return 'failed';
if (status === 'flaky') return 'flaky'; if (status === 'flaky') return 'flaky';
@ -232,13 +277,13 @@ function statusLabel(status) {
return status; return status;
} }
function toRelative(filePath) { function toRelative(filePath: string): string {
if (!filePath) return ''; if (!filePath) return '';
return path.relative(process.cwd(), filePath) || filePath; return path.relative(process.cwd(), filePath) || filePath;
} }
function escapeCell(value) { function escapeCell(value: string): string {
return String(value).replace(/\|/g, '\\|'); return String(value).replace(/\|/g, '\\|');
} }
module.exports = MarkdownReporter; export default MarkdownReporter;

View file

@ -46,10 +46,8 @@ try {
), ),
); );
} catch (error) { } catch (error) {
// It's fine if the daemon hasn't created the projects root yet. const code = error instanceof Error && 'code' in error ? error.code : undefined;
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { if (code !== 'ENOENT') {
// Missing roots are expected before the first daemon boot.
} else {
console.warn('Failed to clean stale e2e project dirs:', error); console.warn('Failed to clean stale e2e project dirs:', error);
} }
} }

View file

@ -3,27 +3,52 @@ import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url); interface AgentInfo {
const __dirname = path.dirname(__filename); id: string;
name: string;
bin: string;
available: boolean;
path?: string;
version?: string | null;
models?: Array<{ id: string; label?: string }>;
streamFormat?: string;
}
interface AgentsResponse {
agents: AgentInfo[];
}
interface StartServerResult {
url: string;
server: { close: (callback: (err?: Error) => void) => void };
}
interface ParsedSseEvent {
event: string;
data: Record<string, unknown>;
}
type StartServer = (options: { port: number; returnServer: true }) => Promise<StartServerResult>;
type CloseDatabase = () => void;
const liveTimeoutMs = Number(process.env.OD_RUNTIME_LIVE_TIMEOUT_MS || 180_000); const liveTimeoutMs = Number(process.env.OD_RUNTIME_LIVE_TIMEOUT_MS || 180_000);
const requestedRuntimeIds = parseRuntimeIds(process.env.OD_E2E_RUNTIMES); const requestedRuntimeIds = parseRuntimeIds(process.env.OD_E2E_RUNTIMES);
const maxRuntimeCount = 8; const maxRuntimeCount = 8;
const marker = 'OD_RUNTIME_ADAPTER_LIVE_OK'; const marker = 'OD_RUNTIME_ADAPTER_LIVE_OK';
let baseUrl; let baseUrl: string;
let server; let server: StartServerResult['server'] | undefined;
let startServer; let startServer: StartServer;
let closeDatabase; let closeDatabase: CloseDatabase | undefined;
let detectedAgents; let detectedAgents: AgentInfo[] | undefined;
let dataDir; let dataDir: string;
test.before(async () => { test.before(async () => {
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'od-runtime-adapter-live-')); dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'od-runtime-adapter-live-'));
process.env.OD_DATA_DIR = dataDir; process.env.OD_DATA_DIR = dataDir;
({ startServer } = await import('../../apps/daemon/server.js')); ({ startServer } = await import('../../apps/daemon/dist/server.js') as { startServer: StartServer });
({ closeDatabase } = await import('../../apps/daemon/db.js')); ({ closeDatabase } = await import('../../apps/daemon/dist/db.js') as { closeDatabase: CloseDatabase });
const started = await startServer({ port: 0, returnServer: true }); const started = await startServer({ port: 0, returnServer: true });
baseUrl = started.url; baseUrl = started.url;
server = started.server; server = started.server;
@ -31,8 +56,8 @@ test.before(async () => {
test.after(async () => { test.after(async () => {
if (server) { if (server) {
await new Promise((resolve, reject) => { await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve())); server?.close((err) => (err ? reject(err) : resolve()));
}); });
} }
closeDatabase?.(); closeDatabase?.();
@ -45,7 +70,7 @@ test('runtime adapter live detection flow exposes installed runtimes', async ()
log('detect', 'starting runtime detection via /api/agents'); log('detect', 'starting runtime detection via /api/agents');
const res = await fetch(`${baseUrl}/api/agents`); const res = await fetch(`${baseUrl}/api/agents`);
assert.equal(res.status, 200); assert.equal(res.status, 200);
const body = await res.json(); const body = await readAgentsResponse(res);
assert.ok(Array.isArray(body.agents)); assert.ok(Array.isArray(body.agents));
assert.ok(body.agents.length > 0); assert.ok(body.agents.length > 0);
@ -77,7 +102,8 @@ test('runtime adapter live detection flow exposes installed runtimes', async ()
assert.equal(typeof agent.streamFormat, 'string'); assert.equal(typeof agent.streamFormat, 'string');
if (agent.available) { if (agent.available) {
assert.equal(typeof agent.path, 'string'); assert.equal(typeof agent.path, 'string');
assert.ok(agent.path.length > 0); const resolvedPath = agent.path;
assert.ok(resolvedPath && resolvedPath.length > 0);
} }
} }
}); });
@ -86,7 +112,7 @@ test('runtime adapter live run flow streams a successful response for every avai
if (!detectedAgents) { if (!detectedAgents) {
log('run', 'detection cache empty; fetching /api/agents before run flow'); log('run', 'detection cache empty; fetching /api/agents before run flow');
const res = await fetch(`${baseUrl}/api/agents`); const res = await fetch(`${baseUrl}/api/agents`);
detectedAgents = (await res.json()).agents; detectedAgents = (await readAgentsResponse(res)).agents;
} }
const requestedSet = requestedRuntimeIds ? new Set(requestedRuntimeIds) : null; const requestedSet = requestedRuntimeIds ? new Set(requestedRuntimeIds) : null;
@ -95,11 +121,11 @@ test('runtime adapter live run flow streams a successful response for every avai
); );
if (requestedSet) { if (requestedSet) {
log('run', `runtime filter=${requestedRuntimeIds.join(',')}`); log('run', `runtime filter=${requestedRuntimeIds?.join(',')}`);
for (const id of requestedSet) { for (const id of requestedSet) {
assert.ok( assert.ok(
detectedAgents.some((agent) => agent.id === id), detectedAgents.some((agent) => agent.id === id),
`Requested runtime ${id} was not returned by /api/agents.`, `Requested runtime ${id} is missing from /api/agents.`,
); );
} }
} }
@ -118,8 +144,8 @@ test('runtime adapter live run flow streams a successful response for every avai
assert.ok( assert.ok(
availableAgents.length > 0, availableAgents.length > 0,
requestedSet requestedSet
? `No requested runtime is available: ${requestedRuntimeIds.join(',')}.` ? `Requested runtimes unavailable: ${requestedRuntimeIds?.join(',')}.`
: 'No available runtime returned by /api/agents.', : 'Available runtime required from /api/agents.',
); );
for (const agent of availableAgents) { for (const agent of availableAgents) {
@ -127,12 +153,12 @@ test('runtime adapter live run flow streams a successful response for every avai
} }
}); });
async function runRuntime(agent) { async function runRuntime(agent: AgentInfo): Promise<void> {
const startedAt = Date.now(); const startedAt = Date.now();
log('run', `${agent.id}: starting /api/chat live run`); log('run', `${agent.id}: starting /api/chat live run`);
const projectId = `runtime-adapter-live-${Date.now()}-${Math.random().toString(36).slice(2)}`; const projectId = `runtime-adapter-live-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const events = []; const events: ParsedSseEvent[] = [];
const abort = AbortSignal.timeout(liveTimeoutMs); const abort = AbortSignal.timeout(liveTimeoutMs);
try { try {
const res = await fetch(`${baseUrl}/api/chat`, { const res = await fetch(`${baseUrl}/api/chat`, {
@ -154,6 +180,7 @@ async function runRuntime(agent) {
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.match(res.headers.get('content-type') || '', /text\/event-stream/); assert.match(res.headers.get('content-type') || '', /text\/event-stream/);
assert.ok(res.body, 'SSE response should include a readable body.');
await collectSseEvents(res, events, agent.id); await collectSseEvents(res, events, agent.id);
} finally { } finally {
@ -167,17 +194,17 @@ async function runRuntime(agent) {
assert.ok(start, 'SSE stream should include a start event.'); assert.ok(start, 'SSE stream should include a start event.');
assert.equal(start.data.agentId, agent.id); assert.equal(start.data.agentId, agent.id);
assert.equal(start.data.projectId, projectId); assert.equal(start.data.projectId, projectId);
log('run', `${agent.id}: start event cwd=${start.data.cwd}`); log('run', `${agent.id}: start event cwd=${String(start.data.cwd ?? '')}`);
const end = events.find((event) => event.event === 'end'); const end = events.find((event) => event.event === 'end');
assert.ok(end, 'SSE stream should include an end event.'); assert.ok(end, 'SSE stream should include an end event.');
assert.equal(end.data.code, 0, renderEvents(events)); assert.equal(end.data.code, 0, renderEvents(events));
log('run', `${agent.id}: end event code=${end.data.code} signal=${end.data.signal ?? 'none'}`); log('run', `${agent.id}: end event code=${String(end.data.code)} signal=${String(end.data.signal ?? 'none')}`);
const text = events const text = events
.map((event) => { .map((event) => {
if (event.event === 'stdout') return event.data.chunk || ''; if (event.event === 'stdout') return stringData(event.data.chunk);
if (event.event === 'agent') return event.data.text || event.data.delta || ''; if (event.event === 'agent') return stringData(event.data.text) || stringData(event.data.delta);
return ''; return '';
}) })
.join(''); .join('');
@ -185,11 +212,12 @@ async function runRuntime(agent) {
log('run', `${agent.id}: passed in ${Date.now() - startedAt}ms`); log('run', `${agent.id}: passed in ${Date.now() - startedAt}ms`);
} }
async function collectSseEvents(res, events, agentId) { async function collectSseEvents(res: Response, events: ParsedSseEvent[], agentId: string): Promise<void> {
const reader = res.body.getReader(); const reader = res.body?.getReader();
assert.ok(reader, 'SSE response should include a readable body.');
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
const seen = new Set(); const seen = new Set<string>();
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
@ -216,7 +244,7 @@ async function collectSseEvents(res, events, agentId) {
} }
} }
function parseSseEvent(chunk) { function parseSseEvent(chunk: string): ParsedSseEvent | null {
const lines = chunk.split('\n'); const lines = chunk.split('\n');
if (lines.every((line) => line === '' || line.startsWith(':'))) return null; if (lines.every((line) => line === '' || line.startsWith(':'))) return null;
@ -225,15 +253,15 @@ function parseSseEvent(chunk) {
if (!eventLine || !dataLine) return null; if (!eventLine || !dataLine) return null;
return { return {
event: eventLine.slice('event: '.length), event: eventLine.slice('event: '.length),
data: JSON.parse(dataLine.slice('data: '.length)), data: JSON.parse(dataLine.slice('data: '.length)) as Record<string, unknown>,
}; };
} }
function renderEvents(events) { function renderEvents(events: ParsedSseEvent[]): string {
return JSON.stringify(events, null, 2).slice(0, 8000); return JSON.stringify(events, null, 2).slice(0, 8000);
} }
function parseRuntimeIds(value) { function parseRuntimeIds(value: string | undefined): string[] | null {
if (!value) return null; if (!value) return null;
const ids = value const ids = value
.split(',') .split(',')
@ -242,11 +270,11 @@ function parseRuntimeIds(value) {
return ids.length > 0 ? ids : null; return ids.length > 0 ? ids : null;
} }
function log(stage, message) { function log(stage: string, message: string): void {
console.log(`[runtime-adapter:e2e:${stage}] ${message}`); console.log(`[runtime-adapter:e2e:${stage}] ${message}`);
} }
function logSseProgress(agentId, event, seen) { function logSseProgress(agentId: string, event: ParsedSseEvent, seen: Set<string>): void {
if (event.event === 'start' && !seen.has('start')) { if (event.event === 'start' && !seen.has('start')) {
seen.add('start'); seen.add('start');
log('run', `${agentId}: received start event`); log('run', `${agentId}: received start event`);
@ -257,9 +285,10 @@ function logSseProgress(agentId, event, seen) {
log('run', `${agentId}: received stdout stream`); log('run', `${agentId}: received stdout stream`);
return; return;
} }
if (event.event === 'agent' && !seen.has(`agent:${event.data.type || 'event'}`)) { const type = stringData(event.data.type) || 'event';
seen.add(`agent:${event.data.type || 'event'}`); if (event.event === 'agent' && !seen.has(`agent:${type}`)) {
log('run', `${agentId}: received agent event type=${event.data.type || 'unknown'}`); seen.add(`agent:${type}`);
log('run', `${agentId}: received agent event type=${type || 'unknown'}`);
return; return;
} }
if (event.event === 'stderr' && !seen.has('stderr')) { if (event.event === 'stderr' && !seen.has('stderr')) {
@ -268,7 +297,7 @@ function logSseProgress(agentId, event, seen) {
return; return;
} }
if (event.event === 'error') { if (event.event === 'error') {
log('run', `${agentId}: received error event ${event.data.message || ''}`.trim()); log('run', `${agentId}: received error event ${stringData(event.data.message)}`.trim());
return; return;
} }
if (event.event === 'end' && !seen.has('end')) { if (event.event === 'end' && !seen.has('end')) {
@ -276,3 +305,12 @@ function logSseProgress(agentId, event, seen) {
log('run', `${agentId}: received end event`); log('run', `${agentId}: received end event`);
} }
} }
async function readAgentsResponse(res: Response): Promise<AgentsResponse> {
const body = await res.json() as Partial<AgentsResponse>;
return { agents: Array.isArray(body.agents) ? body.agents : [] };
}
function stringData(value: unknown): string {
return typeof value === 'string' ? value : '';
}

28
e2e/tsconfig.json Normal file
View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"types": ["node", "vitest"]
},
"include": [
"playwright.config.ts",
"vitest.config.ts",
"cases/report-metadata.ts",
"reporters/**/*.ts",
"scripts/**/*.ts"
],
"exclude": ["node_modules", "reports", ".od-data"]
}

View file

@ -7,22 +7,23 @@
"description": "Local-first design product: detects your installed code-agent CLI, runs design skills + design systems, streams artifacts into a sandboxed preview.", "description": "Local-first design product: detects your installed code-agent CLI, runs design skills + design systems, streams artifacts into a sandboxed preview.",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"od": "./apps/daemon/cli.js" "od": "./apps/daemon/dist/cli.js"
}, },
"scripts": { "scripts": {
"daemon": "pnpm --filter @open-design/daemon daemon", "daemon": "corepack pnpm --filter @open-design/daemon daemon",
"dev": "pnpm --filter @open-design/web dev", "dev": "corepack pnpm --filter @open-design/web dev",
"dev:all": "node scripts/dev-all.mjs", "dev:all": "node --experimental-strip-types scripts/dev-all.ts",
"build": "pnpm --filter @open-design/web build", "build": "corepack pnpm --filter @open-design/web build",
"preview": "pnpm run build && pnpm --filter @open-design/daemon daemon", "check:residual-js": "node --experimental-strip-types scripts/check-residual-js.ts",
"test:e2e:live": "pnpm --filter @open-design/e2e test:e2e:live", "preview": "corepack pnpm run build && corepack pnpm --filter @open-design/daemon daemon",
"test": "pnpm --filter @open-design/web test && pnpm --filter @open-design/daemon test && pnpm --filter @open-design/e2e test", "test:e2e:live": "corepack pnpm --filter @open-design/e2e test:e2e:live",
"test:ui:clean": "pnpm --filter @open-design/e2e test:ui:clean", "test": "corepack pnpm --filter @open-design/web test && corepack pnpm --filter @open-design/daemon test && corepack pnpm --filter @open-design/e2e test",
"test:ui": "pnpm --filter @open-design/e2e test:ui", "test:ui:clean": "corepack pnpm --filter @open-design/e2e test:ui:clean",
"test:ui:headed": "pnpm --filter @open-design/e2e test:ui:headed", "test:ui": "corepack pnpm --filter @open-design/e2e test:ui",
"typecheck": "pnpm --filter @open-design/web typecheck", "test:ui:headed": "corepack pnpm --filter @open-design/e2e test:ui:headed",
"start": "pnpm run build && pnpm --filter @open-design/daemon start", "typecheck": "corepack pnpm --filter @open-design/contracts typecheck && corepack pnpm --filter @open-design/web typecheck && corepack pnpm --filter @open-design/daemon typecheck && corepack pnpm --filter @open-design/daemon build && corepack pnpm --filter @open-design/e2e exec tsc -p ../scripts/tsconfig.json --noEmit && corepack pnpm --filter @open-design/e2e typecheck && corepack pnpm run check:residual-js",
"test:run": "pnpm run test" "start": "corepack pnpm run build && corepack pnpm --filter @open-design/daemon start",
"test:run": "corepack pnpm run test"
}, },
"engines": { "engines": {
"node": "~24", "node": "~24",

View file

@ -0,0 +1,20 @@
{
"name": "@open-design/contracts",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Shared pure TypeScript contracts for the Open Design web/daemon boundary.",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"typescript": "^5.6.3"
}
}

View file

@ -0,0 +1,51 @@
import type { JsonValue } from '../common';
export type ArtifactKind =
| 'html'
| 'deck'
| 'react-component'
| 'markdown-document'
| 'svg'
| 'diagram'
| 'code-snippet'
| 'mini-app'
| 'design-system';
export type ArtifactRendererId =
| 'html'
| 'deck-html'
| 'react-component'
| 'markdown'
| 'svg'
| 'diagram'
| 'code'
| 'mini-app'
| 'design-system';
export type ArtifactExportKind = 'html' | 'pdf' | 'zip' | 'pptx' | 'jsx' | 'md' | 'svg' | 'txt';
export interface ArtifactManifest {
version: 1;
kind: ArtifactKind;
title: string;
entry: string;
renderer: ArtifactRendererId;
exports: ArtifactExportKind[];
supportingFiles?: string[];
createdAt?: string;
updatedAt?: string;
sourceSkillId?: string;
designSystemId?: string | null;
metadata?: Record<string, JsonValue | undefined>;
}
export interface SaveArtifactRequest {
identifier: string;
title: string;
html: string;
}
export interface SaveArtifactResponse {
url: string;
path: string;
}

View file

@ -0,0 +1,42 @@
import type { ProjectFile } from './files';
export type ChatRole = 'user' | 'assistant';
export interface ChatRequest {
agentId: string;
message: string;
systemPrompt?: string;
projectId?: string | null;
attachments?: string[];
model?: string | null;
reasoning?: string | null;
}
export interface ChatAttachment {
path: string;
name: string;
kind: 'image' | 'file';
size?: number;
}
export type PersistedAgentEvent =
| { kind: 'status'; label: string; detail?: string }
| { kind: 'text'; text: string }
| { kind: 'thinking'; text: string }
| { kind: 'tool_use'; id: string; name: string; input: unknown }
| { kind: 'tool_result'; toolUseId: string; content: string; isError: boolean }
| { kind: 'usage'; inputTokens?: number; outputTokens?: number; costUsd?: number; durationMs?: number }
| { kind: 'raw'; line: string };
export interface ChatMessage {
id: string;
role: ChatRole;
content: string;
agentId?: string;
agentName?: string;
events?: PersistedAgentEvent[];
startedAt?: number;
endedAt?: number;
attachments?: ChatAttachment[];
producedFiles?: ProjectFile[];
}

View file

@ -0,0 +1,38 @@
import type { OkResponse } from '../common';
import type { ArtifactKind, ArtifactManifest } from './artifacts';
export type ProjectFileKind =
| 'html'
| 'image'
| 'sketch'
| 'text'
| 'code'
| 'pdf'
| 'document'
| 'presentation'
| 'spreadsheet'
| 'binary';
export interface ProjectFile {
name: string;
path?: string;
type?: 'file' | 'dir';
size: number;
mtime: number;
kind: ProjectFileKind;
mime: string;
artifactKind?: ArtifactKind;
artifactManifest?: ArtifactManifest;
}
export interface ProjectFilesResponse {
files: ProjectFile[];
}
export interface ProjectFileResponse {
file: ProjectFile;
}
export interface UploadProjectFilesResponse extends ProjectFilesResponse {}
export interface DeleteProjectFileResponse extends OkResponse {}

View file

@ -0,0 +1,92 @@
import type { ChatMessage } from './chat';
export type ProjectKind = 'prototype' | 'deck' | 'template' | 'other';
export interface ProjectMetadata {
kind: ProjectKind;
fidelity?: 'wireframe' | 'high-fidelity';
speakerNotes?: boolean;
animations?: boolean;
templateId?: string;
templateLabel?: string;
inspirationDesignSystemIds?: string[];
importedFrom?: 'claude-design' | string;
entryFile?: string;
sourceFileName?: string;
}
export interface Project {
id: string;
name: string;
skillId: string | null;
designSystemId: string | null;
createdAt: number;
updatedAt: number;
pendingPrompt?: string;
metadata?: ProjectMetadata;
}
export interface ProjectTemplate {
id: string;
name: string;
sourceProjectId?: string;
files: Array<{ name: string; content: string }>;
description?: string;
createdAt: number;
}
export interface Conversation {
id: string;
projectId: string;
title: string | null;
createdAt: number;
updatedAt: number;
}
export interface CreateProjectRequest {
name: string;
skillId?: string | null;
designSystemId?: string | null;
pendingPrompt?: string;
metadata?: ProjectMetadata;
}
export interface UpdateProjectRequest {
name?: string;
skillId?: string | null;
designSystemId?: string | null;
pendingPrompt?: string | null;
metadata?: ProjectMetadata | null;
}
export interface ProjectsResponse {
projects: Project[];
}
export interface ProjectResponse {
project: Project;
}
export interface CreateProjectResponse extends ProjectResponse {
conversationId?: string;
}
export interface ConversationsResponse {
conversations: Conversation[];
}
export interface ConversationResponse {
conversation: Conversation;
}
export interface CreateConversationRequest {
title?: string | null;
}
export interface UpdateConversationRequest {
title?: string | null;
}
export interface MessagesResponse {
messages: ChatMessage[];
}

View file

@ -0,0 +1,26 @@
export type ProxyMessageRole = 'system' | 'user' | 'assistant' | 'tool';
export interface ProxyMessage {
role: ProxyMessageRole;
content: string;
}
export interface ProxyStreamRequest {
baseUrl: string;
apiKey: string;
model: string;
systemPrompt?: string;
messages: ProxyMessage[];
}
export interface ProxyStreamStartPayload {
model?: string;
}
export interface ProxyStreamDeltaPayload {
delta: string;
}
export interface ProxyStreamEndPayload {
code?: number;
}

View file

@ -0,0 +1,77 @@
export interface AgentModelOption {
id: string;
label: string;
}
export interface AgentInfo {
id: string;
name: string;
bin: string;
available: boolean;
path?: string;
version?: string | null;
models?: AgentModelOption[];
reasoningOptions?: AgentModelOption[];
}
export interface AgentsResponse {
agents: AgentInfo[];
}
export interface SkillSummary {
id: string;
name: string;
description: string;
triggers: string[];
mode: 'prototype' | 'deck' | 'template' | 'design-system';
platform?: 'desktop' | 'mobile' | null;
scenario?: string | null;
previewType: string;
designSystemRequired: boolean;
defaultFor: string[];
upstream: string | null;
featured?: number | null;
fidelity?: 'wireframe' | 'high-fidelity' | null;
speakerNotes?: boolean | null;
animations?: boolean | null;
hasBody: boolean;
examplePrompt: string;
}
export interface SkillDetail extends SkillSummary {
body: string;
}
export interface SkillsResponse {
skills: SkillSummary[];
}
export interface SkillResponse {
skill: SkillDetail;
}
export interface DesignSystemSummary {
id: string;
title: string;
category: string;
summary: string;
swatches?: string[];
}
export interface DesignSystemDetail extends DesignSystemSummary {
body: string;
}
export interface DesignSystemsResponse {
designSystems: DesignSystemSummary[];
}
export interface DesignSystemResponse {
designSystem: DesignSystemDetail;
}
export interface HealthResponse {
ok: true;
service?: 'daemon';
version?: string;
}

View file

@ -0,0 +1,17 @@
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
export interface OkResponse {
ok: true;
}
export interface IdResponse {
id: string;
}
export type EntityResponse<Key extends string, Value> = Record<Key, Value>;
export type EntityListResponse<Key extends string, Value> = Record<Key, Value[]>;
export type Nullable<T> = T | null;

View file

@ -0,0 +1,54 @@
import type { JsonValue } from './common';
export const API_ERROR_CODES = [
'BAD_REQUEST',
'UNAUTHORIZED',
'FORBIDDEN',
'NOT_FOUND',
'CONFLICT',
'PAYLOAD_TOO_LARGE',
'UNSUPPORTED_MEDIA_TYPE',
'VALIDATION_FAILED',
'AGENT_UNAVAILABLE',
'AGENT_EXECUTION_FAILED',
'PROJECT_NOT_FOUND',
'FILE_NOT_FOUND',
'ARTIFACT_NOT_FOUND',
'UPSTREAM_UNAVAILABLE',
'RATE_LIMITED',
'INTERNAL_ERROR',
] as const;
export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
export interface ApiError {
code: ApiErrorCode;
message: string;
details?: JsonValue;
retryable?: boolean;
requestId?: string;
taskId?: string;
}
export interface ApiErrorResponse {
error: ApiError;
}
export type LegacyErrorResponse =
| { error: string }
| { code: string; error: string };
export type CompatibleErrorResponse = ApiErrorResponse | LegacyErrorResponse;
export interface SseErrorPayload {
message: string;
error?: ApiError;
}
export function createApiError(code: ApiErrorCode, message: string, init: Omit<ApiError, 'code' | 'message'> = {}): ApiError {
return { code, message, ...init };
}
export function createApiErrorResponse(error: ApiError): ApiErrorResponse {
return { error };
}

View file

@ -0,0 +1,49 @@
import type { ChatRequest } from './api/chat';
import type { ProjectFile } from './api/files';
import type { HealthResponse } from './api/registry';
import type { ApiErrorResponse } from './errors';
import type { ChatSseEvent } from './sse/chat';
import type { ProxySseEvent } from './sse/proxy';
export const exampleChatRequest: ChatRequest = {
agentId: 'claude',
message: '## user\nCreate a design',
systemPrompt: 'Design carefully.',
projectId: 'project_1',
attachments: ['brief.pdf'],
model: 'default',
reasoning: null,
};
export const exampleProjectFile: ProjectFile = {
name: 'index.html',
path: 'index.html',
type: 'file',
size: 1024,
mtime: 1_713_000_000,
kind: 'html',
mime: 'text/html',
};
export const exampleChatSseEvents: ChatSseEvent[] = [
{ event: 'start', data: { bin: 'claude', cwd: '/legacy/internal/path' } },
{ event: 'agent', data: { type: 'text_delta', delta: 'Hello' } },
{ event: 'stdout', data: { chunk: 'plain output' } },
{ event: 'end', data: { code: 0 } },
];
export const exampleProxySseEvents: ProxySseEvent[] = [
{ event: 'start', data: { model: 'gpt-4o-mini' } },
{ event: 'delta', data: { delta: 'Hello' } },
{ event: 'end', data: { code: 0 } },
];
export const exampleApiErrorResponse: ApiErrorResponse = {
error: {
code: 'BAD_REQUEST',
message: 'Missing message',
retryable: false,
},
};
export const exampleHealthResponse: HealthResponse = { ok: true, service: 'daemon' };

View file

@ -0,0 +1,12 @@
export * from './common';
export * from './errors';
export * from './tasks';
export * from './api/artifacts';
export * from './api/chat';
export * from './api/files';
export * from './api/projects';
export * from './api/proxy';
export * from './api/registry';
export * from './sse/common';
export * from './sse/chat';
export * from './sse/proxy';

View file

@ -0,0 +1,37 @@
import type { SseErrorPayload } from '../errors';
import type { SseTransportEvent } from './common';
export const CHAT_SSE_PROTOCOL_VERSION = 1;
export interface ChatSseStartPayload {
bin: string;
protocolVersion?: typeof CHAT_SSE_PROTOCOL_VERSION;
/** Legacy daemon-internal absolute cwd. Kept for compatibility during W2 adoption. */
cwd?: string;
}
export interface ChatSseChunkPayload {
chunk: string;
}
export interface ChatSseEndPayload {
code: number | null;
}
export type DaemonAgentPayload =
| { type: 'status'; label: string; model?: string; ttftMs?: number; detail?: string }
| { type: 'text_delta'; delta: string }
| { type: 'thinking_delta'; delta: string }
| { type: 'thinking_start' }
| { type: 'tool_use'; id: string; name: string; input: unknown }
| { type: 'tool_result'; toolUseId: string; content: string; isError?: boolean }
| { type: 'usage'; usage?: { input_tokens?: number; output_tokens?: number }; costUsd?: number; durationMs?: number }
| { type: 'raw'; line: string };
export type ChatSseEvent =
| SseTransportEvent<'start', ChatSseStartPayload>
| SseTransportEvent<'agent', DaemonAgentPayload>
| SseTransportEvent<'stdout', ChatSseChunkPayload>
| SseTransportEvent<'stderr', ChatSseChunkPayload>
| SseTransportEvent<'error', SseErrorPayload>
| SseTransportEvent<'end', ChatSseEndPayload>;

View file

@ -0,0 +1,10 @@
export interface SseTransportEvent<Name extends string, Payload> {
event: Name;
data: Payload;
}
export type SseEventName<Event> = Event extends SseTransportEvent<infer Name, unknown> ? Name : never;
export type SseEventPayload<Event, Name extends string> = Event extends SseTransportEvent<Name, infer Payload>
? Payload
: never;

View file

@ -0,0 +1,11 @@
import type { ProxyStreamDeltaPayload, ProxyStreamEndPayload, ProxyStreamStartPayload } from '../api/proxy';
import type { SseErrorPayload } from '../errors';
import type { SseTransportEvent } from './common';
export const PROXY_SSE_PROTOCOL_VERSION = 1;
export type ProxySseEvent =
| SseTransportEvent<'start', ProxyStreamStartPayload>
| SseTransportEvent<'delta', ProxyStreamDeltaPayload>
| SseTransportEvent<'error', SseErrorPayload>
| SseTransportEvent<'end', ProxyStreamEndPayload>;

View file

@ -0,0 +1,20 @@
export const TASK_STATES = [
'queued',
'starting',
'running',
'succeeded',
'failed',
'cancelled',
] as const;
export type TaskState = (typeof TASK_STATES)[number];
export interface TaskStatus {
id: string;
state: TaskState;
label?: string;
detail?: string;
startedAt?: number;
updatedAt?: number;
endedAt?: number;
}

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View file

@ -10,6 +10,9 @@ importers:
apps/daemon: apps/daemon:
dependencies: dependencies:
'@open-design/contracts':
specifier: workspace:*
version: link:../../packages/contracts
better-sqlite3: better-sqlite3:
specifier: ^11.10.0 specifier: ^11.10.0
version: 11.10.0 version: 11.10.0
@ -23,6 +26,21 @@ importers:
specifier: ^1.4.5-lts.1 specifier: ^1.4.5-lts.1
version: 1.4.5-lts.2 version: 1.4.5-lts.2
devDependencies: devDependencies:
'@types/better-sqlite3':
specifier: ^7.6.13
version: 7.6.13
'@types/express':
specifier: ^4.17.21
version: 4.17.25
'@types/multer':
specifier: ^1.4.12
version: 1.4.13
'@types/node':
specifier: ^20.17.10
version: 20.19.39
typescript:
specifier: ^5.6.3
version: 5.9.3
vitest: vitest:
specifier: ^2.1.8 specifier: ^2.1.8
version: 2.1.9(@types/node@20.19.39)(jsdom@29.1.0) version: 2.1.9(@types/node@20.19.39)(jsdom@29.1.0)
@ -32,6 +50,9 @@ importers:
'@anthropic-ai/sdk': '@anthropic-ai/sdk':
specifier: ^0.32.1 specifier: ^0.32.1
version: 0.32.1 version: 0.32.1
'@open-design/contracts':
specifier: workspace:*
version: link:../../packages/contracts
next: next:
specifier: ^16.2.4 specifier: ^16.2.4
version: 16.2.4(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 16.2.4(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -88,6 +109,12 @@ importers:
specifier: ^2.1.8 specifier: ^2.1.8
version: 2.1.9(@types/node@20.19.39)(jsdom@29.1.0) version: 2.1.9(@types/node@20.19.39)(jsdom@29.1.0)
packages/contracts:
devDependencies:
typescript:
specifier: ^5.6.3
version: 5.9.3
packages: packages:
'@anthropic-ai/sdk@0.32.1': '@anthropic-ai/sdk@0.32.1':
@ -689,9 +716,33 @@ packages:
'@types/aria-query@5.0.4': '@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
'@types/better-sqlite3@7.6.13':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/express-serve-static-core@4.19.8':
resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==}
'@types/express@4.17.25':
resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==}
'@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/multer@1.4.13':
resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==}
'@types/node-fetch@2.6.13': '@types/node-fetch@2.6.13':
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
@ -704,6 +755,12 @@ packages:
'@types/prop-types@15.7.15': '@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/qs@6.15.0':
resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==}
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
'@types/react-dom@18.3.7': '@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
peerDependencies: peerDependencies:
@ -712,6 +769,15 @@ packages:
'@types/react@18.3.28': '@types/react@18.3.28':
resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==}
'@types/send@0.17.6':
resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==}
'@types/send@1.2.1':
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
'@types/serve-static@1.15.10':
resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==}
'@vitest/expect@2.1.9': '@vitest/expect@2.1.9':
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
@ -2076,8 +2142,43 @@ snapshots:
'@types/aria-query@5.0.4': {} '@types/aria-query@5.0.4': {}
'@types/better-sqlite3@7.6.13':
dependencies:
'@types/node': 20.19.39
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 20.19.39
'@types/connect@3.4.38':
dependencies:
'@types/node': 20.19.39
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/express-serve-static-core@4.19.8':
dependencies:
'@types/node': 20.19.39
'@types/qs': 6.15.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
'@types/express@4.17.25':
dependencies:
'@types/body-parser': 1.19.6
'@types/express-serve-static-core': 4.19.8
'@types/qs': 6.15.0
'@types/serve-static': 1.15.10
'@types/http-errors@2.0.5': {}
'@types/mime@1.3.5': {}
'@types/multer@1.4.13':
dependencies:
'@types/express': 4.17.25
'@types/node-fetch@2.6.13': '@types/node-fetch@2.6.13':
dependencies: dependencies:
'@types/node': 20.19.39 '@types/node': 20.19.39
@ -2093,6 +2194,10 @@ snapshots:
'@types/prop-types@15.7.15': {} '@types/prop-types@15.7.15': {}
'@types/qs@6.15.0': {}
'@types/range-parser@1.2.7': {}
'@types/react-dom@18.3.7(@types/react@18.3.28)': '@types/react-dom@18.3.7(@types/react@18.3.28)':
dependencies: dependencies:
'@types/react': 18.3.28 '@types/react': 18.3.28
@ -2102,6 +2207,21 @@ snapshots:
'@types/prop-types': 15.7.15 '@types/prop-types': 15.7.15
csstype: 3.2.3 csstype: 3.2.3
'@types/send@0.17.6':
dependencies:
'@types/mime': 1.3.5
'@types/node': 20.19.39
'@types/send@1.2.1':
dependencies:
'@types/node': 20.19.39
'@types/serve-static@1.15.10':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 20.19.39
'@types/send': 0.17.6
'@vitest/expect@2.1.9': '@vitest/expect@2.1.9':
dependencies: dependencies:
'@vitest/spy': 2.1.9 '@vitest/spy': 2.1.9

View file

@ -1,3 +1,4 @@
packages: packages:
- packages/*
- apps/* - apps/*
- e2e - e2e

View file

@ -0,0 +1,85 @@
import { readdir } from "node:fs/promises";
import path from "node:path";
const repoRoot = path.resolve(import.meta.dirname, "..");
const residualExtensions = new Set([".js", ".mjs", ".cjs"]);
const skippedDirectories = new Set([
".agents",
".claude",
".claude-sessions",
".codex",
".cursor",
".git",
".od",
".od-e2e",
".opencode",
".task",
".vite",
"node_modules",
]);
const allowedPathPrefixes = [
"apps/daemon/dist/",
"apps/web/.next/",
"apps/web/out/",
"generated/",
"e2e/playwright-report/",
"e2e/reports/html/",
"e2e/reports/playwright-html-report/",
"e2e/reports/test-results/",
"test-results/",
"vendor/",
];
function toRepositoryPath(filePath: string): string {
return path.relative(repoRoot, filePath).split(path.sep).join("/");
}
function isAllowedOutputPath(repositoryPath: string): boolean {
return allowedPathPrefixes.some((prefix) => repositoryPath.startsWith(prefix));
}
async function collectResidualJavaScript(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const residualFiles: string[] = [];
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
const repositoryPath = toRepositoryPath(fullPath);
if (entry.isDirectory()) {
if (skippedDirectories.has(entry.name) || isAllowedOutputPath(`${repositoryPath}/`)) {
continue;
}
residualFiles.push(...(await collectResidualJavaScript(fullPath)));
continue;
}
if (!entry.isFile() || !residualExtensions.has(path.extname(entry.name))) {
continue;
}
if (isAllowedOutputPath(repositoryPath)) {
continue;
}
residualFiles.push(repositoryPath);
}
return residualFiles;
}
const residualFiles = await collectResidualJavaScript(repoRoot);
if (residualFiles.length > 0) {
console.error("Residual project-owned JavaScript files found:");
for (const filePath of residualFiles) {
console.error(`- ${filePath}`);
}
console.error("Convert these files to TypeScript or add a documented generated/vendor/output allowlist entry.");
process.exitCode = 1;
} else {
console.log("Residual JavaScript check passed: project-owned code is TypeScript-only.");
}

14
scripts/dev-all.mjs → scripts/dev-all.ts Executable file → Normal file
View file

@ -6,7 +6,7 @@
// apps, so a stray process holding either port doesn't kill the // apps, so a stray process holding either port doesn't kill the
// whole boot. The resolved ports are exported into the child env, which // whole boot. The resolved ports are exported into the child env, which
// means: // means:
// * the daemon's cli.js sees the new OD_PORT and binds to it // * the daemon's cli.ts sees the new OD_PORT and binds to it
// * apps/web/next.config.ts reads the same OD_PORT and proxies /api, /artifacts, // * apps/web/next.config.ts reads the same OD_PORT and proxies /api, /artifacts,
// /frames to the daemon's actual port // /frames to the daemon's actual port
// * Next.js binds to NEXT_PORT (we pass `-p $NEXT_PORT` to the web package // * Next.js binds to NEXT_PORT (we pass `-p $NEXT_PORT` to the web package
@ -17,7 +17,7 @@
// the switch so the user notices. // the switch so the user notices.
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { findFreePort } from './resolve-dev-ports.mjs'; import { findFreePort } from './resolve-dev-ports.ts';
const desiredDaemon = Number(process.env.OD_PORT) || 7456; const desiredDaemon = Number(process.env.OD_PORT) || 7456;
const desiredNext = Number(process.env.NEXT_PORT) || 3000; const desiredNext = Number(process.env.NEXT_PORT) || 3000;
@ -49,14 +49,14 @@ const env = {
PORT: String(nextPort), PORT: String(nextPort),
}; };
const packageManager = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; const packageManager = process.platform === 'win32' ? 'corepack.cmd' : 'corepack';
const children = [ const children = [
spawn(packageManager, ['--filter', '@open-design/daemon', 'daemon'], { spawn(packageManager, ['pnpm', '--filter', '@open-design/daemon', 'daemon'], {
env, env,
stdio: 'inherit', stdio: 'inherit',
}), }),
spawn(packageManager, ['--filter', '@open-design/web', 'dev', '-p', String(nextPort)], { spawn(packageManager, ['pnpm', '--filter', '@open-design/web', 'dev', '-p', String(nextPort)], {
env, env,
stdio: 'inherit', stdio: 'inherit',
}), }),
@ -64,7 +64,7 @@ const children = [
let shuttingDown = false; let shuttingDown = false;
function stopChildren(signal = 'SIGTERM') { function stopChildren(signal: NodeJS.Signals = 'SIGTERM'): void {
for (const child of children) { for (const child of children) {
if (!child.killed) child.kill(signal); if (!child.killed) child.kill(signal);
} }
@ -80,7 +80,7 @@ for (const child of children) {
}); });
} }
for (const sig of ['SIGINT', 'SIGTERM']) { for (const sig of ['SIGINT', 'SIGTERM'] as const) {
process.on(sig, () => { process.on(sig, () => {
shuttingDown = true; shuttingDown = true;
stopChildren(sig); stopChildren(sig);

View file

@ -3,7 +3,23 @@ import net from 'node:net';
const HOST = '127.0.0.1'; const HOST = '127.0.0.1';
const DEFAULT_PORT_SEARCH_RANGE = 50; const DEFAULT_PORT_SEARCH_RANGE = 50;
export function isPortFree(port, host = HOST) { export interface PortSearchOptions {
host?: string;
searchRange?: number;
}
export interface ResolveDevPortsOptions extends PortSearchOptions {
daemonStart?: number;
appStart?: number;
appLabel?: string;
}
export interface ResolvedDevPorts {
daemonPort: number;
appPort: number;
}
export function isPortFree(port: number, host = HOST): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const server = net.createServer(); const server = net.createServer();
server.unref(); server.unref();
@ -15,11 +31,11 @@ export function isPortFree(port, host = HOST) {
} }
export async function findFreePort( export async function findFreePort(
start, start: number,
label, label: string,
{ host = HOST, searchRange = DEFAULT_PORT_SEARCH_RANGE } = {}, { host = HOST, searchRange = DEFAULT_PORT_SEARCH_RANGE }: PortSearchOptions = {},
) { ): Promise<number> {
for (let port = start; port < start + searchRange; port++) { for (let port = start; port < start + searchRange; port += 1) {
if (await isPortFree(port, host)) return port; if (await isPortFree(port, host)) return port;
} }
throw new Error( throw new Error(
@ -33,7 +49,7 @@ export async function resolveDevPorts({
appLabel = 'app', appLabel = 'app',
host = HOST, host = HOST,
searchRange = DEFAULT_PORT_SEARCH_RANGE, searchRange = DEFAULT_PORT_SEARCH_RANGE,
} = {}) { }: ResolveDevPortsOptions = {}): Promise<ResolvedDevPorts> {
const daemonPort = await findFreePort(daemonStart, 'daemon', { const daemonPort = await findFreePort(daemonStart, 'daemon', {
host, host,
searchRange, searchRange,

View file

@ -4,17 +4,23 @@
// Usage: // Usage:
// 1) curl -sL $(npm view getdesign dist.tarball) -o /tmp/getdesign.tgz // 1) curl -sL $(npm view getdesign dist.tarball) -o /tmp/getdesign.tgz
// tar -xzf /tmp/getdesign.tgz -C /tmp // tar -xzf /tmp/getdesign.tgz -C /tmp
// 2) node scripts/sync-design-systems.mjs [/tmp/package/templates] // 2) node --experimental-strip-types scripts/sync-design-systems.ts [/tmp/package/templates]
// //
// The script re-creates each brand's design-systems/<slug>/DESIGN.md with a // The script re-creates each brand's design-systems/<slug>/DESIGN.md with a
// `> Category: <name>` line inserted after the H1, mapped from the // `> Category: <name>` line inserted after the H1, mapped from the
// awesome-design-md README. Hand-authored systems (default, warm-editorial) // awesome-design-md README. Hand-authored systems (default, warm-editorial)
// are not touched. // are left untouched.
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
interface ManifestEntry {
brand: string;
file: string;
description: string;
}
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..'); const ROOT = path.resolve(__dirname, '..');
const SRC = process.argv[2] || '/tmp/package/templates'; const SRC = process.argv[2] || '/tmp/package/templates';
@ -60,37 +66,66 @@ const CATEGORY = {
// Automotive // Automotive
bmw: 'Automotive', bugatti: 'Automotive', ferrari: 'Automotive', bmw: 'Automotive', bugatti: 'Automotive', ferrari: 'Automotive',
lamborghini: 'Automotive', renault: 'Automotive', tesla: 'Automotive', lamborghini: 'Automotive', renault: 'Automotive', tesla: 'Automotive',
}; } as const;
const slugOf = (b) => b.replace(/\./g, '-'); type Brand = keyof typeof CATEGORY;
function main() { const slugOf = (brand: string): string => brand.replace(/\./g, '-');
let manifest;
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function readManifest(): ManifestEntry[] {
const raw = readFileSync(path.join(SRC, 'manifest.json'), 'utf8');
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error('manifest.json must contain an array');
}
return parsed.map((entry) => {
if (
typeof entry === 'object' &&
entry !== null &&
'brand' in entry &&
'file' in entry &&
'description' in entry &&
typeof entry.brand === 'string' &&
typeof entry.file === 'string' &&
typeof entry.description === 'string'
) {
return entry;
}
throw new Error('manifest.json contains an invalid entry');
});
}
function main(): void {
let manifest: ManifestEntry[];
try { try {
manifest = JSON.parse(readFileSync(path.join(SRC, 'manifest.json'), 'utf8')); manifest = readManifest();
} catch (err) { } catch (error) {
console.error(`Could not read manifest.json under ${SRC}: ${err.message}`); console.error(`Could not read manifest.json under ${SRC}: ${errorMessage(error)}`);
console.error('Did you extract the getdesign tarball? See scripts/sync-design-systems.mjs header.'); console.error('Did you extract the getdesign tarball? See scripts/sync-design-systems.ts header.');
process.exit(1); process.exit(1);
} }
const written = []; const written: string[] = [];
const skipped = []; const skipped: string[] = [];
for (const entry of manifest) { for (const entry of manifest) {
const { brand, file, description } = entry; const { brand, file, description } = entry;
const cat = CATEGORY[brand]; const cat = CATEGORY[brand as Brand];
if (!cat) { skipped.push(`${brand} (unmapped category)`); continue; } if (!cat) { skipped.push(`${brand} (unmapped category)`); continue; }
const slug = slugOf(brand); const slug = slugOf(brand);
let raw; let raw: string;
try { try {
raw = readFileSync(path.join(SRC, file), 'utf8'); raw = readFileSync(path.join(SRC, file), 'utf8');
} catch (err) { } catch (error) {
skipped.push(`${brand} (${err.message})`); skipped.push(`${brand} (${errorMessage(error)})`);
continue; continue;
} }
const lines = raw.split(/\r?\n/); const lines = raw.split(/\r?\n/);
const h1 = lines.findIndex((l) => /^#\s+/.test(l)); const h1 = lines.findIndex((line) => /^#\s+/.test(line));
if (h1 < 0) { skipped.push(`${brand} (no H1)`); continue; } if (h1 < 0) { skipped.push(`${brand} (no H1)`); continue; }
const head = lines.slice(0, h1 + 1); const head = lines.slice(0, h1 + 1);
const tail = lines.slice(h1 + 1); const tail = lines.slice(h1 + 1);
@ -112,7 +147,7 @@ function main() {
console.log(`wrote ${written.length} design systems → design-systems/`); console.log(`wrote ${written.length} design systems → design-systems/`);
if (skipped.length) { if (skipped.length) {
console.log('skipped:'); console.log('skipped:');
for (const s of skipped) console.log(` - ${s}`); for (const entry of skipped) console.log(` - ${entry}`);
} }
} }

20
scripts/tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"typeRoots": ["../e2e/node_modules/@types"],
"types": ["node"]
},
"include": ["./**/*.ts"]
}

View file

@ -0,0 +1,340 @@
---
id: 20260430-implement-maintainability-w2-w3
name: Implement Maintainability W2 W3
status: implemented
created: '2026-04-30'
---
## Overview
### Problem Statement
`apps/web` and `apps/daemon` need the next maintainability-roadmap workstreams implemented: W2 and W3 from `specs/current/maintainability-roadmap.md`. This spec targets a full project migration to TypeScript as the end state; the implementation plan can still use incremental steps so each step is easy to validate.
### Goals
- Implement W2: define shared API, SSE, and error contracts covering R2, R7, and R8.
- Implement W3 as a full TypeScript migration for project-owned JavaScript entrypoints, modules, scripts, tests, and reporters, covering R1 and related maintainability risk across the repository.
- Provide shared request/response types, an SSE event union, and an error model that can be imported by both web and daemon code.
- Configure TypeScript so all migrated project code is checked consistently, with migration steps ordered for safe verification.
### Scope
- Create or update the shared contract layer for web/daemon request, response, error, and SSE event types.
- Add TypeScript configuration and package/script integration needed to migrate daemon, repository scripts, and test support code to TypeScript.
- Keep the existing architecture boundary from the roadmap: `apps/web` remains the Next.js frontend and thin BFF/proxy layer; `apps/daemon` remains the local runtime/backend.
### Constraints
- W2 depends on the completed W1 ownership and capability boundaries.
- W3 should build on W2 for highest-value shared types.
- Runtime validation, server modularization, process/task manager work, and broader daemon test pyramid work belong to later roadmap workstreams.
### Success Criteria
- Web and daemon can import the same contract types.
- Project-owned source, scripts, tests, and reporters have a TypeScript migration path with a typed end state.
- Typecheck covers the shared contracts, web, daemon, scripts, and test support code included in this spec.
- The new contracts explicitly cover HTTP payloads, SSE events, and the unified error model.
## Research
### Existing System
- W2 covers the implicit web/daemon API contract, inconsistent error handling, and under-specified SSE protocol; the roadmap's W3 text names daemon TypeScript support as the original output. Source: `specs/current/maintainability-roadmap.md:57-58`
- W1 defines the shared boundary as pure JavaScript or TypeScript usable by both web and daemon, with API DTO types, runtime schemas, task states, SSE event names, and error codes as allowed shared contents. Source: `specs/current/architecture-boundaries.md:41-56`
- `apps/web` communicates with daemon-owned capabilities through API DTOs and streaming events, while privileged local filesystem, SQLite, agent CLI, task lifecycle, logs, and artifacts stay daemon-owned. Source: `specs/current/architecture-boundaries.md:13-40`
- The workspace currently has no `packages/*` workspace entries; `pnpm-workspace.yaml` includes `apps/*` and `e2e` only. Source: `pnpm-workspace.yaml:1-3`
- No shared package currently exists under `packages/*`. Source: file search `packages/*/package.json`
- Root scripts run daemon, web, build, tests, and typecheck through pnpm filters; root `typecheck` currently targets only `@open-design/web`. Source: `package.json:12-25`
- Dev-mode web rewrites `/api/*`, `/artifacts/*`, and `/frames/*` to the local daemon origin; the config notes that `/api/chat` SSE streams through the rewrite. Source: `apps/web/next.config.ts:35-44`
- Web-side daemon chat types live in `apps/web/src/providers/daemon.ts`: `DaemonStreamOptions` sends `agentId`, `history`, `systemPrompt`, `projectId`, `attachments`, `model`, and `reasoning`. Source: `apps/web/src/providers/daemon.ts:19-38`
- The web chat client posts `/api/chat` with JSON fields `agentId`, `systemPrompt`, `message`, `projectId`, `attachments`, `model`, and `reasoning`. Source: `apps/web/src/providers/daemon.ts:57-77`
- The daemon `/api/chat` handler reads the same request fields from `req.body`, validates agent and message ad hoc, and returns HTTP 400 JSON errors for invalid agent, missing binary, or missing message. Source: `apps/daemon/server.js:868-884`
- Web-side `AgentEvent` currently models UI events as `status`, `text`, `thinking`, `tool_use`, `tool_result`, `usage`, and `raw`. Source: `apps/web/src/types.ts:32-39`
- Daemon SSE setup for `/api/chat` writes `text/event-stream` frames with `event: <name>` and JSON `data`, using events such as `start`, `agent`, `stdout`, `stderr`, `error`, and `end`. Source: `apps/daemon/server.js:1035-1044`, `apps/daemon/server.js:1087-1095`, `apps/daemon/server.js:1136-1180`
- The web SSE parser consumes frame separators, parses event/data fields, maps `stdout` to text, buffers `stderr`, translates `agent` payloads, handles `start`, treats `error` as terminal, and reads `end` exit code. Source: `apps/web/src/providers/daemon.ts:85-151`
- Web translation accepts daemon `agent` payload types `status`, `text_delta`, `thinking_delta`, `thinking_start`, `tool_use`, `tool_result`, `usage`, and `raw`; unknown payloads are ignored. Source: `apps/web/src/providers/daemon.ts:178-228`
- Agent JSON event parsing emits normalized events such as `status`, `text_delta`, `tool_use`, `tool_result`, `usage`, and `raw`; OpenCode error payloads currently become a `raw` event with embedded error text. Source: `apps/daemon/json-event-stream.js:35-91`
- The daemon API proxy has a separate SSE endpoint at `/api/proxy/stream` with request fields `baseUrl`, `apiKey`, `model`, `systemPrompt`, and `messages`, and returns `start`, `delta`, `error`, and `end` SSE events. Source: `apps/daemon/server.js:1188-1192`, `apps/daemon/server.js:1241-1250`, `apps/daemon/server.js:1262-1275`, `apps/daemon/server.js:1291-1303`
- HTTP error responses are ad hoc: project routes often return `{ error: string }`, upload errors return `{ code, error }`, and preview errors derive status plus `{ error }`. Source: `apps/daemon/server.js:200-205`, `apps/daemon/server.js:147-177`, `apps/daemon/server.js:755-763`
- Project CRUD and conversation/message routes shape common response envelopes such as `{ projects }`, `{ project, conversationId }`, `{ project }`, `{ conversations }`, `{ conversation }`, and `{ messages }`. Source: `apps/daemon/server.js:200-269`, `apps/daemon/server.js:325-424`
- File routes shape common response envelopes such as `{ files }`, `{ file }`, and `{ ok: true }`, while raw file routes return binary data. Source: `apps/daemon/server.js:725-752`, `apps/daemon/server.js:776-833`, `apps/daemon/server.js:840-864`
- `apps/daemon/projects.js` owns project file DTO construction with fields `name`, `path`, `type`, `size`, `mtime`, `kind`, `mime`, `artifactKind`, and `artifactManifest`. Source: `apps/daemon/projects.js:30-70`
- Web application types already include daemon-adjacent DTOs such as `AgentInfo`, `ProjectFileKind`, `ProjectFile`, `Project`, and chat attachment/message/event types in `apps/web/src/types.ts`. Source: `apps/web/src/types.ts:41-101`, `apps/web/src/types.ts:150-160`
- `apps/web` has TypeScript configured with `strict`, `noUncheckedIndexedAccess`, `allowJs`, `noEmit`, and a `typecheck` script using `tsc -b --noEmit`. Source: `apps/web/tsconfig.json:2-23`, `apps/web/package.json:6-10`
- `apps/daemon` is ESM, starts with `node cli.js`, tests with `vitest run -c vitest.config.ts`, and currently has no `typecheck` script. Source: `apps/daemon/package.json:1-23`
- The daemon test config is TypeScript and includes `**/*.test.{ts,tsx,js,mjs,cjs}` under a Node test environment. Source: `apps/daemon/vitest.config.ts:1-8`
- `apps/daemon` currently contains JavaScript and MJS project code alongside TypeScript test/config files: `cli.js`, `server.js`, `db.js`, `agents.js`, stream parsers, project/design-system helpers, artifact helpers, `json-event-stream.test.mjs`, `artifact-manifest.test.ts`, and `vitest.config.ts`. Source: file search `apps/daemon/**/*.{js,mjs,cjs,ts,tsx}`
- `apps/web` source and config files are TypeScript/TSX, including `next.config.ts`, `app/**/*.tsx`, `src/**/*.ts`, `src/**/*.tsx`, and `vitest.config.ts`. Source: file search `apps/web/**/*.{js,mjs,cjs,ts,tsx}`
- `e2e` is mixed: Playwright and Vitest config/tests are TypeScript, while runtime/support scripts and reporters include `.mjs` and `.cjs` files. Source: `e2e/package.json:6-12`, `e2e/playwright.config.ts:1-58`, `e2e/vitest.config.ts:1-12`, file search `e2e/**/*.{js,mjs,cjs,ts,tsx}`
- Root scripts are currently MJS files: `scripts/resolve-dev-ports.mjs`, `scripts/dev-all.mjs`, and `scripts/sync-design-systems.mjs`. Source: file search `scripts/**/*.{js,mjs,cjs,ts,tsx}`
### Available Approaches
- **Add a new shared workspace package**: create a workspace package for contracts and add it to the pnpm workspace so both `apps/web` and `apps/daemon` can import pure shared TypeScript. This matches the roadmap output of a shared contract layer and the W1 shared-boundary rules. Source: `specs/current/maintainability-roadmap.md:57-58`, `specs/current/architecture-boundaries.md:41-56`, `pnpm-workspace.yaml:1-3`
- **Keep shared contracts inside an existing app**: moving contract types under `apps/web` would reuse the current location of many UI-adjacent types, but W1 says shared code should contain pure DTOs and avoid framework or environment-specific APIs. Source: `apps/web/src/types.ts:32-101`, `specs/current/architecture-boundaries.md:41-56`
- **Start with type-only contracts**: W2 can define request/response, SSE event, and error model types first, while runtime schemas remain in the later W4 workstream. Source: `specs/current/maintainability-roadmap.md:57-60`
- **Migrate repository code to a typed end state in phases**: W3 can add TypeScript configs and package scripts first, then convert daemon modules, root scripts, and e2e support files in bounded batches while running typecheck/test verification after each batch. Source: `apps/daemon/package.json:9-13`, `apps/daemon/vitest.config.ts:1-8`, `e2e/package.json:6-12`, file search `apps/daemon/**/*.{js,mjs,cjs,ts,tsx}`, file search `scripts/**/*.{js,mjs,cjs,ts,tsx}`
- **Broaden root typecheck**: root `typecheck` currently targets only web, so full project TypeScript verification requires daemon, shared package, scripts, and e2e/support coverage. Source: `package.json:23`, `apps/daemon/package.json:9-13`, `e2e/package.json:6-12`
### Constraints & Dependencies
- W2 depends on completed W1 ownership boundaries, and W3 depends on W2 for the highest-value shared types. Source: `specs/current/maintainability-roadmap.md:56-58`
- Runtime validation for HTTP inputs, paths, agents, models, uploads, task IDs, and command args is W4 scope, so research for W2/W3 should capture type boundaries without implementing full validation policy yet. Source: `specs/current/maintainability-roadmap.md:59`
- Shared code must stay free of Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, and daemon internals. Source: `specs/current/architecture-boundaries.md:41-56`
- API DTOs should prefer workspace-scoped logical or relative paths; machine absolute paths should remain daemon-internal. Source: `specs/current/architecture-boundaries.md:58-64`
- The `/api/chat` stream currently includes daemon-internal `cwd` in the `start` SSE event. Source: `apps/daemon/server.js:1087-1095`
- Current daemon SSE lifecycle has no heartbeat or version field in emitted events. Source: `apps/daemon/server.js:1035-1044`, `apps/daemon/server.js:1087-1180`
- Current error responses and SSE errors do not use a unified model with `code`, `message`, `details`, `retryable`, and `requestId/taskId`. Source: `apps/daemon/server.js:147-177`, `apps/daemon/server.js:200-205`, `apps/daemon/server.js:868-884`, `apps/daemon/server.js:1170-1180`
- Daemon package devDependencies currently include `vitest` only; TypeScript and Node/Express type packages are available in web but not daemon. Source: `apps/daemon/package.json:21-23`, `apps/web/package.json:19-24`
- Full-project TypeScript migration includes CommonJS/MJS operational edges such as Playwright reporter loading and Node test/script execution. Source: `e2e/playwright.config.ts:22-37`, `e2e/package.json:8-12`, `package.json:14-15`
### Key References
- `specs/current/maintainability-roadmap.md:57-58` - W2/W3 outputs and dependency relationship.
- `specs/current/architecture-boundaries.md:41-56` - allowed shared contract contents and shared-code restrictions.
- `apps/web/next.config.ts:35-44` - dev proxy boundary for web-to-daemon API and SSE.
- `apps/web/src/providers/daemon.ts:19-38` - web-side `/api/chat` request options.
- `apps/web/src/providers/daemon.ts:85-151` - web-side SSE frame handling.
- `apps/web/src/providers/daemon.ts:178-228` - web-side daemon agent event translation.
- `apps/web/src/types.ts:32-39` - current UI `AgentEvent` union.
- `apps/daemon/server.js:868-884` - daemon `/api/chat` request field handling and ad hoc HTTP errors.
- `apps/daemon/server.js:1035-1044` - daemon SSE frame writer.
- `apps/daemon/server.js:1087-1180` - daemon `/api/chat` start/agent/stdout/stderr/error/end lifecycle.
- `apps/daemon/server.js:1188-1303` - daemon API proxy stream request and SSE events.
- `apps/daemon/json-event-stream.js:35-91` - normalized agent JSON event output.
- `apps/daemon/package.json:9-23` - daemon scripts and dependencies.
- `apps/web/tsconfig.json:2-23` - web TypeScript baseline.
- `e2e/package.json:6-12` - e2e test scripts that currently execute TS config plus MJS runtime support.
- `pnpm-workspace.yaml:1-3` - current workspace package globs.
## Design
### Architecture Overview
```mermaid
flowchart LR
Web[apps/web\nUI + thin BFF/proxy]
Contracts[packages/contracts\npure TypeScript DTOs\nSSE unions\nerror model]
Daemon[apps/daemon\nlocal capability server]
Scripts[scripts + e2e\nTypeScript-covered operational code]
Web -->|imports HTTP/SSE/error types| Contracts
Daemon -->|imports HTTP/SSE/error types| Contracts
Web -->|/api/* JSON + SSE| Daemon
Scripts -->|typechecked execution helpers| Contracts
```
### Design Decisions
- Decision: Add a new `packages/contracts` workspace package for W2. The package exports pure TypeScript types for daemon HTTP DTOs, SSE event unions, task states, and error codes; this aligns with the shared-boundary allowed contents and the roadmap's shared-contract output. Source: `specs/current/architecture-boundaries.md:41-56`, `specs/current/maintainability-roadmap.md:57-58`, `pnpm-workspace.yaml:1-3`
- Decision: Keep `apps/web/src/types.ts` as the UI/application type layer and move only daemon-facing DTOs/events/errors into `packages/contracts`. Web owns UI state and communicates with daemon through API DTOs and streaming events; current UI `AgentEvent` is a presentation union. Source: `specs/current/architecture-boundaries.md:13-27`, `apps/web/src/types.ts:32-39`, `apps/web/src/types.ts:150-179`, `apps/web/src/types.ts:215-252`
- Decision: Model the current daemon API before tightening behavior. Start with type contracts for `/api/chat`, `/api/proxy/stream`, project routes, conversation/message routes, file routes, artifacts, health, agents, skills, and design systems, then add runtime schemas in W4. Source: `specs/current/maintainability-roadmap.md:57-60`, `apps/daemon/server.js:200-269`, `apps/daemon/server.js:725-864`, `apps/daemon/server.js:868-884`, `apps/daemon/server.js:1188-1303`
- Decision: Define separate transport-level SSE unions and UI-level event unions. `/api/chat` transport events cover `start`, `agent`, `stdout`, `stderr`, `error`, and `end`; normalized agent payloads cover `status`, `text_delta`, `thinking_delta`, `thinking_start`, `tool_use`, `tool_result`, `usage`, and `raw`; web translation remains liberal for forward compatibility. Source: `apps/daemon/server.js:1035-1044`, `apps/daemon/server.js:1087-1180`, `apps/web/src/providers/daemon.ts:85-151`, `apps/web/src/providers/daemon.ts:178-228`, `apps/daemon/json-event-stream.js:35-91`
- Decision: Define a versioned SSE contract shape for future W8/W6 compatibility while preserving the existing event names during W2 adoption. Include a protocol version constant and typed event payloads; heartbeat, cancellation, and canonical task lifecycle events remain future extensions. Source: `specs/current/maintainability-roadmap.md:40-41`, `specs/current/maintainability-roadmap.md:57-64`, `apps/daemon/server.js:1035-1044`, `apps/daemon/server.js:1087-1180`
- Decision: Introduce a unified `ApiError` and `SseErrorEvent` type with `code`, `message`, `details`, `retryable`, `requestId`, and `taskId`, plus compatibility helpers for existing `{ error }` and `{ code, error }` responses. Current routes return multiple ad hoc shapes; W2 should make the target contract explicit. Source: `specs/current/maintainability-roadmap.md:39-40`, `apps/daemon/server.js:147-177`, `apps/daemon/server.js:200-205`, `apps/daemon/server.js:868-884`, `apps/daemon/server.js:1170-1180`
- Decision: Treat machine absolute paths as daemon-internal in public contracts. DTOs should use project-relative or logical paths; the existing `/api/chat` `start` event's `cwd` field should be typed as legacy/internal and removed from web-facing assumptions during adoption. Source: `specs/current/architecture-boundaries.md:58-64`, `apps/daemon/server.js:1087-1095`
- Decision: W3's end state is a compiled TypeScript daemon runtime with a transitional `allowJs` phase. The daemon currently runs `node cli.js` and exposes `./cli.js` as its bin, so TypeScript entrypoint migration needs a deliberate build output and script/bin update. Source: `apps/daemon/package.json:6-13`, `package.json:9-24`
- Decision: Broaden typechecking from web-only to contracts, daemon, scripts, and e2e support. Root `typecheck` currently filters only `@open-design/web`; daemon has tests but no typecheck script; e2e already uses TypeScript configs and MJS/CJS operational files. Source: `package.json:19-25`, `apps/daemon/package.json:9-23`, `apps/web/tsconfig.json:2-23`, `e2e/package.json:6-12`, `e2e/playwright.config.ts:1-58`
- Decision: Migrate JavaScript/MJS/CJS files in dependency order: pure parsers/helpers, project/artifact helpers, DB/agent modules, server/CLI entrypoints, root scripts, then e2e scripts/reporters. This keeps each step verifiable and limits runtime-loader risk around Playwright reporter loading. Source: `apps/daemon/vitest.config.ts:1-8`, `apps/daemon/json-event-stream.js:35-91`, `e2e/playwright.config.ts:22-37`, `e2e/package.json:8-12`, `package.json:14-15`
### Why this design
- A dedicated shared package makes the web/daemon boundary explicit while preserving the existing product architecture: web handles UI/proxy behavior and daemon owns local runtime capabilities. Source: `specs/current/architecture-boundaries.md:13-40`
- Type-only W2 contracts deliver immediate drift protection and give W4 a stable target for runtime schemas. Source: `specs/current/maintainability-roadmap.md:57-60`
- Separating transport events from UI events keeps daemon protocol evolution independent from rendering concerns and preserves the current liberal parser behavior. Source: `apps/web/src/providers/daemon.ts:85-151`, `apps/web/src/providers/daemon.ts:178-228`
- A compiled daemon TypeScript target is the safest full migration end state for the package bin and root `od` entrypoint. Source: `apps/daemon/package.json:6-13`, `package.json:9-10`
### Implementation Steps
1. Create `packages/contracts`, add it to the pnpm workspace, and expose typed exports for API DTOs, SSE events, errors, task states, and shared constants.
2. Add package-level and root-level TypeScript configuration so contracts typecheck independently and participate in root `pnpm run typecheck`.
3. Replace duplicated web daemon-facing types with imports from `packages/contracts`, keeping UI-only state and presentation unions in `apps/web/src/types.ts`.
4. Type daemon request handlers, response envelopes, SSE send helpers, and normalized JSON event parsing against the shared contracts while preserving current runtime behavior.
5. Introduce compatibility error helpers and adopt the unified error model first in `/api/chat`, upload errors, project/file routes, and proxy stream errors.
6. Add daemon TypeScript config and scripts, migrate daemon modules in dependency order, and switch runtime/bin scripts to compiled JavaScript output once `cli.ts` and `server.ts` are converted.
7. Convert root scripts and e2e support scripts/reporters with an explicit execution strategy for Node scripts and Playwright reporter loading.
8. Broaden root verification to contracts, web, daemon, scripts, and e2e support, then run targeted daemon/web/e2e tests.
### Test Strategy
- Contracts: run `pnpm --filter @open-design/contracts typecheck`; add lightweight type-level coverage via exported example payloads or `tsc`-checked fixture files. Source: `specs/current/maintainability-roadmap.md:57-58`
- Web adoption: run `pnpm --filter @open-design/web typecheck` and existing web tests after importing shared DTO/SSE/error types. Source: `apps/web/package.json:6-10`, `apps/web/tsconfig.json:2-23`
- Daemon adoption: add and run `pnpm --filter @open-design/daemon typecheck`, then `pnpm --filter @open-design/daemon test`; daemon already uses Vitest with TypeScript config. Source: `apps/daemon/package.json:9-23`, `apps/daemon/vitest.config.ts:1-8`
- SSE compatibility: add or update parser/translator tests around `/api/chat` `start`, `agent`, `stdout`, `stderr`, `error`, and `end` frames plus normalized agent payloads. Source: `apps/web/src/providers/daemon.ts:85-151`, `apps/daemon/json-event-stream.js:35-91`
- Error model compatibility: add daemon route/helper tests for existing `{ error }` and `{ code, error }` inputs mapping into the new `ApiError` shape. Source: `apps/daemon/server.js:147-177`, `apps/daemon/server.js:200-205`, `apps/daemon/server.js:868-884`
- Runtime migration: after each TypeScript conversion batch, run daemon tests and root typecheck; after script/e2e migration, run `pnpm --filter @open-design/e2e test` and a Playwright reporter smoke run when feasible. Source: `package.json:19-25`, `e2e/package.json:6-12`, `e2e/playwright.config.ts:22-37`
### Pseudocode
Flow:
Add shared package
Export contract modules
api/chat.ts
api/projects.ts
api/files.ts
sse/chat.ts
sse/proxy.ts
errors.ts
Web imports contracts
build typed request body
parse transport SSE frame
translate typed transport event to UI AgentEvent
Daemon imports contracts
type request body reads
type response envelopes
type send(event, data)
wrap legacy errors into ApiError shape
TypeScript migration proceeds by dependency order
helpers/parsers
DTO builders
services/adapters
server/CLI
scripts/e2e
### File Structure
- `packages/contracts/package.json` - new workspace package metadata, exports, and typecheck script.
- `packages/contracts/tsconfig.json` - strict declaration-emitting TypeScript config for shared contracts.
- `packages/contracts/src/index.ts` - public export surface.
- `packages/contracts/src/api/*.ts` - HTTP request/response DTOs and response envelopes.
- `packages/contracts/src/sse/*.ts` - chat/proxy SSE event unions and protocol constants.
- `packages/contracts/src/errors.ts` - error codes, `ApiError`, `ApiErrorResponse`, and SSE error payload types.
- `packages/contracts/src/tasks.ts` - task state/lifecycle constants shared with later W6/W8 work.
- `apps/web/src/types.ts` - keep UI/application types; import shared daemon DTOs where applicable.
- `apps/web/src/providers/daemon.ts` - consume shared chat request and SSE event types; retain UI translator.
- `apps/daemon/tsconfig.json` - new daemon TypeScript config with transitional `allowJs` and strict checking target.
- `apps/daemon/package.json` - add `typecheck`, build/runtime scripts, and TypeScript/type dependencies as migration steps require.
- `apps/daemon/**/*.ts` - migrated daemon modules, server, CLI, parsers, and helpers.
- `scripts/**/*.ts` - migrated root operational scripts.
- `e2e/**/*.ts` - migrated e2e support scripts and reporter strategy.
- `AGENTS.md` - repository development conventions for future work: shared contracts first for web/daemon boundaries, TypeScript-first implementation, no project-owned JavaScript entrypoints/modules/scripts/tests/reporters after W3.
### Interfaces / APIs
- `ChatRequest`: `{ agentId, message, systemPrompt?, projectId?, attachments?, model?, reasoning? }`, matching web post body and daemon handler reads. Source: `apps/web/src/providers/daemon.ts:57-77`, `apps/daemon/server.js:868-884`
- `ChatSseEvent`: discriminated union for `start`, `agent`, `stdout`, `stderr`, `error`, and `end`, with `cwd` treated as legacy/internal on `start`. Source: `apps/daemon/server.js:1035-1044`, `apps/daemon/server.js:1087-1180`
- `DaemonAgentPayload`: discriminated union for normalized agent payloads emitted inside `agent` events. Source: `apps/web/src/providers/daemon.ts:178-228`, `apps/daemon/json-event-stream.js:35-91`
- `ProxyStreamRequest` and `ProxySseEvent`: request fields `baseUrl`, `apiKey`, `model`, `systemPrompt`, and `messages`; events `start`, `delta`, `error`, and `end`. Source: `apps/daemon/server.js:1188-1303`
- `ApiError`: `{ code, message, details?, retryable?, requestId?, taskId? }`; `ApiErrorResponse`: `{ error: ApiError }`; compatibility helpers accept legacy string errors during migration. Source: `specs/current/maintainability-roadmap.md:39-40`, `apps/daemon/server.js:147-177`, `apps/daemon/server.js:200-205`
- Response envelopes: projects, conversations, messages, files, and file mutation responses should mirror the current daemon JSON shapes and reuse existing web DTO fields. Source: `apps/daemon/server.js:200-269`, `apps/daemon/server.js:325-424`, `apps/daemon/server.js:725-864`, `apps/web/src/types.ts:150-179`, `apps/web/src/types.ts:215-252`
### Edge Cases
- Existing SSE consumers should continue ignoring unknown `agent` payloads, so new union members can be added safely. Source: `apps/web/src/providers/daemon.ts:178-228`
- Malformed or partial SSE frames should preserve current parser tolerance until W4 validation defines stricter behavior. Source: `apps/web/src/providers/daemon.ts:163-176`
- `/api/chat` currently emits terminal SSE errors and HTTP 400 JSON errors through different shapes; W2 should type both and allow incremental adoption. Source: `apps/daemon/server.js:868-884`, `apps/daemon/server.js:1170-1180`
- Playwright reporter loading currently points to a `.cjs` reporter path; migration needs a compiled JS reporter path or supported TS execution path. Source: `e2e/playwright.config.ts:22-37`
- The root `od` bin and daemon package bin currently point at JavaScript entrypoints; conversion to `.ts` requires a compiled output target before script/bin paths change. Source: `package.json:9-10`, `apps/daemon/package.json:6-13`
- Shared contracts should stay pure and free of Next, Express, Node filesystem/process APIs, browser APIs, SQLite, and daemon internals. Source: `specs/current/architecture-boundaries.md:41-56`
## Plan
- [x] Step 1: Establish shared contracts package
- [x] Substep 1.1 Implement: Add `packages/contracts` workspace package, exports, and strict TypeScript config.
- [x] Substep 1.2 Implement: Define `ApiError`, error codes, task states, and common response envelope helpers.
- [x] Substep 1.3 Implement: Define HTTP DTOs for chat, proxy stream, projects, conversations, messages, files, agents, skills, design systems, artifacts, and health.
- [x] Substep 1.4 Implement: Define `/api/chat` and `/api/proxy/stream` SSE event unions and normalized agent payload unions.
- [x] Substep 1.5 Verify: Run contracts typecheck and root package graph install/type resolution checks.
- [x] Step 2: Adopt contracts in web and daemon boundary code
- [x] Substep 2.1 Implement: Import shared chat/proxy/file/project DTOs in web provider and app types while keeping UI-only unions local.
- [x] Substep 2.2 Implement: Type daemon response envelopes, chat request body reads, proxy stream request body reads, and SSE send helpers.
- [x] Substep 2.3 Implement: Add compatibility error helpers and adopt them in chat, upload, project/file, and proxy stream paths.
- [x] Substep 2.4 Verify: Run web typecheck, daemon tests, and targeted SSE/error compatibility tests.
- [x] Step 3: Add daemon TypeScript foundation
- [x] Substep 3.1 Implement: Add daemon `tsconfig.json`, `typecheck` script, and required TypeScript/Node/Express type dependencies.
- [x] Substep 3.2 Implement: Configure transitional `allowJs` checking for current daemon modules.
- [x] Substep 3.3 Implement: Update root `typecheck` to include contracts and daemon.
- [x] Substep 3.4 Verify: Run daemon typecheck, daemon tests, and root typecheck.
- [x] Step 4: Migrate daemon modules to TypeScript
- [x] Substep 4.1 Implement: Convert pure parsers/helpers and their tests first.
- [x] Substep 4.2 Implement: Convert project/file/artifact helper modules and DTO builders.
- [x] Substep 4.3 Implement: Convert DB, agents, runtime adapter, and stream orchestration modules.
- [x] Substep 4.4 Implement: Convert `server` and `cli` entrypoints and switch package/runtime bin paths to compiled output.
- [x] Substep 4.5 Verify: Run daemon typecheck/tests after each conversion batch and smoke the daemon CLI locally.
- [x] Step 5: Migrate scripts and e2e support to TypeScript
- [x] Substep 5.1 Implement: Convert root scripts with a documented Node execution strategy.
- [x] Substep 5.2 Implement: Convert e2e runtime/support scripts and preserve Playwright reporter loading through compiled output or supported TS loading.
- [x] Substep 5.3 Implement: Update root `typecheck` to include scripts and e2e support.
- [x] Substep 5.4 Verify: Run root typecheck, repo test suite, e2e tests, and a Playwright reporter smoke check when feasible.
- [x] Step 6: Lock in typed end state and future conventions
- [x] Substep 6.1 Implement: Add or update root `AGENTS.md` with W2/W3 development conventions: put shared web/daemon contracts in `packages/contracts`, keep UI-only types in web, keep daemon capability logic in daemon, use TypeScript for new project-owned code, and route runtime validation work to the later validation workstream.
- [x] Substep 6.2 Implement: Add an automated residual-JavaScript check for project-owned entrypoints, modules, scripts, tests, and reporters, with explicit allowlist entries only for generated, vendored, or compatibility-output files.
- [x] Substep 6.3 Verify: Run the residual-JavaScript check and confirm no project-owned `.js`, `.mjs`, or `.cjs` source files remain outside the documented allowlist.
- [x] Substep 6.4 Verify: Re-run root typecheck and full test suite after the final convention and residual-file checks are in place.
## Notes
<!-- Optional sections — add what's relevant. -->
### Implementation
- `pnpm-workspace.yaml` - added `packages/*` so shared packages participate in the workspace graph.
- `packages/contracts/package.json` - added `@open-design/contracts` package metadata, source exports, and `typecheck` script.
- `packages/contracts/tsconfig.json` - added strict TypeScript configuration for shared contracts.
- `packages/contracts/src/common.ts` - added JSON, nullable, and response envelope helper types.
- `packages/contracts/src/errors.ts` - added `ApiError`, error codes, compatibility response types, SSE error payloads, and small pure construction helpers.
- `packages/contracts/src/tasks.ts` - added shared task state and task status contracts.
- `packages/contracts/src/api/*.ts` - added HTTP DTOs for chat, proxy stream, projects, conversations, messages, files, agents, skills, design systems, artifacts, and health.
- `packages/contracts/src/sse/*.ts` - added typed SSE event helpers plus `/api/chat` and `/api/proxy/stream` event unions with protocol constants.
- `packages/contracts/src/examples.ts` - added tsc-checked example payloads for key contracts.
- `packages/contracts/src/index.ts` - added the public export surface.
- `apps/web/package.json` and `apps/daemon/package.json` - added workspace dependencies on `@open-design/contracts` for boundary type adoption.
- `apps/web/src/types.ts` - re-exported shared chat, registry, project, file, and conversation DTOs while keeping UI/config-only types local.
- `apps/web/src/providers/daemon.ts` - typed `/api/chat` request construction, chat SSE frame handling, daemon agent payload translation, and unified SSE error payload reading with shared contracts.
- `apps/daemon/server.js` - added JSDoc contract imports, typed project/file response envelopes, typed chat/proxy request body reads, typed SSE send events, and shared-shape compatibility error helpers.
- `apps/daemon/server.js` - adopted `ApiErrorResponse`/`SseErrorPayload` shapes for chat, upload, project/file, and proxy stream error paths while preserving runtime behavior.
- `apps/web/src/providers/sse.test.ts` - added coverage for unified daemon SSE error payload handling.
- `apps/daemon/sse-response.test.mjs` - added coverage for compatibility `ApiErrorResponse` construction.
- `apps/daemon/tsconfig.json` - added a strict daemon TypeScript foundation with `allowJs` for the current JavaScript/MJS transition and bundler resolution for workspace contract source imports.
- `apps/daemon/package.json` - added a `typecheck` script plus TypeScript, Node, Express, Multer, and better-sqlite3 type dependencies.
- `package.json` - broadened root `typecheck` to run contracts, web, and daemon checks through Corepack-pinned pnpm.
- `pnpm-lock.yaml` - updated lockfile entries for daemon TypeScript/type dependencies.
- `apps/daemon/*.ts` - migrated remaining daemon-owned modules, helpers, server entrypoint, CLI entrypoint, and daemon tests from `.js`/`.mjs` to `.ts` while preserving runtime `.js` ESM import specifiers for compiled output.
- `apps/daemon/tsconfig.json` - switched daemon compilation to NodeNext module resolution, disabled JavaScript source inclusion, and added `dist` declaration/source-map emit.
- `apps/daemon/package.json` - added daemon `build`, routed daemon/dev/start through compiled `dist/cli.js`, and updated the package bin to compiled output.
- `package.json` - updated the root `od` bin to the compiled daemon CLI path.
- `scripts/*.ts` - migrated root development and design-system sync scripts from MJS to TypeScript, using Node 24 `--experimental-strip-types` for direct script execution.
- `scripts/tsconfig.json` - added strict TypeScript coverage for root operational scripts.
- `e2e/scripts/*.ts` - migrated e2e cleanup and live runtime-adapter smoke scripts to TypeScript; the live script now builds and imports the compiled daemon output.
- `e2e/reporters/markdown-reporter.ts` and `e2e/cases/report-metadata.ts` - migrated the Playwright markdown reporter and report metadata from CommonJS to TypeScript ESM.
- `e2e/tsconfig.json` and `e2e/package.json` - added e2e support typechecking plus Node strip-types execution for support scripts.
- `e2e/playwright.config.ts` - updated root script imports and reporter paths to TypeScript files and routed the Playwright webServer command through Corepack-pinned pnpm.
- `package.json` - broadened root `typecheck` to cover scripts and e2e support, and routed root pnpm-invoking scripts through Corepack so the pinned pnpm version is used consistently.
- `AGENTS.md` - updated project shape, command notes, TypeScript-first conventions, shared contract boundaries, daemon ownership rules, runtime validation scope, and migrated `.ts` file references for future agents.
- `scripts/check-residual-js.ts` - added an automated residual JavaScript scanner for project-owned `.js`, `.mjs`, and `.cjs` files, with documented output/vendor/generated allowlist prefixes and local scratch/dependency directory skips.
- `package.json` - added `check:residual-js` and made root `typecheck` run the residual JavaScript check after package/support typechecks and daemon build output generation.
### Verification
- `corepack pnpm install` - passed; workspace graph recognized all 5 projects and updated lockfile state.
- `corepack pnpm --filter @open-design/contracts typecheck` - passed.
- `corepack pnpm --filter @open-design/web typecheck` - passed as a package graph/type resolution sanity check.
- `corepack pnpm typecheck` - attempted; failed because the root script invokes `pnpm` from PATH version 10.28.0 while the repo requires `>=10.33.2 <11`. The Corepack package-level equivalent above passed.
- `corepack pnpm install` - passed after adding app dependencies on `@open-design/contracts`; lockfile links web and daemon to the workspace package.
- `corepack pnpm --filter @open-design/contracts typecheck` - passed after Step 2 adoption.
- `corepack pnpm --filter @open-design/web typecheck` - passed after Step 2 adoption.
- `corepack pnpm --filter @open-design/web test -- src/providers/sse.test.ts` - passed; Vitest also ran existing artifact manifest tests in the web package.
- `corepack pnpm --filter @open-design/daemon test -- sse-response.test.mjs` - passed; Vitest also ran existing daemon artifact manifest and json event stream tests.
- `corepack pnpm install` - passed after adding daemon TypeScript/type dependencies.
- `corepack pnpm --filter @open-design/daemon typecheck` - passed after adding the daemon TypeScript foundation.
- `corepack pnpm --filter @open-design/daemon test` - passed; all 18 daemon tests passed.
- `corepack pnpm typecheck` - passed after root `typecheck` was broadened to contracts, web, and daemon and routed through Corepack-pinned pnpm.
- `corepack pnpm --filter @open-design/daemon typecheck` - passed after daemon module conversion.
- `corepack pnpm --filter @open-design/daemon build` - passed and emitted compiled daemon output under `apps/daemon/dist`.
- `corepack pnpm --filter @open-design/daemon test` - passed after daemon module conversion; all 18 daemon tests passed.
- `node apps/daemon/dist/cli.js --help` - passed as a compiled CLI smoke check.
- `corepack pnpm typecheck` - passed after daemon module conversion and compiled-bin package updates.
- `corepack pnpm --filter @open-design/e2e exec tsc -p ../scripts/tsconfig.json --noEmit` - passed for root TypeScript scripts.
- `corepack pnpm --filter @open-design/daemon build` - passed before e2e support typechecking and live-script import validation.
- `corepack pnpm --filter @open-design/e2e typecheck` - passed for Playwright config, reporter, report metadata, and e2e support scripts.
- `corepack pnpm typecheck` - passed after script and e2e support migration.
- `node --experimental-strip-types` import smoke checks for `scripts/resolve-dev-ports.ts` and `e2e/reporters/markdown-reporter.ts` - passed.
- `corepack pnpm test` - passed; web 15 tests, daemon 18 tests, and e2e Vitest 9 tests passed.
- `corepack pnpm --filter @open-design/e2e test:ui:clean` - passed against the TypeScript cleanup script.
- `corepack pnpm --filter @open-design/e2e exec playwright test -c playwright.config.ts --list` - passed as a Playwright config/reporter loading smoke check and listed 15 Chromium UI tests.
- `corepack pnpm run check:residual-js` - passed; no project-owned residual `.js`, `.mjs`, or `.cjs` files were found outside the documented allowlist.
- `corepack pnpm --filter @open-design/e2e exec tsc -p ../scripts/tsconfig.json --noEmit` - passed after adding the residual JavaScript scanner.
- `corepack pnpm typecheck` - passed after Step 6; contracts, web, daemon typecheck, daemon build, scripts typecheck, e2e typecheck, and residual JavaScript check all passed.
- `corepack pnpm test` - passed after Step 6; web 15 tests, daemon 18 tests, and e2e Vitest 9 tests passed.

View file

@ -0,0 +1,115 @@
# Architecture Boundaries
## Purpose
This document defines the architectural boundaries for the local Open Design app. These boundaries are architectural constraints; some enforcement details can be implemented later through the relevant roadmap workstreams.
## Product Shape
Open Design is a local-first application. The near-term Electron version is a shell around the same `apps/web` and `apps/daemon` architecture.
Electron does not introduce a separate privileged application layer. The web layer and daemon keep the same responsibilities in browser and Electron modes.
## Web Boundary
`apps/web` owns UI, presentation state, and thin BFF/proxy behavior.
`apps/web` must not directly access local privileged capabilities:
- `.od` state
- SQLite storage
- workspace filesystem reads or writes
- agent CLI processes
- task process lifecycle
- local logs and artifacts
The web layer communicates with daemon-owned capabilities through API DTOs and streaming events.
## Daemon Boundary
`apps/daemon` is the sole local capability server. It owns privileged local runtime behavior:
- `.od` state
- SQLite storage, schema, migrations, and storage layout
- workspace filesystem access
- agent CLI invocation
- task lifecycle and process cleanup
- logs, artifacts, and diagnostic state
Daemon capabilities should be isolated behind internal modules such as `db`, `fs`, `agents`, `tasks`, `logs`, and `artifacts`.
## Shared Boundary
Shared code must be pure JavaScript or TypeScript that can run in both web and daemon contexts.
Shared code may contain:
- API DTO types
- runtime schemas such as Zod or TypeBox schemas
- domain constants
- task states
- SSE event names
- error codes
- pure helper functions
- path-related logical string helpers
Shared code must not depend on framework or environment-specific APIs such as Next.js, Express, Node filesystem/process APIs, browser-only APIs, SQLite, or daemon internals.
## API DTO Boundary
The web layer should understand API DTOs, not daemon implementation details.
API DTOs should prefer workspace-scoped logical or relative paths. Machine absolute paths should remain daemon-internal. Enforcement can be implemented later through a workspace path resolver and runtime validation layer.
SQLite schema names, table structure, migration details, and storage layout are daemon-private. The web layer sees API DTOs for display and interaction.
## Workspace Boundary
The current architecture can assume one active workspace. Workspace root selection should come from explicit user choice or an explicit startup parameter.
Daemon filesystem access should be scoped to the active workspace root. Path normalization and root containment checks should be implemented in the daemon path resolver and validation layer.
Precise implementation priority for workspace enforcement can be deferred, but the boundary direction is fixed: web does not construct privileged filesystem paths, and daemon owns path resolution.
## Agent Command Boundary
Users cannot provide free-form shell commands for daemon execution.
Agent invocations should use controlled command templates and argument construction. User-provided content may enter prompts, files, or configuration fields, while command structure remains daemon-controlled.
Plugin or custom-agent command extension is outside the current scope.
## Security Baseline
The app is local-first. Daemon should bind locally, and local API authentication can be deferred.
Daemon output should redact sensitive values by default, including tokens, API keys, environment secrets, and Authorization-like headers.
## Task Lifecycle Boundary
Daemon owns the full task lifecycle. The web layer may create, subscribe to, query, and request cancellation for tasks through API DTOs and events.
Tasks belong to a workspace and an agent. Terminal states are:
- `succeeded`
- `failed`
- `cancelled`
- `interrupted`
The web layer requests cancellation; daemon determines final task state and owns cleanup. Detailed concurrency, timeout, scheduling, and recovery policies can be defined in the process manager workstream.
## Deferred Policy Details
The following policy details can be finalized in later workstreams:
- multiple workspace support
- workspace registry location
- artifact, cache, and log directory layout
- Electron workspace picker behavior
- task concurrency limits
- timeout defaults
- queueing strategy
- restart recovery behavior
- process-tree cleanup strategy
These deferred choices should preserve the boundaries in this document.

View file

@ -0,0 +1,79 @@
# Maintainability Roadmap
## Purpose
This document captures the maintainability risks in the current `apps/web` + `apps/daemon` architecture and the recommended optimization path.
The architectural boundary stays unchanged:
- `apps/web`: Next.js frontend and thin BFF/proxy layer.
- `apps/daemon`: local runtime/backend for SQLite, `.od` filesystem state, AI agent CLI processes, and SSE streaming.
The first-principles maintainability goals are:
- **Understandability**: engineers can locate behavior quickly and reason about data flow.
- **Changeability**: common changes can be made with bounded blast radius.
- **Verifiability**: contracts, tests, and types catch regressions early.
- **Isolation**: high-risk capabilities are contained behind explicit boundaries.
- **Recoverability**: failures produce actionable state, logs, and cleanup behavior.
## Priority Scale
| Priority | Meaning |
|---|---|
| P0 | Blocks safe evolution or creates high-risk runtime/security failure modes. |
| P1 | Major maintainability risk that increases regression and debugging cost. |
| P2 | Medium-term risk that affects reliability, portability, or architecture clarity. |
| P3 | Supporting documentation/process improvement. |
## Risk List and Optimization Plan
| ID | Priority | Risk | Evidence | Impact | Optimization Plan |
|---|---:|---|---|---|---|
| R1 | P0 | Daemon lacks TypeScript type checking. | `apps/daemon` is mostly JavaScript while handling API payloads, SQLite rows, filesystem paths, child processes, and SSE events. | API payloads, DB rows, agent events, and task states can drift silently; refactors are riskier. | Add gradual TypeScript support with `allowJs`; write new daemon modules in `.ts`; first type API payloads, SSE events, task lifecycle, DB rows, and agent definitions. |
| R2 | P0 | Web/daemon API contract is implicit. | `apps/web` calls daemon through `/api/*` rewrites; web has TypeScript types, daemon returns manually shaped JSON. | Field mismatches surface at runtime; API evolution is fragile. | Create `packages/api-contract` or an equivalent shared contract layer for request, response, error, and SSE event types. |
| R3 | P0 | Runtime validation is incomplete at the daemon boundary. | Daemon requests can trigger local filesystem access, SQLite writes, and `child_process.spawn()`. | Type correctness alone cannot protect against malformed runtime input, path traversal, invalid agent IDs, or unsafe args. | Add schema validation at HTTP boundaries with Zod/TypeBox; centralize validation for workspace paths, task IDs, agent IDs, models, reasoning options, uploaded files, and command arguments. |
| R4 | P0 | Local capability security boundary needs explicit rules. | Daemon owns high-permission capabilities: local files, `.od`, project workspaces, agent CLIs, and logs. | Unsafe path handling, broad command execution, token leakage, and unintended workspace access become possible failure modes. | Treat daemon as a capability server: bind to localhost, use workspace/path allowlists, normalize and jail paths, allowlist agent commands, and redact sensitive output. |
| R5 | P0 | Agent process lifecycle needs a first-class manager. | `/api/chat` spawns multiple agent runtimes and streams output to the frontend. | Zombie processes, cancellation gaps, orphaned tasks, inconsistent exit handling, and concurrent process conflicts. | Introduce a process/task manager with task state machine, cancellation, timeout, cleanup, exit code capture, signal handling, and concurrency limits. |
| R6 | P1 | `server.js` is too monolithic. | `apps/daemon/server.js` contains many routes plus orchestration, filesystem logic, streaming, uploads, and artifact handling. | Harder to understand, test, and change; unrelated edits share the same file and increase regression risk. | Split into thin routes plus services/adapters: `routes/`, `services/`, `agents/`, `db/`, `fs/`, `streams/`, `artifacts/`. |
| R7 | P1 | Error handling is inconsistent. | Handlers commonly use local `try/catch` and return ad hoc JSON errors. | UI receives inconsistent failures; logs lose context; task state can stall after partial failures. | Define a unified error model with `code`, `message`, `details`, `retryable`, and `requestId/taskId`; add centralized Express error middleware and adapter-level error mapping. |
| R8 | P1 | SSE protocol is under-specified. | Daemon manually writes `text/event-stream` events for agent output and status. | Frontend parsing is fragile; disconnect, heartbeat, terminal events, and error semantics can drift. | Version the SSE event contract and define canonical events such as `task.started`, `task.output`, `task.error`, `task.completed`, `task.cancelled`, and `heartbeat`. |
| R9 | P1 | SQLite schema and migration lifecycle need stronger guarantees. | `apps/daemon/db.js` owns local `better-sqlite3` tables and migrations. | Local user data upgrades can fail unpredictably; schema drift is hard to diagnose and recover. | Add explicit migration table, ordered forward migrations, startup migration checks, schema version logging, backup-before-migrate strategy, and migration tests. |
| R10 | P1 | Test coverage is thin around daemon behavior. | Existing daemon tests focus on stream parsing and artifact manifest behavior; HTTP/DB/spawn flows have limited coverage. | Changes are validated by manual testing; regressions in filesystem, SQLite, SSE, or agent mocks can ship. | Build layered tests: shared contract tests, route integration tests, service unit tests, SQLite migration tests, SSE parser tests, and agent mock integration tests. |
| R11 | P1 | Logging and observability are insufficient for local runtime debugging. | Agent execution involves long-lived tasks, subprocess output, filesystem state, and frontend SSE consumption. | User issues are hard to reproduce; failures lack correlated context. | Add structured logs with `requestId`, `taskId`, `agentId`, `workspace`, exit code, and duration; separate app logs from agent output; redact secrets. |
| R12 | P2 | Configuration, port, and health behavior can become fragile. | Web proxies `/api/*` to daemon; dev startup coordinates Next.js and daemon ports. | Port conflicts, daemon-not-ready states, and mismatched environment variables can break startup or distribution. | Centralize config resolution; expose `/health`; add daemon readiness checks; make port selection and UI fallback deterministic. |
| R13 | P2 | Cross-platform behavior is a recurring risk. | Daemon uses filesystem paths, SQLite native bindings, shell/process behavior, and signals. | macOS, Linux, and Windows/WSL can differ in path normalization, quoting, permissions, and process termination. | Use Node path APIs consistently, avoid shell string composition, isolate platform-specific process logic, and add CI coverage for supported platforms. |
| R14 | P2 | Framework migration can distract from core maintainability issues. | Current complexity is concentrated in FS/spawn/SSE/SQLite and module boundaries. | A framework rewrite can consume time while preserving the risky domain logic. | Keep Express for now; revisit Fastify only after TS, contracts, validation, tests, and modularization are in place and Express becomes a clear limiter. |
| R15 | P2 | Web/daemon boundary can erode over time. | Next.js has BFF capability and daemon has backend capability; future edits may blur ownership. | High-permission local runtime logic may leak into `apps/web`; deployment and security assumptions become unclear. | Document and enforce ownership: web handles UI/BFF/proxy; daemon owns local runtime capabilities; shared code contains contracts and pure logic only. |
| R16 | P3 | Operational documentation is incomplete. | Local-first daemon behavior depends on ports, `.od`, agent CLIs, runtime logs, and recovery flows. | Onboarding and support costs rise; troubleshooting relies on oral knowledge. | Document daemon architecture, API/SSE contract, task lifecycle, `.od` data layout, agent dependency checks, and common recovery procedures. |
## Optimization Dependencies
The optimization work should proceed in dependency order. Some items can run in parallel once their prerequisites are stable.
| Workstream | Status | Optimization | Covers | Depends on | Output |
|---|---|---|---|---|---|
| W1 | Completed | Confirm architecture and capability boundaries | R4, R15 | — | Written ownership rules for web, daemon, shared contracts, and dangerous local capabilities. See `specs/current/architecture-boundaries.md`. |
| W2 | Planned | Define API, SSE, and error contracts | R2, R7, R8 | W1 | Shared request/response types, SSE event union, and error model. |
| W3 | Planned | Add gradual TypeScript support | R1 | W2 for highest-value shared types | Daemon TS config, `allowJs`, typed new modules, typed contracts imported by web and daemon. |
| W4 | Planned | Add runtime validation at daemon boundaries | R3, R4 | W2 | Schemas for HTTP requests, paths, agents, models, uploads, task IDs, and command args. |
| W5 | Planned | Modularize `server.js` | R6 | W2, W3, W4 | Thin route handlers plus services/adapters for agents, DB, FS, streams, and artifacts. |
| W6 | Planned | Introduce agent process/task manager | R5, R8, R11 | W2, W5 | Task state machine, cancellation, timeout, cleanup, exit handling, and concurrency controls. |
| W7 | Planned | Strengthen SQLite migrations | R9 | W5 or a clear DB adapter boundary | Migration table, ordered migrations, startup checks, backup strategy, migration tests. |
| W8 | Planned | Build the daemon test pyramid | R10 | W2, W4, W5 | Contract tests, route integration tests, service unit tests, migration tests, SSE tests, and mocked agent-process tests. |
| W9 | Planned | Add structured logs and observability | R11 | W2, W6 | Correlated request/task logs, sanitized agent output, durations, exit status, and diagnostic context. |
| W10 | Planned | Harden config, port, and readiness behavior | R12 | W1 | Centralized config, `/health`, readiness checks, deterministic port behavior. |
| W11 | Planned | Harden cross-platform behavior | R13 | W4, W6, W5 | Platform-specific process handling, path normalization rules, supported-platform CI. |
| W12 | Planned | Revisit HTTP framework choice | R14 | W2, W3, W4, W5, W8 | Evidence-based decision on whether Express remains adequate or Fastify provides clear net value. |
| W13 | Planned | Complete operational documentation | R16 | W1 through W11 as sections stabilize | Current-state docs, runbooks, troubleshooting guides, and recovery procedures. |
## Recommended Execution Order
```text
Phase 1: W1 -> W2 -> W3 -> W4
Phase 2: W5 -> W6 -> W7 -> W8
Phase 3: W9 -> W10 -> W11 -> W13
Phase 4: W12
```
The core principle is to reduce risk before changing framework foundations: establish contracts, types, validation, and module boundaries first; then evaluate whether Express remains the right transport layer.