mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +02:00
chore: version packages
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user