chore: version packages

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-12 02:30:08 +02:00
parent cf3183abf7
commit f39beb3326
29 changed files with 3191 additions and 441 deletions
+21 -6
View File
@@ -6,7 +6,9 @@
* driving Codeman over HTTP. Nothing tied it to the server, so renaming or dropping a
* route left the skill confidently telling agents to call a 404. This parses the
* `METHOD /api/...` pairs out of the doc and matches them against the `app.<method>()`
* registrations in src/web/routes/*.ts.
* registrations in src/web/routes/*.ts plus src/web/server.ts (which registers `/api/events`
* and `/api/events/subscribe` directly). Fastify generics on the registration call are
* tolerated, since approval-routes.ts uses them.
*
* Precision over recall on purpose: only a bare uppercase verb followed by an
* `/api/...` path counts, so prose that merely mentions a path (the `.../sessions/null`
@@ -26,11 +28,19 @@ import { join } from 'node:path';
const HERE = fileURLToPath(new URL('.', import.meta.url));
const DOC_PATH = join(HERE, '../skills/codeman/reference/endpoints.md');
const ROUTES_DIR = join(HERE, '../src/web/routes');
/** `/api/events` and `/api/events/subscribe` are registered here, not in routes/. */
const SERVER_PATH = join(HERE, '../src/web/server.ts');
/** `METHOD /api/<path>`, stopping before a query string, backtick or prose. */
const DOC_ENDPOINT = /\b(GET|POST|PUT|PATCH|DELETE)\s+\/(api\/[A-Za-z0-9_:/-]+)/g;
/** `app.get('/api/…'`, where the path may sit on its own line (case-routes.ts, file-routes.ts). */
const ROUTE_REGISTRATION = /app\.(get|post|put|patch|delete)\(\s*'([^']+)'/g;
/**
* `app.get('/api/…'`, where the path may sit on its own line (case-routes.ts,
* file-routes.ts) and the call may carry a Fastify generic
* (`app.post<{ Params: { id: string } }>('/api/approvals/:id/answer'`, approval-routes.ts).
* The generic is matched non-greedily up to the `(` so a `<…>` containing braces or
* nested generics still lands on the path argument.
*/
const ROUTE_REGISTRATION = /app\.(get|post|put|patch|delete)(?:<[\s\S]*?>)?\(\s*'([^']+)'/g;
/**
* Strip the `/api/v1` alias and replace param names with a placeholder, so
@@ -53,9 +63,14 @@ function documentedEndpoints(): string[] {
function registeredRoutes(): Set<string> {
const registered = new Set<string>();
for (const file of readdirSync(ROUTES_DIR)) {
if (!file.endsWith('.ts')) continue;
const source = readFileSync(join(ROUTES_DIR, file), 'utf-8');
const sources = readdirSync(ROUTES_DIR)
.filter((file) => file.endsWith('.ts'))
.map((file) => join(ROUTES_DIR, file));
// Not every route lives in routes/: the SSE stream and its subscribe companion are
// registered directly on the server (`this.app.get('/api/events')`), and the doc
// documents them, so scanning only routes/ reported real endpoints as missing.
sources.push(SERVER_PATH);
for (const source of sources.map((path) => readFileSync(path, 'utf-8'))) {
for (const match of source.matchAll(ROUTE_REGISTRATION)) {
if (!match[2].startsWith('/api/')) continue;
registered.add(normalize(match[1], match[2]));
@@ -0,0 +1,229 @@
/**
* @fileoverview `parentSessionId` on the create routes — the "who spawned me" hint
* that draws the tab lineage lines.
*
* The rules under test are the ones that keep a cosmetic field harmless: it is
* RESOLVED against live sessions rather than trusted, anything unresolvable is
* dropped instead of failing the spawn (a worker must never fail to start over a
* decoration), and it never crosses an owner boundary.
*
* Uses app.inject(), so no real HTTP port is needed.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdtemp, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
const PARENT_ID = 'test-session-1'; // the id the mock context pre-populates
interface Harness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function createHarness(): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('POST /api/sessions parentSessionId', () => {
let workingDir: string;
let harness: Harness;
/**
* The created session as the route returned it. The harness registers the route
* module alone, without server.ts's envelope hook, so the handler's raw
* `{ session }` is what lands here.
*/
const created = (body: string) => {
const parsed = JSON.parse(body);
return (parsed.data?.session ?? parsed.session) as { id: string; parentSessionId?: string };
};
beforeEach(async () => {
workingDir = await mkdtemp(join(tmpdir(), 'codeman-lineage-'));
harness = await createHarness();
});
afterEach(async () => {
await harness.app.close();
await rm(workingDir, { recursive: true, force: true });
});
it('stores a body-supplied parent that resolves to a live session', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'child', mode: 'claude', workingDir, parentSessionId: PARENT_ID },
});
expect(res.statusCode).toBe(200);
expect(created(res.body).parentSessionId).toBe(PARENT_ID);
});
it('accepts the X-Codeman-Parent-Session header, which is how the skill sends it', async () => {
// The agent skill puts this on its shared curl invocation, so every spawn
// recipe carries it without a per-recipe edit.
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
headers: { 'x-codeman-parent-session': PARENT_ID },
payload: { name: 'child', mode: 'claude', workingDir },
});
expect(res.statusCode).toBe(200);
expect(created(res.body).parentSessionId).toBe(PARENT_ID);
});
it('lets the body win when both are present', async () => {
harness.ctx.sessions.set('other-session', createMockSession('other-session'));
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
headers: { 'x-codeman-parent-session': 'other-session' },
payload: { name: 'child', mode: 'claude', workingDir, parentSessionId: PARENT_ID },
});
expect(created(res.body).parentSessionId).toBe(PARENT_ID);
});
it('DROPS an unknown parent instead of failing the spawn', async () => {
// The whole point: a stale id from a cached preamble must cost a line, not a worker.
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'child', mode: 'claude', workingDir, parentSessionId: 'no-such-session-anywhere' },
});
expect(res.statusCode).toBe(200);
expect(created(res.body).id).toBeTruthy(); // the worker still started
expect(created(res.body).parentSessionId).toBeUndefined();
});
it('resolves a >= 8-char prefix, because ids reach agents truncated', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'child', mode: 'claude', workingDir, parentSessionId: PARENT_ID.slice(0, 8) },
});
expect(created(res.body).parentSessionId).toBe(PARENT_ID);
});
it('refuses a prefix shorter than 8 chars', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'child', mode: 'claude', workingDir, parentSessionId: PARENT_ID.slice(0, 4) },
});
expect(created(res.body).parentSessionId).toBeUndefined();
});
it('resolves an AMBIGUOUS prefix to nothing rather than to a guess', async () => {
harness.ctx.sessions.set('test-session-2', createMockSession('test-session-2'));
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'child', mode: 'claude', workingDir, parentSessionId: 'test-session-' },
});
expect(created(res.body).parentSessionId).toBeUndefined();
});
it('drops a parent owned by someone else', async () => {
// The new session's owner is undefined here (single-user), so a parent carrying
// an owner is a mismatch — which is exactly the multi-user case of stapling your
// session under another user's tab.
(harness.ctx.sessions.get(PARENT_ID) as unknown as { owner?: string }).owner = 'someone-else';
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'child', mode: 'claude', workingDir, parentSessionId: PARENT_ID },
});
expect(res.statusCode).toBe(200);
expect(created(res.body).parentSessionId).toBeUndefined();
});
it('ignores an over-long header without failing the request', async () => {
// The body field is schema-capped at 100; the header is not, so the resolver
// caps it too rather than scanning an arbitrary string against every session.
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
headers: { 'x-codeman-parent-session': 'x'.repeat(500) },
payload: { name: 'child', mode: 'claude', workingDir },
});
expect(res.statusCode).toBe(200);
expect(created(res.body).parentSessionId).toBeUndefined();
});
it('survives into the persisted state, so lineage outlives a restart', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { name: 'child', mode: 'claude', workingDir, parentSessionId: PARENT_ID },
});
const childId = created(res.body).id;
const child = harness.ctx.sessions.get(childId) as unknown as {
toState(): { parentSessionId?: string };
};
expect(child.toState().parentSessionId).toBe(PARENT_ID);
});
it("applies the same resolution on quick-start, the skill's usual spawn route", async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'lineagecase', mode: 'claude', parentSessionId: PARENT_ID },
});
expect(res.statusCode).toBe(200);
const { sessionId } = JSON.parse(res.body) as { sessionId: string };
const child = harness.ctx.sessions.get(sessionId) as unknown as {
toState(): { parentSessionId?: string };
};
expect(child.toState().parentSessionId).toBe(PARENT_ID);
});
it('drops an unresolvable parent on quick-start without failing the spawn', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'lineagecase2', mode: 'claude', parentSessionId: 'ghost-session-id' },
});
expect(res.statusCode).toBe(200);
const { sessionId } = JSON.parse(res.body) as { sessionId: string };
expect(sessionId).toBeTruthy();
const child = harness.ctx.sessions.get(sessionId) as unknown as {
toState(): { parentSessionId?: string };
};
expect(child.toState().parentSessionId).toBeUndefined();
});
it('never lets a session parent itself', async () => {
// Only reachable through recovery (both values come off disk), but a self-edge
// would draw a zero-length arc under one tab, so the Session ctor refuses it.
const { Session } = await import('../../src/session.js');
const s = new Session({ id: 'self-ref', workingDir, parentSessionId: 'self-ref' });
expect(s.parentSessionId).toBeUndefined();
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* Geometry policy for the session lineage lines (tab → tab it spawned).
*
* The renderer in session-lineage.js measures and appends; every decision about
* WHAT to draw (and whether to draw at all) lives in computeLineagePath, so it can
* be pinned here without a browser.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
type Rect = { left: number; top: number; width: number; height: number };
type LineagePath = { d: string; endX: number; endY: number; sameRow: boolean } | null;
function loadLineageHelper() {
const context = vm.createContext({ window: {}, globalThis: {} });
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
vm.runInContext(source, context, { filename: 'constants.js' });
return (
context.window as {
CodemanLineage: {
computePath: (input: { parent: Rect | null; child: Rect | null; strip?: Rect; depth?: number }) => LineagePath;
DIP_MIN_PX: number;
DIP_MAX_PX: number;
SIBLING_STEP_PX: number;
};
}
).CodemanLineage;
}
// A strip wide enough that nothing is clipped unless a test says so.
const STRIP: Rect = { left: 0, top: 0, width: 1200, height: 40 };
const tab = (left: number, top = 4): Rect => ({ left, top, width: 120, height: 30 });
/** Pull the control-point Y values out of `M x y C x y, x y, x y`. */
function controlYs(d: string): number[] {
const nums = d.match(/-?\d+(\.\d+)?/g)?.map(Number) ?? [];
// M x0 y0 C x1 y1, x2 y2, x3 y3 → indices 3 and 5 are the control Ys
return [nums[3], nums[5]];
}
describe('lineage line geometry', () => {
it('bridges two same-row tabs with an arc that hangs BELOW the strip', () => {
const helper = loadLineageHelper();
const geom = helper.computePath({ parent: tab(0), child: tab(400), strip: STRIP });
expect(geom).not.toBeNull();
expect(geom!.sameRow).toBe(true);
// Starts at the parent's bottom-center, ends at the child's bottom-center.
expect(geom!.d.startsWith('M 60 34')).toBe(true);
expect(geom!.endX).toBe(460);
expect(geom!.endY).toBe(34);
// Both control points dip below the tab bottoms — that is what makes it a
// bracket under the strip rather than a line drawn across the tabs.
for (const y of controlYs(geom!.d)) expect(y).toBeGreaterThan(34);
});
it('deepens the dip with distance, but keeps it inside the clamp', () => {
const helper = loadLineageHelper();
const near = helper.computePath({ parent: tab(0), child: tab(140), strip: STRIP })!;
const far = helper.computePath({ parent: tab(0), child: tab(1000), strip: STRIP })!;
const nearDip = controlYs(near.d)[0] - 34;
const farDip = controlYs(far.d)[0] - 34;
expect(farDip).toBeGreaterThan(nearDip);
expect(nearDip).toBeGreaterThanOrEqual(helper.DIP_MIN_PX);
expect(farDip).toBeLessThanOrEqual(helper.DIP_MAX_PX);
});
it('nests siblings by depth so two children of one parent do not overprint', () => {
const helper = loadLineageHelper();
const first = helper.computePath({ parent: tab(0), child: tab(400), strip: STRIP, depth: 0 })!;
const second = helper.computePath({ parent: tab(0), child: tab(400), strip: STRIP, depth: 1 })!;
expect(controlYs(second.d)[0] - controlYs(first.d)[0]).toBe(helper.SIBLING_STEP_PX);
expect(first.d).not.toBe(second.d);
});
it('switches to a vertical bezier when the strip has wrapped to two rows', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 0, top: 0, width: 1200, height: 90 };
const geom = helper.computePath({ parent: tab(0, 4), child: tab(200, 48), strip })!;
expect(geom.sameRow).toBe(false);
// Parent bottom (34) → child top (48): the arc travels between rows.
expect(geom.d.startsWith('M 60 34')).toBe(true);
expect(geom.endY).toBe(48);
});
it('draws upward when the child sits on the row ABOVE its parent', () => {
const helper = loadLineageHelper();
const strip: Rect = { left: 0, top: 0, width: 1200, height: 90 };
const geom = helper.computePath({ parent: tab(0, 48), child: tab(200, 4), strip })!;
expect(geom.sameRow).toBe(false);
expect(geom.d.startsWith('M 60 48')).toBe(true); // parent TOP edge
expect(geom.endY).toBe(34); // child bottom edge
});
it('skips an edge whose tab is scrolled out of the strip', () => {
const helper = loadLineageHelper();
// `.session-tabs` is overflow-x:auto, so a scrolled-out tab still HAS a rect —
// one lying over the logo or the header buttons. It must not be drawn to.
const strip: Rect = { left: 200, top: 0, width: 600, height: 40 };
expect(helper.computePath({ parent: tab(-300), child: tab(400), strip })).toBeNull();
expect(helper.computePath({ parent: tab(400), child: tab(1400), strip })).toBeNull();
expect(helper.computePath({ parent: tab(300), child: tab(600), strip })).not.toBeNull();
});
it('returns null for a missing or degenerate rect instead of emitting NaN', () => {
const helper = loadLineageHelper();
expect(helper.computePath({ parent: null, child: tab(0), strip: STRIP })).toBeNull();
expect(helper.computePath({ parent: tab(0), child: null, strip: STRIP })).toBeNull();
expect(
helper.computePath({ parent: { left: 0, top: 0, width: 0, height: 0 }, child: tab(0), strip: STRIP })
).toBeNull();
});
it('still draws when no strip rect is supplied (clipping is opt-in)', () => {
const helper = loadLineageHelper();
const geom = helper.computePath({ parent: tab(0), child: tab(9000) });
expect(geom).not.toBeNull();
expect(geom!.d).not.toContain('NaN');
});
});