fix(api): close contract gaps found by post-merge adversarial audit

A 15-agent audit of the merged tree confirmed 9 envelope/contract bugs;
all fixed here, with live-server contract tests added:

Blockers (fresh-install quick start broken):
- session-ui.js runClaude/runShell unwrapped .data from the /api/cases/:name
  404 error envelope (which has no data key), so a not-yet-created case threw
  TypeError instead of triggering the auto-create fallback. Now '?.data ?? {}'.

Contract violations on the new stable surface:
- Unknown /api routes returned HTTP 404 with {success:true,...} (Fastify's
  default not-found payload was wrapped by the envelope hook). Added a
  setNotFoundHandler returning the standard error envelope for /api paths.
- POST /api/events/subscribe 400 body became {success:true,data:{error}};
  now createErrorResponse(INVALID_INPUT).
- POST /api/clipboard validation error lacked errorCode and shipped HTTP 200;
  now createErrorResponse(INVALID_INPUT) -> 400.
- POST /api/run catch path returned bare {success:false,sessionId,error}
  (HTTP 200, no errorCode); now OPERATION_FAILED envelope -> 422 with the
  dead session id in the message.
- DELETE tail-file/:streamId returned {success: closed}, colliding with the
  envelope discriminator; now returns {closed}.

Dead/regressed UI paths:
- Plan history modal could never open: route returned the bare history array
  under data while the frontend read data.data.history/currentVersion. Route
  now returns {history, currentVersion}; modal task count fixed to stats.total.
- Self-update error toast read j.error.message from the string-typed envelope
  error, always falling back to the generic message; now reads the string.

Cleanup:
- Removed the stale QuickStartResponse type (unreferenced; documented the
  pre-envelope shape and invited success-key collisions).

Tests: new test/http-contract.test.ts boots a real WebServer (port 3168) and
pins the envelope, /api/v1 alias, error statuses, and the /api 404 shape —
the route-test harness does not install the server-level hook, so these need
the live server. Updated file-routes/plan-routes/scheduled-runs tests to the
fixed shapes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-06-10 03:41:04 +02:00
co-authored by Claude Opus 4.8
parent 732463b36e
commit c29475ed10
13 changed files with 137 additions and 35 deletions
+2 -1
View File
@@ -6,13 +6,14 @@
import { FastifyInstance } from 'fastify';
import { SseEvent } from '../sse-events.js';
import type { EventPort } from '../ports/index.js';
import { createErrorResponse, ApiErrorCode } from '../../types.js';
export function registerClipboardRoutes(app: FastifyInstance, ctx: EventPort): void {
app.post('/api/clipboard', async (req) => {
const body = req.body as { text?: string; sessionId?: string };
const text = body?.text;
if (typeof text !== 'string' || text.length === 0) {
return { success: false, error: 'Missing or empty "text" field' };
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing or empty "text" field');
}
ctx.broadcast(SseEvent.ClipboardWrite, {
text,
+5 -2
View File
@@ -375,12 +375,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
});
});
// Close a file stream
// Close a file stream. Returns { closed } rather than { success: closed } —
// a top-level `success` key would collide with the envelope discriminator
// (the preSerialization hook would pass `{success:false}` through as a
// malformed error envelope instead of wrapping it).
app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => {
const { id, streamId } = req.params as { id: string; streamId: string };
findSessionOrFail(ctx, id); // Validates session exists
const closed = fileStreamManager.closeStream(streamId);
return { success: closed };
return { closed };
});
// Session-scoped file download.
// Uses the same realpath-based workspace boundary as file preview/raw routes;
+1 -1
View File
@@ -408,7 +408,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available');
}
return { success: true, data: tracker.getPlanHistory() };
return { success: true, data: { history: tracker.getPlanHistory(), currentVersion: tracker.planVersion } };
});
// ========== Rollback to Version ==========
+3 -2
View File
@@ -1086,9 +1086,10 @@ export function registerSessionRoutes(
await ctx.cleanupSession(session.id, true, 'run_prompt_complete');
return { sessionId: session.id, ...result };
} catch (err) {
// Clean up session on error too
// Clean up session on error too. The session is destroyed here, so its id
// is only useful for log correlation — carry it in the error message.
await ctx.cleanupSession(session.id, true, 'run_prompt_error');
return { success: false, sessionId: session.id, error: getErrorMessage(err) };
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${getErrorMessage(err)} (session ${session.id})`);
}
});