feat(api): establish stable HTTP contract — uniform {success,data} envelope, status codes, /api/v1

Point 1 of the v1.0 lock-in: commit to a stable HTTP API (the cleanest, fullest form).

Core (centralized):
- Every JSON /api response now uses ONE envelope via a Fastify preSerialization hook (src/web/server.ts): success -> { success:true, data:<payload> }; error -> { success:false, error, errorCode } with a conventional HTTP status. Non-JSON routes (file-raw, tail-file SSE, download, screenshots, /q redirect, WS) are skipped.
- Error-code -> HTTP status is a single source of truth (httpStatusForErrorCode in src/types/api.ts): 400/401/404/409/422/429/500. Expanded ApiErrorCode (added UNAUTHORIZED, CONFLICT, RATE_LIMITED). Errors are no longer HTTP 200.
- Versioned alias: /api/v1/* rewrites to /api/* (rewriteApiV1Url), so external clients pin to a stable surface while the bundled UI keeps using /api/*.
- Handlers stripped of manual 'success:true' (50 across 14 route files) so they return bare payloads the hook wraps uniformly; fixed the mux DELETE {success:<bool>} envelope collision (-> {killed}).

Frontend (48 call sites across 10 files):
- _apiJson() auto-unwraps { success:true, data } -> data (null on error), so most bare-shape readers are transparent. Raw-fetch sites relocate payload reads under .data; success/res.ok/error checks unchanged.

Docs: new docs/api-reference.md (envelope, status table, error codes, /api/v1, SSE); versioning-policy.md flipped — the HTTP/SSE API is now part of the stable, SemVer-covered surface.

Verification: full unit/route suite green (2680 passed) incl. ~166 updated assertions across 24 test files; typecheck/lint/format/frontend-syntax clean; a headless-chromium smoke loaded the migrated UI and drove the panels with 0 console/page errors; /api/status and /api/v1/status confirmed returning the uniform envelope live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-06-10 01:30:43 +02:00
co-authored by Claude Opus 4.8
parent 5b3024b327
commit 458fb81cbe
49 changed files with 929 additions and 554 deletions
+25 -23
View File
@@ -88,10 +88,10 @@ async function createSession(baseUrl: string): Promise<string> {
body: JSON.stringify({ workingDir: '/tmp' }),
});
const data = await res.json();
if (!data.session?.id) {
if (!data.data?.session?.id) {
throw new Error(`Failed to create session: ${JSON.stringify(data)}`);
}
return data.session.id;
return data.data.session.id;
}
// Helper to delete a session
@@ -392,9 +392,9 @@ describe('Operation Lightspeed', () => {
expect(res.status).toBe(200);
// terminalBuffer may be empty for a fresh session, but field should exist
expect(data).toHaveProperty('terminalBuffer');
expect(data).toHaveProperty('truncated');
expect(data.truncated).toBe(false);
expect(data.data).toHaveProperty('terminalBuffer');
expect(data.data).toHaveProperty('truncated');
expect(data.data.truncated).toBe(false);
await deleteSession(baseUrl, sessionId);
});
@@ -407,7 +407,7 @@ describe('Operation Lightspeed', () => {
const data = await res.json();
expect(res.status).toBe(200);
expect(data).toHaveProperty('terminalBuffer');
expect(data.data).toHaveProperty('terminalBuffer');
await deleteSession(baseUrl, sessionId);
});
@@ -431,7 +431,7 @@ describe('Operation Lightspeed', () => {
// All should succeed
for (const data of results) {
expect(data).toHaveProperty('terminalBuffer');
expect(data.data).toHaveProperty('terminalBuffer');
}
// Cleanup
@@ -692,7 +692,8 @@ describe('Operation Lightspeed', () => {
const sessionId = await createSession(baseUrl);
const res = await fetch(`${baseUrl}/api/sessions`);
const sessions = await res.json();
const body = await res.json();
const sessions = body.data;
expect(Array.isArray(sessions)).toBe(true);
const session = sessions.find((s: any) => s.id === sessionId);
@@ -718,7 +719,8 @@ describe('Operation Lightspeed', () => {
await new Promise((resolve) => setTimeout(resolve, 1100));
const res = await fetch(`${baseUrl}/api/sessions`);
const sessions = await res.json();
const body = await res.json();
const sessions = body.data;
// All 3 should be present in the response
const foundIds = sessions.map((s: any) => s.id);
@@ -911,10 +913,10 @@ describe('Operation Lightspeed', () => {
expect(res.status).toBe(200);
// Local echo overlay needs session status to know when to show/hide
expect(data).toHaveProperty('status');
expect(typeof data.status).toBe('string');
expect(data.data).toHaveProperty('status');
expect(typeof data.data.status).toBe('string');
// Fresh session starts as 'starting'
expect(['starting', 'running', 'idle', 'error']).toContain(data.status);
expect(['starting', 'running', 'idle', 'error']).toContain(data.data.status);
await deleteSession(baseUrl, sessionId);
});
@@ -926,9 +928,9 @@ describe('Operation Lightspeed', () => {
const data = await res.json();
expect(res.status).toBe(200);
expect(data).toHaveProperty('fullSize');
expect(typeof data.fullSize).toBe('number');
expect(data.fullSize).toBeGreaterThanOrEqual(0);
expect(data.data).toHaveProperty('fullSize');
expect(typeof data.data.fullSize).toBe('number');
expect(data.data.fullSize).toBeGreaterThanOrEqual(0);
await deleteSession(baseUrl, sessionId);
});
@@ -942,9 +944,9 @@ describe('Operation Lightspeed', () => {
]);
// tail=0 means "don't tail" — should return same as no tail param
expect(fullRes.truncated).toBe(false);
expect(tailZeroRes.truncated).toBe(false);
expect(fullRes.terminalBuffer).toBe(tailZeroRes.terminalBuffer);
expect(fullRes.data.truncated).toBe(false);
expect(tailZeroRes.data.truncated).toBe(false);
expect(fullRes.data.terminalBuffer).toBe(tailZeroRes.data.terminalBuffer);
await deleteSession(baseUrl, sessionId);
});
@@ -957,8 +959,8 @@ describe('Operation Lightspeed', () => {
const data = await res.json();
expect(res.status).toBe(200);
expect(data).toHaveProperty('terminalBuffer');
expect(data.truncated).toBe(false); // Can't truncate if tail > fullSize
expect(data.data).toHaveProperty('terminalBuffer');
expect(data.data.truncated).toBe(false); // Can't truncate if tail > fullSize
await deleteSession(baseUrl, sessionId);
});
@@ -971,7 +973,7 @@ describe('Operation Lightspeed', () => {
// Should handle gracefully (either return full buffer or error cleanly)
expect(res.status).toBe(200);
expect(data).toHaveProperty('terminalBuffer');
expect(data.data).toHaveProperty('terminalBuffer');
await deleteSession(baseUrl, sessionId);
});
@@ -984,7 +986,7 @@ describe('Operation Lightspeed', () => {
// NaN tail should be handled (parseInt('abc') = NaN, which is falsy)
expect(res.status).toBe(200);
expect(data).toHaveProperty('terminalBuffer');
expect(data.data).toHaveProperty('terminalBuffer');
await deleteSession(baseUrl, sessionId);
});
@@ -1235,7 +1237,7 @@ describe('Operation Lightspeed', () => {
)
);
const ids = results.map((r) => r.session.id);
const ids = results.map((r) => r.data.session.id);
expect(ids.length).toBe(5);
expect(new Set(ids).size).toBe(5); // All unique