command hq: the api, mounted and documented
full REST surface for categories, tags and commanders. core permission (personal: ['/'], no grant to hold — every query is scoped to the caller's own user_id at the sql layer regardless). registered in the permission registry and PROTECTED_MOUNTS together, so totality holds by construction. GET /commanders is the grid endpoint: everything /chat/sessions already returns per session, plus categoryId/tagIds/running. `running` is derived live off collectRunningTasks (now exported from websocket.ts) folded over chat_session_events — not a stored column, on purpose, matching the design doc's stance that a status field would go stale exactly when a sidecar restart silently kills a watcher. GET /commanders/:sessionId deliberately does NOT duplicate the transcript — that stays GET /chat/sessions/:id, unchanged. a client calls both. /chat/sessions now excludes anything filed as a commander (listCommandHqSessionIds, .catch()'d so a hiccup on this feature can't take the whole chat list down) — the other half of the one predicate this whole design turns on. there is deliberately no POST /commanders/new: creation is an ordinary chat turn in the commanders directory, which is the next commit. docs/command-hq.md gains an API reference table for the mobile dev, and a status line at the top saying plainly what is real and what is not yet: needs `bun db:push` before any route returns real data, frontend not started. bunx tsgo clean. suite at baseline (same 7 pre-existing failures, all registry/totality drift and the pty socket test — none touch this router). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
import type { Context } from 'hono';
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { getCommandersDir } from '@@/data-path';
|
||||
import { getChatEventsSince } from 'officerdb';
|
||||
import type { ServerMessage } from '../chat/types';
|
||||
import {
|
||||
listClaudeSessions,
|
||||
loadClaudeSessionById,
|
||||
renameClaudeSession,
|
||||
type ChatIdentity,
|
||||
type ClaudeSessionSummary,
|
||||
} from '../chat/claude-sessions';
|
||||
import { collectRunningTasks } from '../chat/websocket';
|
||||
import {
|
||||
listCommandHqCategories,
|
||||
createCommandHqCategory,
|
||||
renameCommandHqCategory,
|
||||
deleteCommandHqCategory,
|
||||
createCommandHqSession,
|
||||
listCommandHqSessions,
|
||||
getCommandHqSession,
|
||||
setCommandHqSessionCategory,
|
||||
purgeCommandHqSessions,
|
||||
listCommandHqSessionTags,
|
||||
listCommandHqTags,
|
||||
createCommandHqTag,
|
||||
deleteCommandHqTag,
|
||||
addCommandHqSessionTag,
|
||||
removeCommandHqSessionTag,
|
||||
} from 'officerdb';
|
||||
|
||||
/**
|
||||
* Command HQ — commanders (long-running background jobs the owner starts, names and organises separately
|
||||
* from `/chat`). Design record: `docs/command-hq.md`. That file is the "why"; this file is the "what",
|
||||
* kept deliberately explicit in its doc comments because it is the contract a mobile client builds against.
|
||||
*
|
||||
* ── Identity ──
|
||||
*
|
||||
* Every route resolves `commandHqIdentity`, the same shape `/chat` resolves as `chatIdentity` (duplicated
|
||||
* rather than imported — that function is private to `chat.ts` and this is five lines, not worth coupling
|
||||
* two routers over). `core` permission, no grant to hold: every account reaches this router, and every
|
||||
* query is additionally scoped to the caller's own `user_id` regardless.
|
||||
*
|
||||
* ── Where commanders live ──
|
||||
*
|
||||
* Every commander shares ONE working directory per user — `getCommandersDir(email)` — never one per
|
||||
* commander. That means listing a user's commanders is exactly `listClaudeSessions` (the same function
|
||||
* `/chat` uses for its own per-directory list) pointed at that one directory, filtered to sessions with a
|
||||
* `command_hq_sessions` row. No new session-scanning code exists here; this router is a client of the same
|
||||
* transcript reader `/chat` already is.
|
||||
*/
|
||||
|
||||
async function commandHqIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
|
||||
const resolved = await resolveHomeDir(user.id);
|
||||
if (!resolved.ok) throw errors.FORBIDDEN(resolved.reason);
|
||||
return { email: user.email, home: resolved.home, osUser: resolved.osUser, isOwner: resolved.isOwner };
|
||||
}
|
||||
|
||||
export const commandHqRouter = createRouter();
|
||||
|
||||
// ── Categories ──
|
||||
|
||||
// GET /command-hq/categories
|
||||
commandHqRouter.get('/categories', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
return ctx.json({ categories: await listCommandHqCategories(user.id) });
|
||||
});
|
||||
|
||||
// POST /command-hq/categories { name: string }
|
||||
commandHqRouter.post('/categories', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = await ctx.req.json<{ name?: string }>().catch(() => ({}) as { name?: string });
|
||||
const name = body.name?.trim();
|
||||
if (!name) throw errors.BAD_REQUEST('name is required');
|
||||
try {
|
||||
return ctx.json(await createCommandHqCategory(user.id, name));
|
||||
} catch {
|
||||
// The unique index (`uq_command_hq_categories_user_name`) is the real guard; a Postgres constraint
|
||||
// violation is the only way this throws, so it is read back as the one thing it can mean.
|
||||
throw errors.CONFLICT('a category with this name already exists');
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /command-hq/categories/:id { name: string }
|
||||
commandHqRouter.patch('/categories/:id', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('id'));
|
||||
const body = await ctx.req.json<{ name?: string }>().catch(() => ({}) as { name?: string });
|
||||
const name = body.name?.trim();
|
||||
if (!name) throw errors.BAD_REQUEST('name is required');
|
||||
const ok = await renameCommandHqCategory(user.id, id, name);
|
||||
if (!ok) throw errors.NOT_FOUND('no such category');
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE /command-hq/categories/:id — uncategorises its commanders (onDelete: 'set null'), does not delete them.
|
||||
commandHqRouter.delete('/categories/:id', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('id'));
|
||||
const ok = await deleteCommandHqCategory(user.id, id);
|
||||
if (!ok) throw errors.NOT_FOUND('no such category');
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Tags (schema only — no UI planned yet, but the endpoints are real) ──
|
||||
|
||||
// GET /command-hq/tags
|
||||
commandHqRouter.get('/tags', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
return ctx.json({ tags: await listCommandHqTags(user.id) });
|
||||
});
|
||||
|
||||
// POST /command-hq/tags { name: string }
|
||||
commandHqRouter.post('/tags', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = await ctx.req.json<{ name?: string }>().catch(() => ({}) as { name?: string });
|
||||
const name = body.name?.trim();
|
||||
if (!name) throw errors.BAD_REQUEST('name is required');
|
||||
try {
|
||||
return ctx.json(await createCommandHqTag(user.id, name));
|
||||
} catch {
|
||||
throw errors.CONFLICT('a tag with this name already exists');
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /command-hq/tags/:id
|
||||
commandHqRouter.delete('/tags/:id', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('id'));
|
||||
const ok = await deleteCommandHqTag(user.id, id);
|
||||
if (!ok) throw errors.NOT_FOUND('no such tag');
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Commanders ──
|
||||
|
||||
/** A grid card's worth of information — the transcript summary plus what Command HQ knows about it. */
|
||||
type CommanderSummary = ClaudeSessionSummary & {
|
||||
categoryId: number | null;
|
||||
tagIds: number[];
|
||||
/**
|
||||
* Derived from `chat_session_events` at request time — `task:started` minus `task:notification` — the
|
||||
* same fold `/chat`'s own background-job tray uses (`collectRunningTasks`). NOT a stored value: see
|
||||
* `docs/command-hq.md` for why a status column would go stale exactly when this feature matters most.
|
||||
*/
|
||||
running: boolean;
|
||||
};
|
||||
|
||||
async function isRunning(sessionId: string): Promise<boolean> {
|
||||
const events = await getChatEventsSince(sessionId, 0);
|
||||
return collectRunningTasks(events.map((e) => e.event as ServerMessage)).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /command-hq/commanders[?category=<id>|none]
|
||||
*
|
||||
* `category` omitted → every commander, categorised or not. `category=none` → only the uncategorised
|
||||
* bucket. `category=<id>` → one category.
|
||||
*
|
||||
* A row whose transcript no longer resolves is silently dropped, never rendered as a tombstone — the
|
||||
* design's explicit stance (see "The /chat ↔ /command-hq relationship" in the design doc). Response order
|
||||
* is newest-updated first, matching `/chat`'s own list.
|
||||
*/
|
||||
commandHqRouter.get('/commanders', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const categoryParam = ctx.req.query('category');
|
||||
const categoryId = categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
|
||||
|
||||
const [rows, live] = await Promise.all([
|
||||
listCommandHqSessions(user.id, categoryId),
|
||||
listClaudeSessions(who, getCommandersDir(who.email)),
|
||||
]);
|
||||
|
||||
const liveById = new Map(live.map((s) => [s.id, s]));
|
||||
const filed = rows.filter((row) => liveById.has(row.sessionId));
|
||||
|
||||
const tagsByRow = await listCommandHqSessionTags(filed.map((row) => row.id));
|
||||
const runningById = new Map(
|
||||
await Promise.all(filed.map(async (row) => [row.sessionId, await isRunning(row.sessionId)] as const)),
|
||||
);
|
||||
|
||||
const commanders: CommanderSummary[] = filed
|
||||
.map((row) => ({
|
||||
...liveById.get(row.sessionId)!,
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running: runningById.get(row.sessionId) ?? false,
|
||||
}))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
|
||||
return ctx.json({ commanders });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /command-hq/commanders/:sessionId
|
||||
*
|
||||
* Command HQ's OWN metadata for one commander — category, tags, liveness. Deliberately does NOT return the
|
||||
* conversation itself: the transcript is `GET /chat/sessions/:id`, unchanged, and a client fetches both
|
||||
* rather than this route duplicating it. 404 both when the session was never filed and when its transcript
|
||||
* has since vanished — from the caller's side those are the same fact, "not a commander right now".
|
||||
*/
|
||||
commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
if (!loadClaudeSessionById(who, sessionId)) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
const [tagsByRow, running] = await Promise.all([listCommandHqSessionTags([row.id]), isRunning(sessionId)]);
|
||||
return ctx.json({
|
||||
sessionId: row.sessionId,
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running,
|
||||
createdAt: row.createdAt,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /command-hq/commanders { sessionId: string, categoryId?: number }
|
||||
*
|
||||
* Files an EXISTING session as a commander. Idempotent — filing an already-filed session is a no-op, and
|
||||
* does not move it to a different category (use PATCH for that).
|
||||
*
|
||||
* This is a low-level primitive, not the normal creation path. The normal path is starting a new commander
|
||||
* through Command HQ's own composer, which writes this row itself the moment the harness reports a real
|
||||
* transcript id — see `docs/command-hq.md` → "How a commander is created". This route exists for that
|
||||
* wiring to call, and for anything else (a purge-and-refile flow, a script) that needs to file a session
|
||||
* without going through a live chat turn.
|
||||
*
|
||||
* The session must actually exist in the commanders directory, or this refuses — filing a `/chat` session
|
||||
* makes no sense given commanders and `/chat` never share a working directory.
|
||||
*/
|
||||
commandHqRouter.post('/commanders', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const body = await ctx.req
|
||||
.json<{ sessionId?: string; categoryId?: number }>()
|
||||
.catch(() => ({}) as { sessionId?: string; categoryId?: number });
|
||||
const sessionId = body.sessionId?.trim();
|
||||
if (!sessionId) throw errors.BAD_REQUEST('sessionId is required');
|
||||
|
||||
const live = listClaudeSessions(who, getCommandersDir(who.email));
|
||||
if (!live.some((s) => s.id === sessionId)) throw errors.NOT_FOUND('no such session in the commanders directory');
|
||||
|
||||
const row = await createCommandHqSession(user.id, sessionId, body.categoryId ?? null);
|
||||
return ctx.json(row);
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /command-hq/commanders/:sessionId
|
||||
*
|
||||
* Body may carry either or both:
|
||||
* `categoryId: number | null` — move to a category, or `null` to uncategorise
|
||||
* `name: string` — rename. Proxies straight to `renameClaudeSession`, the SAME function `/chat`'s own
|
||||
* rename action calls — there is no separate name field on this table to disagree with it.
|
||||
*/
|
||||
commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
const body = await ctx.req
|
||||
.json<{ categoryId?: number | null; name?: string }>()
|
||||
.catch(() => ({}) as { categoryId?: number | null; name?: string });
|
||||
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
if (body.categoryId !== undefined) {
|
||||
await setCommandHqSessionCategory(user.id, sessionId, body.categoryId);
|
||||
}
|
||||
if (body.name !== undefined) {
|
||||
const ok = renameClaudeSession(who, getCommandersDir(who.email), sessionId, body.name.trim());
|
||||
if (!ok) throw errors.NOT_FOUND('transcript not found');
|
||||
}
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /command-hq/purge
|
||||
*
|
||||
* Deletes every `command_hq_sessions` row whose transcript no longer resolves in the commanders directory.
|
||||
* Housekeeping only — nothing about correctness ever depended on this running, since a dangling row is
|
||||
* already invisible everywhere. Returns how many rows were removed.
|
||||
*/
|
||||
commandHqRouter.post('/purge', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const live = listClaudeSessions(who, getCommandersDir(who.email));
|
||||
const removed = await purgeCommandHqSessions(user.id, new Set(live.map((s) => s.id)));
|
||||
return ctx.json({ removed });
|
||||
});
|
||||
|
||||
// ── Tag assignment ──
|
||||
|
||||
// POST /command-hq/commanders/:sessionId/tags { tagId: number }
|
||||
commandHqRouter.post('/commanders/:sessionId/tags', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
const body = await ctx.req.json<{ tagId?: number }>().catch(() => ({}) as { tagId?: number });
|
||||
if (!body.tagId) throw errors.BAD_REQUEST('tagId is required');
|
||||
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
await addCommandHqSessionTag(row.id, body.tagId);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE /command-hq/commanders/:sessionId/tags/:tagId
|
||||
commandHqRouter.delete('/commanders/:sessionId/tags/:tagId', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
const tagId = Number(ctx.req.param('tagId'));
|
||||
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
await removeCommandHqSessionTag(row.id, tagId);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
Reference in New Issue
Block a user