command hq: per-turn cost ledger, watcher fires as their own table
The owner asked for individual turn token/dollar costs, a running total, cost over time, and cost per trigger — derived, not stored, following the same rule `running` already uses. Two new tables carry it: - command_hq_watcher_fires: one row per trigger (sessionRowId, taskId, firedAt). Replaces the single watcherLastFiredAt column, which lost the previous fire's timestamp every time a new one landed — fine for "when did it last fire", useless for "how many times, and what did each one cost". - command_hq_turns: one row per completed turn (sessionRowId, fireId nullable, inputTokens, outputTokens, totalUsd). Nothing pre-summed — running total, cost-over-time and cost-per-trigger are all queries over this table. Turn count needed no column either: COUNT(*) already answers it, confirmed explicitly with the owner rather than assumed. command_hq_sessions gains watcherArmedAt/watcherStoppedAt in place of any "last fired" cache — set only on the false<->true transitions, so duration (current run, or the last completed one) is computable without a full transition history table. Two new read routes, GET /commanders/:sessionId/turns and .../fires, expose the raw series behind the cost summary and watcherLastFiredAt (now computed via MAX(fired_at), not read off a column — the field survives in the JSON response under the same name). Bug found and fixed while wiring this up, recorded in docs/command-hq.md: recordCommandHqWatcherFired's signature changed from (userId, sessionId) to (sessionRowId, taskId) and one call site kept the old argument order — tsgo stayed clean because number/string still matched structurally. Caught by re-reading every call site by hand, not by any tool. Needs bun db:push before any route answers with real data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,12 +25,17 @@ import {
|
||||
setCommandHqSessionCategory,
|
||||
purgeCommandHqSessions,
|
||||
disarmCommandHqWatcher,
|
||||
getCommandHqCostSummary,
|
||||
listCommandHqTurns,
|
||||
listCommandHqWatcherFires,
|
||||
getLastFiredAtBySessionRowIds,
|
||||
listCommandHqSessionTags,
|
||||
listCommandHqTags,
|
||||
createCommandHqTag,
|
||||
deleteCommandHqTag,
|
||||
addCommandHqSessionTag,
|
||||
removeCommandHqSessionTag,
|
||||
type CommandHqCostSummary,
|
||||
} from 'officerdb';
|
||||
|
||||
/**
|
||||
@@ -155,7 +160,14 @@ type CommanderSummary = ClaudeSessionSummary & {
|
||||
* no display meaning, the join key `armCommandHqWatcher` uses and nothing a card needs to show.
|
||||
*/
|
||||
watcherArmed: boolean;
|
||||
/** Since when the CURRENT run has been going, or null if never armed. Untouched by every self-re-arm. */
|
||||
watcherArmedAt: Date | null;
|
||||
/** When the current run ended, or null while still armed (or never armed). Paired with `watcherArmedAt`. */
|
||||
watcherStoppedAt: Date | null;
|
||||
/** `MAX(firedAt)` for this commander — no column of its own, see `docs/command-hq.md` → "The watcher loop". */
|
||||
watcherLastFiredAt: Date | null;
|
||||
/** The running total — every turn this commander has ever cost, summed. See `GET .../turns` for the raw series. */
|
||||
cost: CommandHqCostSummary;
|
||||
};
|
||||
|
||||
async function isRunning(sessionId: string): Promise<boolean> {
|
||||
@@ -187,10 +199,19 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
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 [tagsByRow, lastFiredByRow, runningById, costByRow] = await Promise.all([
|
||||
listCommandHqSessionTags(filed.map((row) => row.id)),
|
||||
getLastFiredAtBySessionRowIds(filed.map((row) => row.id)),
|
||||
Promise.all(filed.map(async (row) => [row.sessionId, await isRunning(row.sessionId)] as const)).then(
|
||||
(pairs) => new Map(pairs),
|
||||
),
|
||||
// One aggregate query per commander, the same cost class `isRunning` above already accepts for this
|
||||
// grid — not batched, because a running SUM/COUNT is not a lookup on a small shared table the way tags
|
||||
// and last-fired are.
|
||||
Promise.all(filed.map(async (row) => [row.id, await getCommandHqCostSummary(row.id)] as const)).then(
|
||||
(pairs) => new Map(pairs),
|
||||
),
|
||||
]);
|
||||
|
||||
const commanders: CommanderSummary[] = filed
|
||||
.map((row) => ({
|
||||
@@ -199,7 +220,10 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running: runningById.get(row.sessionId) ?? false,
|
||||
watcherArmed: row.watcherArmed,
|
||||
watcherLastFiredAt: row.watcherLastFiredAt,
|
||||
watcherArmedAt: row.watcherArmedAt,
|
||||
watcherStoppedAt: row.watcherStoppedAt,
|
||||
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
|
||||
cost: costByRow.get(row.id) ?? { turnCount: 0, inputTokens: 0, outputTokens: 0, totalUsd: 0 },
|
||||
}))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
|
||||
@@ -304,18 +328,57 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
|
||||
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)]);
|
||||
const [tagsByRow, running, lastFiredByRow, cost] = await Promise.all([
|
||||
listCommandHqSessionTags([row.id]),
|
||||
isRunning(sessionId),
|
||||
getLastFiredAtBySessionRowIds([row.id]),
|
||||
getCommandHqCostSummary(row.id),
|
||||
]);
|
||||
return ctx.json({
|
||||
sessionId: row.sessionId,
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running,
|
||||
watcherArmed: row.watcherArmed,
|
||||
watcherLastFiredAt: row.watcherLastFiredAt,
|
||||
watcherArmedAt: row.watcherArmedAt,
|
||||
watcherStoppedAt: row.watcherStoppedAt,
|
||||
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
|
||||
cost,
|
||||
createdAt: row.createdAt,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /command-hq/commanders/:sessionId/turns
|
||||
*
|
||||
* The raw per-turn cost ledger, oldest first — what a client plots "cost over time" from, and what
|
||||
* `cost` on the detail route above is the sum of. Not included inline on that route because it is
|
||||
* unbounded (a commander running for months accumulates one row per turn), so a client that only wants
|
||||
* the running total is not paying for the whole history on every open.
|
||||
*/
|
||||
commandHqRouter.get('/commanders/:sessionId/turns', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
return ctx.json({ turns: await listCommandHqTurns(row.id) });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /command-hq/commanders/:sessionId/fires
|
||||
*
|
||||
* Every trigger this commander's watcher has ever fired, oldest first — the boundaries a client uses to
|
||||
* group `.../turns` by trigger, and the full history behind the single `watcherLastFiredAt` the other
|
||||
* routes report.
|
||||
*/
|
||||
commandHqRouter.get('/commanders/:sessionId/fires', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
return ctx.json({ fires: await listCommandHqWatcherFires(row.id) });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /command-hq/commanders { sessionId: string, categoryId?: number }
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user