Merge remote-tracking branch 'origin/master' into worktree-grok-mode

# Conflicts:
#	src/web/public/app.js
This commit is contained in:
Codeman maintainer
2026-08-24 00:32:25 +02:00
70 changed files with 7091 additions and 182 deletions
+26
View File
@@ -461,6 +461,32 @@ describe('ralph-routes', () => {
// ========== POST /api/ralph-loop/start ==========
describe('POST /api/ralph-loop/start', () => {
it('awaits layout insertion and stops lifecycle work when registration rejects', async () => {
let rejectRegistration!: (error: Error) => void;
harness.ctx.addSession.mockImplementationOnce(
() =>
new Promise<void>((_resolve, reject) => {
rejectRegistration = reject;
})
);
const pending = harness.app.inject({
method: 'POST',
url: '/api/ralph-loop/start',
payload: { taskDescription: 'test task', completionPhrase: 'DONE', caseName: 'registration-order' },
});
await vi.waitFor(() => expect(harness.ctx.addSession).toHaveBeenCalledTimes(1));
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
expect(harness.ctx.setupSessionListeners).not.toHaveBeenCalled();
rejectRegistration(new Error('layout capacity exceeded'));
const response = await pending;
expect(response.statusCode).toBe(500);
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
expect(harness.ctx.setupSessionListeners).not.toHaveBeenCalled();
expect(harness.ctx.broadcast).not.toHaveBeenCalledWith('session:created', expect.anything());
});
it('rejects invalid request body', async () => {
const res = await harness.app.inject({
method: 'POST',
+49 -34
View File
@@ -1,11 +1,11 @@
/**
* @fileoverview Tests for PUT /api/session-order (global tab-order sync, COD-131).
* @fileoverview Tests for the synchronized legacy PUT /api/session-order endpoint.
*
* Uses app.inject() — no real HTTP ports needed.
* Asserts the uniform envelope contract:
* SUCCESS -> 2xx, { success: true, data: { order } }
* ERROR -> 4xx/5xx, { success: false, error, errorCode }
* and that the order is persisted to the (mock) StateStore + broadcast over SSE.
* Legacy callers are routed through the authenticated owner-scoped tab-layout service.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
@@ -13,6 +13,7 @@ import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { TabLayoutValidationError } from '../../src/tab-layout.js';
// registerSessionRoutes pulls in session.js which can shell out; stub the bits
// that would touch the OS at import/registration time. None are needed by the
@@ -29,11 +30,22 @@ interface LocalHarness {
ctx: MockRouteContext;
}
async function buildHarness(): Promise<LocalHarness> {
async function buildHarness(authUser = { username: 'alice', role: 'user' as const }): Promise<LocalHarness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
app.addHook('onRequest', async (req) => {
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
});
const ctx = createMockRouteContext();
Object.assign(ctx.tabLayouts, {
putLegacyOrder: vi.fn(async (_actor: unknown, order: string[]) => ({
order: [...order],
changedOwnerOrders: {},
globalOrder: [...order],
globalChanged: true,
})),
});
registerSessionRoutes(app, ctx as unknown as Parameters<typeof registerSessionRoutes>[1]);
// Mirror production's uniform-envelope preSerialization hook (server.ts).
@@ -60,35 +72,22 @@ describe('PUT /api/session-order', () => {
let harness: LocalHarness;
beforeEach(async () => {
vi.stubEnv('CODEMAN_MULTIUSER', '1');
harness = await buildHarness();
});
afterEach(async () => {
await harness.app.close();
vi.unstubAllEnvs();
});
it('persists the order and returns it in the envelope', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
payload: { order: ['a', 'b', 'c'] },
it('routes a regular legacy PUT through the authenticated owner layout service', async () => {
vi.mocked(harness.ctx.tabLayouts.putLegacyOrder).mockResolvedValueOnce({
order: ['a', 'b'],
changedOwnerOrders: { alice: ['a', 'b'] },
globalOrder: ['a', 'b'],
globalChanged: true,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body).toEqual({ success: true, data: { order: ['a', 'b', 'c'] } });
// Persisted to the store.
expect(harness.ctx.store.setSessionOrder).toHaveBeenCalledWith(['a', 'b', 'c']);
expect(harness.ctx.store.getSessionOrder()).toEqual(['a', 'b', 'c']);
// Broadcast over SSE.
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['a', 'b', 'c'] });
});
it('preserves a server-only id (unknown to the pushing device) at the end', async () => {
// Seed the store with an order containing a server-only id "z".
harness.ctx.store.setSessionOrder(['a', 'z', 'b']);
(harness.ctx.broadcast as ReturnType<typeof vi.fn>).mockClear();
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
@@ -96,25 +95,24 @@ describe('PUT /api/session-order', () => {
});
expect(res.statusCode).toBe(200);
const body = res.json();
// Incoming order wins, server-only "z" falls to the end.
expect(body).toEqual({ success: true, data: { order: ['b', 'a', 'z'] } });
expect(harness.ctx.store.getSessionOrder()).toEqual(['b', 'a', 'z']);
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['b', 'a', 'z'] });
expect(harness.ctx.tabLayouts.putLegacyOrder).toHaveBeenCalledWith({ owner: 'alice', isAdmin: false }, ['b', 'a']);
expect(res.json().data.order).toEqual(['a', 'b']);
});
it('normalizes junk input (dedup + drop empties) before persisting', async () => {
it('uses the machine-wide admin bridge for an admin caller', async () => {
await harness.app.close();
harness = await buildHarness({ username: 'root', role: 'admin' });
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
payload: { order: ['a', 'a', '', 'b'] },
payload: { order: ['b', 'a'] },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ success: true, data: { order: ['a', 'b'] } });
expect(harness.ctx.tabLayouts.putLegacyOrder).toHaveBeenCalledWith({ owner: 'root', isAdmin: true }, ['b', 'a']);
});
it('rejects a non-array order with a 4xx envelope', async () => {
it('rejects malformed bodies before invoking the service', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
@@ -124,6 +122,23 @@ describe('PUT /api/session-order', () => {
expect(res.statusCode).toBeGreaterThanOrEqual(400);
const body = res.json();
expect(body.success).toBe(false);
expect(harness.ctx.store.setSessionOrder).not.toHaveBeenCalled();
expect(harness.ctx.tabLayouts.putLegacyOrder).not.toHaveBeenCalled();
});
it('maps owner-boundary validation failures to INVALID_INPUT', async () => {
vi.mocked(harness.ctx.tabLayouts.putLegacyOrder).mockRejectedValueOnce(
new TabLayoutValidationError('session is not owned by layout owner: foreign')
);
const res = await harness.app.inject({
method: 'PUT',
url: '/api/session-order',
payload: { order: ['foreign'] },
});
expect(res.statusCode).toBe(400);
expect(res.json()).toMatchObject({
success: false,
errorCode: ApiErrorCode.INVALID_INPUT,
});
});
});
+6 -1
View File
@@ -332,11 +332,16 @@ describe('system-routes', () => {
describe('POST /api/cleanup-state', () => {
it('cleans up stale session state', async () => {
const runStaleSessionCleanup = vi.fn(
async (_activeIds: Set<string>, action: (ids: ReadonlySet<string>) => unknown) => action(new Set())
);
harness.ctx.tabLayouts.runStaleSessionCleanup = runStaleSessionCleanup;
const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.cleanedSessions).toBe(0);
expect(harness.ctx.store.cleanupStaleSessions).toHaveBeenCalled();
expect(harness.ctx.store.cleanupSessionsByIds).toHaveBeenCalledWith(new Set());
expect(runStaleSessionCleanup).toHaveBeenCalledOnce();
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* @fileoverview Owner-scoped tab-layout HTTP concurrency and validation contract.
*/
import Fastify from 'fastify';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { registerTabLayoutRoutes } from '../../src/web/routes/tab-layout-routes.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import type { TabLayout } from '../../src/tab-layout.js';
const layout = (version = 3): TabLayout => ({
version,
groups: [],
ungrouped: [{ kind: 'session', id: 'mine' }],
updatedAt: '2026-08-16T00:00:00.000Z',
});
async function harness(username?: string, role: 'admin' | 'user' = 'user') {
const app = Fastify({ logger: false });
if (username) {
app.addHook('onRequest', async (req) => {
(req as unknown as { authUser: { username: string; role: 'admin' | 'user' } }).authUser = { username, role };
});
}
const service = {
get: vi.fn(async () => layout()),
put: vi.fn(async (_owner: string, desired: unknown, baseVersion: number) => ({
status: 'updated' as const,
layout: { ...(desired as TabLayout), version: baseVersion + 1 },
})),
};
registerTabLayoutRoutes(app, { tabLayouts: service } as never);
installRouteErrorHandler(app);
await app.ready();
return { app, service };
}
afterEach(() => vi.unstubAllEnvs());
describe('tab layout routes', () => {
it('maps single-user requests to @single and never accepts an owner override', async () => {
vi.stubEnv('CODEMAN_MULTIUSER', '0');
const { app, service } = await harness();
const response = await app.inject({ method: 'GET', url: '/api/tab-layout?owner=foreign' });
expect(response.statusCode).toBe(200);
expect(service.get).toHaveBeenCalledWith('@single');
await app.close();
});
it('uses the authenticated username in multi-user mode, including for admins', async () => {
vi.stubEnv('CODEMAN_MULTIUSER', '1');
const { app, service } = await harness('admin-a', 'admin');
await app.inject({ method: 'GET', url: '/api/tab-layout?owner=someone-else' });
expect(service.get).toHaveBeenCalledWith('admin-a');
await app.close();
});
it.each([2, 4])('returns 409 with the authoritative prepared layout when baseVersion=%s', async (baseVersion) => {
vi.stubEnv('CODEMAN_MULTIUSER', '1');
const { app, service } = await harness('alice');
service.put.mockResolvedValueOnce({ status: 'conflict', layout: layout(3) });
const response = await app.inject({
method: 'PUT',
url: '/api/tab-layout',
payload: { baseVersion, layout: layout(baseVersion) },
});
expect(response.statusCode).toBe(409);
expect(response.json().success).toBe(false);
expect(response.json().errorCode).toBe('CONFLICT');
expect(response.json().data.layout).toEqual(layout(3));
expect(service.put).toHaveBeenCalledWith('alice', layout(baseVersion), baseVersion);
await app.close();
});
it('rejects malformed writes before invoking the service', async () => {
const { app, service } = await harness();
const response = await app.inject({ method: 'PUT', url: '/api/tab-layout', payload: { baseVersion: -1 } });
expect(response.statusCode).toBe(400);
expect(service.put).not.toHaveBeenCalled();
await app.close();
});
it('rejects extra write keys before invoking the service', async () => {
const { app, service } = await harness();
const response = await app.inject({
method: 'PUT',
url: '/api/tab-layout',
payload: { baseVersion: 3, layout: layout(), owner: 'foreign' },
});
expect(response.statusCode).toBe(400);
expect(response.json().errorCode).toBe('INVALID_INPUT');
expect(service.put).not.toHaveBeenCalled();
await app.close();
});
it('has an explicit conservative body limit', async () => {
const { app } = await harness();
const response = await app.inject({
method: 'PUT',
url: '/api/tab-layout',
payload: { baseVersion: 3, layout: layout(), padding: 'x'.repeat(140 * 1024) },
});
expect(response.statusCode).toBe(413);
await app.close();
});
});
+73 -1
View File
@@ -5,7 +5,7 @@
* the developer's real ~/.codeman/webviews.json.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyWebsocket from '@fastify/websocket';
@@ -16,17 +16,23 @@ import { registerWebviewRoutes } from '../../src/web/routes/webview-routes.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { webviewCapabilities } from '../../src/webview-capabilities.js';
import { capabilityFromProxyPath } from '../../src/web/webview-proxy.js';
import { TabLayoutService } from '../../src/tab-layout-service.js';
import type { TabLayout } from '../../src/tab-layout.js';
let app: FastifyInstance;
let tmpDir: string;
let savedDataDir: string | undefined;
const broadcasts: Array<{ event: string; data: unknown }> = [];
const webviewCreated = vi.fn(async () => {});
const webviewDeleted = vi.fn(async () => {});
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-webviews-'));
savedDataDir = process.env.CODEMAN_DATA_DIR;
process.env.CODEMAN_DATA_DIR = tmpDir;
broadcasts.length = 0;
webviewCreated.mockClear();
webviewDeleted.mockClear();
app = Fastify({ logger: false });
await app.register(fastifyCookie);
@@ -34,6 +40,7 @@ beforeEach(async () => {
await app.register(fastifyWebsocket);
registerWebviewRoutes(app, {
broadcast: (event: string, data: unknown) => broadcasts.push({ event, data }),
tabLayouts: { webviewCreated, webviewDeleted },
} as never);
installRouteErrorHandler(app);
await app.ready();
@@ -90,6 +97,58 @@ describe('POST /api/webviews', () => {
}
});
it('rolls back webview persistence and emits nothing when layout capacity rejects insertion', async () => {
const refs = Array.from({ length: 512 }, (_, index) => ({ kind: 'session' as const, id: `s-${index}` }));
const original: TabLayout = {
version: 9,
groups: [],
ungrouped: refs,
updatedAt: '2026-08-16T00:00:00.000Z',
};
let stored = original;
const live = new Map(refs.map((ref, index) => [ref.id, { id: ref.id, createdAt: index }]));
const atomicBroadcast = vi.fn();
const service = new TabLayoutService({
store: {
getTabLayout: () => stored,
setTabLayout: (_owner, layout) => {
stored = layout;
},
getSessions: () => ({}),
getSessionOrder: () => [],
} as never,
sessions: live,
readWebviews: async () =>
(await import('../../src/webview-store.js')).readWebviews(tmpDir) as Promise<
Array<{ id: string; owner?: string }>
>,
broadcast: atomicBroadcast,
broadcastSessionOrder: vi.fn(),
});
const atomicApp = Fastify({ logger: false });
await atomicApp.register(fastifyCookie);
await atomicApp.register(fastifyWebsocket);
registerWebviewRoutes(atomicApp, {
broadcast: atomicBroadcast,
tabLayouts: service,
} as never);
installRouteErrorHandler(atomicApp);
await atomicApp.ready();
const response = await atomicApp.inject({
method: 'POST',
url: '/api/webviews',
payload: { name: 'overflow', url: 'https://example.test/' },
});
const list = (await atomicApp.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
expect(response.statusCode).toBe(500);
expect(list).toEqual([]);
expect(stored).toEqual(original);
expect(atomicBroadcast).not.toHaveBeenCalled();
await atomicApp.close();
});
it('rejects URLs carrying embedded credentials', async () => {
const res = await create({ name: 'bad', url: 'http://user:pass@host:4000/' });
expect(res.statusCode).toBe(400);
@@ -142,6 +201,19 @@ describe('DELETE /api/webviews/:id', () => {
expect(webviewCapabilities.resolve(cap)).toBeUndefined();
});
it('keeps the exact saved record and emits nothing when layout deletion fails', async () => {
const created = (await create({ name: 'Keep me', url: 'https://keep.example/' })).json().data;
broadcasts.length = 0;
webviewDeleted.mockRejectedValueOnce(new Error('tab layout restoration failed'));
const response = await app.inject({ method: 'DELETE', url: `/api/webviews/${created.id}` });
const list = (await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
expect(response.statusCode).toBe(500);
expect(list).toEqual([created]);
expect(broadcasts).toEqual([]);
});
it('404s an unknown id', async () => {
expect((await app.inject({ method: 'DELETE', url: '/api/webviews/nope' })).statusCode).toBe(404);
});