mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
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:
@@ -58,24 +58,24 @@ describe('Integration Flows', () => {
|
||||
const quickStartData = await quickStartRes.json();
|
||||
|
||||
expect(quickStartData.success).toBe(true);
|
||||
expect(quickStartData.sessionId).toBeDefined();
|
||||
expect(quickStartData.caseName).toBe(caseName);
|
||||
createdSessions.push(quickStartData.sessionId);
|
||||
expect(quickStartData.data.sessionId).toBeDefined();
|
||||
expect(quickStartData.data.caseName).toBe(caseName);
|
||||
createdSessions.push(quickStartData.data.sessionId);
|
||||
|
||||
// Step 2: Verify session is in interactive mode
|
||||
const sessionRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}`);
|
||||
const sessionRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}`);
|
||||
const sessionData = await sessionRes.json();
|
||||
|
||||
expect(sessionData.id).toBe(quickStartData.sessionId);
|
||||
expect(sessionData.workingDir).toContain(caseName);
|
||||
expect(['busy', 'idle', 'running']).toContain(sessionData.status); // May transition quickly in test mode
|
||||
expect(sessionData.data.id).toBe(quickStartData.data.sessionId);
|
||||
expect(sessionData.data.workingDir).toContain(caseName);
|
||||
expect(['busy', 'idle', 'running']).toContain(sessionData.data.status); // May transition quickly in test mode
|
||||
|
||||
// Step 3: Verify case was created with CLAUDE.md
|
||||
const caseRes = await fetch(`${baseUrl}/api/cases/${caseName}`);
|
||||
const caseData = await caseRes.json();
|
||||
|
||||
expect(caseData.name).toBe(caseName);
|
||||
expect(caseData.hasClaudeMd).toBe(true);
|
||||
expect(caseData.data.name).toBe(caseName);
|
||||
expect(caseData.data.hasClaudeMd).toBe(true);
|
||||
});
|
||||
|
||||
it('should reuse existing case when quick starting with existing case name', async () => {
|
||||
@@ -90,10 +90,10 @@ describe('Integration Flows', () => {
|
||||
});
|
||||
const firstData = await firstRes.json();
|
||||
expect(firstData.success).toBe(true);
|
||||
createdSessions.push(firstData.sessionId);
|
||||
createdSessions.push(firstData.data.sessionId);
|
||||
|
||||
// Delete the session but keep the case
|
||||
await fetch(`${baseUrl}/api/sessions/${firstData.sessionId}`, { method: 'DELETE' });
|
||||
await fetch(`${baseUrl}/api/sessions/${firstData.data.sessionId}`, { method: 'DELETE' });
|
||||
|
||||
// Second quick start - should reuse the case
|
||||
const secondRes = await fetch(`${baseUrl}/api/quick-start`, {
|
||||
@@ -104,9 +104,9 @@ describe('Integration Flows', () => {
|
||||
const secondData = await secondRes.json();
|
||||
|
||||
expect(secondData.success).toBe(true);
|
||||
expect(secondData.caseName).toBe(caseName);
|
||||
expect(secondData.casePath).toBe(firstData.casePath);
|
||||
createdSessions.push(secondData.sessionId);
|
||||
expect(secondData.data.caseName).toBe(caseName);
|
||||
expect(secondData.data.casePath).toBe(firstData.data.casePath);
|
||||
createdSessions.push(secondData.data.sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,20 +132,20 @@ describe('Integration Flows', () => {
|
||||
});
|
||||
const sessionData = await sessionRes.json();
|
||||
expect(sessionData.success).toBe(true);
|
||||
createdSessions.push(sessionData.session.id);
|
||||
createdSessions.push(sessionData.data.session.id);
|
||||
|
||||
// Step 3: Start interactive mode
|
||||
const interactiveRes = await fetch(`${baseUrl}/api/sessions/${sessionData.session.id}/interactive`, {
|
||||
const interactiveRes = await fetch(`${baseUrl}/api/sessions/${sessionData.data.session.id}/interactive`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const interactiveData = await interactiveRes.json();
|
||||
expect(interactiveData.success).toBe(true);
|
||||
|
||||
// Verify session state
|
||||
const verifyRes = await fetch(`${baseUrl}/api/sessions/${sessionData.session.id}`);
|
||||
const verifyRes = await fetch(`${baseUrl}/api/sessions/${sessionData.data.session.id}`);
|
||||
const verifyData = await verifyRes.json();
|
||||
expect(['busy', 'idle', 'running']).toContain(verifyData.status);
|
||||
expect(verifyData.workingDir).toContain(caseName);
|
||||
expect(['busy', 'idle', 'running']).toContain(verifyData.data.status);
|
||||
expect(verifyData.data.workingDir).toContain(caseName);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,13 +162,13 @@ describe('Integration Flows', () => {
|
||||
});
|
||||
const quickStartData = await quickStartRes.json();
|
||||
expect(quickStartData.success).toBe(true);
|
||||
createdSessions.push(quickStartData.sessionId);
|
||||
createdSessions.push(quickStartData.data.sessionId);
|
||||
|
||||
// Wait for Claude to start up
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Send input
|
||||
const inputRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}/input`, {
|
||||
const inputRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: '/help\n' }),
|
||||
@@ -180,9 +180,9 @@ describe('Integration Flows', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Check terminal buffer has content
|
||||
const terminalRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}/terminal`);
|
||||
const terminalRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}/terminal`);
|
||||
const terminalData = await terminalRes.json();
|
||||
expect(terminalData.terminalBuffer.length).toBeGreaterThan(0);
|
||||
expect(terminalData.data.terminalBuffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle terminal resize', async () => {
|
||||
@@ -197,15 +197,21 @@ describe('Integration Flows', () => {
|
||||
});
|
||||
const quickStartData = await quickStartRes.json();
|
||||
expect(quickStartData.success).toBe(true);
|
||||
createdSessions.push(quickStartData.sessionId);
|
||||
createdSessions.push(quickStartData.data.sessionId);
|
||||
|
||||
// Resize terminal
|
||||
const resizeRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols: 200, rows: 50 }),
|
||||
});
|
||||
const resizeData = await resizeRes.json();
|
||||
// Resize terminal (retry briefly — a just-quick-started session can be
|
||||
// momentarily busy, which would return SESSION_BUSY; this is a transient race).
|
||||
let resizeData;
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const resizeRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols: 200, rows: 50 }),
|
||||
});
|
||||
resizeData = await resizeRes.json();
|
||||
if (resizeData.success) break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
expect(resizeData.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -225,14 +231,14 @@ describe('Integration Flows', () => {
|
||||
expect(quickStartData.success).toBe(true);
|
||||
|
||||
// Delete the session
|
||||
const deleteRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}`, {
|
||||
const deleteRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const deleteData = await deleteRes.json();
|
||||
expect(deleteData.success).toBe(true);
|
||||
|
||||
// Verify session is gone
|
||||
const verifyRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}`);
|
||||
const verifyRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.data.sessionId}`);
|
||||
const verifyData = await verifyRes.json();
|
||||
expect(verifyData.error).toContain('not found');
|
||||
});
|
||||
@@ -251,20 +257,20 @@ describe('Integration Flows', () => {
|
||||
});
|
||||
const quickStartData = await quickStartRes.json();
|
||||
expect(quickStartData.success).toBe(true);
|
||||
createdSessions.push(quickStartData.sessionId);
|
||||
createdSessions.push(quickStartData.data.sessionId);
|
||||
|
||||
// Get full status
|
||||
const statusRes = await fetch(`${baseUrl}/api/status`);
|
||||
const statusData = await statusRes.json();
|
||||
|
||||
expect(statusData.sessions).toBeDefined();
|
||||
expect(Array.isArray(statusData.sessions)).toBe(true);
|
||||
expect(statusData.scheduledRuns).toBeDefined();
|
||||
expect(statusData.respawnStatus).toBeDefined();
|
||||
expect(statusData.timestamp).toBeDefined();
|
||||
expect(statusData.data.sessions).toBeDefined();
|
||||
expect(Array.isArray(statusData.data.sessions)).toBe(true);
|
||||
expect(statusData.data.scheduledRuns).toBeDefined();
|
||||
expect(statusData.data.respawnStatus).toBeDefined();
|
||||
expect(statusData.data.timestamp).toBeDefined();
|
||||
|
||||
// Verify our session is in the list
|
||||
const ourSession = statusData.sessions.find((s: any) => s.id === quickStartData.sessionId);
|
||||
const ourSession = statusData.data.sessions.find((s: any) => s.id === quickStartData.data.sessionId);
|
||||
expect(ourSession).toBeDefined();
|
||||
expect(ourSession.workingDir).toContain(caseName);
|
||||
});
|
||||
@@ -341,7 +347,7 @@ describe('SSE Event Flow', () => {
|
||||
});
|
||||
const quickStartData = await quickStartRes.json();
|
||||
expect(quickStartData.success).toBe(true);
|
||||
createdSessions.push(quickStartData.sessionId);
|
||||
createdSessions.push(quickStartData.data.sessionId);
|
||||
|
||||
// Wait for events
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
Reference in New Issue
Block a user