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:
2026-08-18 09:03:52 +00:00
co-authored by Claude Opus 5
parent 56911f6874
commit 764ba23206
6 changed files with 477 additions and 64 deletions
@@ -13,6 +13,11 @@ export {
armCommandHqWatcher,
recordCommandHqWatcherFired,
disarmCommandHqWatcher,
recordCommandHqTurn,
getCommandHqCostSummary,
listCommandHqTurns,
listCommandHqWatcherFires,
getLastFiredAtBySessionRowIds,
listCommandHqTags,
createCommandHqTag,
deleteCommandHqTag,
@@ -20,4 +25,13 @@ export {
addCommandHqSessionTag,
removeCommandHqSessionTag,
} from './queries';
export type { CommandHqCategory, CommandHqSessionRow, CommandHqTag } from './queries';
export type {
CommandHqCategory,
CommandHqSessionRow,
CommandHqTag,
CommandHqWatcherFire,
CommandHqTurnCost,
CommandHqCostSummary,
CommandHqTurnRow,
CommandHqFireRow,
} from './queries';
@@ -1,6 +1,13 @@
import { and, eq, inArray, isNull } from 'drizzle-orm';
import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '../db';
import { commandHqCategories, commandHqSessionTags, commandHqSessions, commandHqTags } from './schema';
import {
commandHqCategories,
commandHqSessionTags,
commandHqSessions,
commandHqTags,
commandHqTurns,
commandHqWatcherFires,
} from './schema';
// ── Categories ──
@@ -50,7 +57,8 @@ export type CommandHqSessionRow = {
categoryId: number | null;
watcherArmed: boolean;
watcherTaskId: string | null;
watcherLastFiredAt: Date | null;
watcherArmedAt: Date | null;
watcherStoppedAt: Date | null;
createdAt: Date;
};
@@ -61,7 +69,8 @@ const SESSION_ROW_COLUMNS = {
categoryId: commandHqSessions.categoryId,
watcherArmed: commandHqSessions.watcherArmed,
watcherTaskId: commandHqSessions.watcherTaskId,
watcherLastFiredAt: commandHqSessions.watcherLastFiredAt,
watcherArmedAt: commandHqSessions.watcherArmedAt,
watcherStoppedAt: commandHqSessions.watcherStoppedAt,
createdAt: commandHqSessions.createdAt,
};
@@ -136,46 +145,175 @@ export async function getCommandHqSession(userId: number, sessionId: string): Pr
* Arm (or re-arm) a commander's watcher — called on the first `task:started` after the dialog submits a
* `handoff` job, and again on every SUBSEQUENT `task:started` while already armed, which is how the
* AGENT's own self-re-arm (same turn, per `handoffContract`) is picked up: the platform does not drive
* the re-arm, it only notices the new task id the agent's own next watch produced. Idempotent in effect:
* arming with the same task id twice just writes the same values again.
* the re-arm, it only notices the new task id the agent's own next watch produced.
*
* `watcherArmedAt` is set ONLY on the false→true transition — a `CASE` inside the same UPDATE rather than
* a read-then-write, so there is no race between checking the current value and writing the new one. Every
* later re-arm call leaves it alone, which is what makes it "since when has the CURRENT run been going"
* rather than "since the last re-arm". `watcherStoppedAt` is unconditionally cleared, because arming from a
* previously-disarmed state means a stale stop time would otherwise sit there describing a run that is no
* longer the current one.
*/
export async function armCommandHqWatcher(userId: number, sessionId: string, taskId: string): Promise<boolean> {
const rows = await db
.update(commandHqSessions)
.set({ watcherArmed: true, watcherTaskId: taskId })
.set({
watcherArmed: true,
watcherTaskId: taskId,
watcherArmedAt: sql`CASE WHEN ${commandHqSessions.watcherArmed} = false THEN now() ELSE ${commandHqSessions.watcherArmedAt} END`,
watcherStoppedAt: null,
})
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
.returning({ id: commandHqSessions.id });
return rows.length > 0;
}
export type CommandHqWatcherFire = { id: number; firedAt: Date };
/**
* The watcher fired. Records WHEN for display — does not touch `watcherArmed` or `watcherTaskId`, which
* stay exactly as they were through the fired-then-acting window, right up until the next `armCommandHqWatcher`
* replaces the task id on re-arm.
* The watcher fired — a "trigger". Inserts a row in `command_hq_watcher_fires` rather than updating a
* scalar, so cost can later be attributed per trigger rather than only in aggregate — see
* `commandHqWatcherFires`'s own docstring. Does not touch `watcherArmed` or `watcherTaskId` on the
* commander row, which stay exactly as they were through the fired-then-acting window, right up until the
* next `armCommandHqWatcher` replaces the task id on re-arm.
*
* Takes the commander's own row id, not `(userId, sessionId)` — every caller already has the row in hand
* (it had to check `watcherTaskId` matched before deciding this WAS the tracked watcher firing), so this
* avoids resolving it a second time.
*/
export async function recordCommandHqWatcherFired(userId: number, sessionId: string): Promise<boolean> {
const rows = await db
.update(commandHqSessions)
.set({ watcherLastFiredAt: new Date() })
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
.returning({ id: commandHqSessions.id });
return rows.length > 0;
export async function recordCommandHqWatcherFired(sessionRowId: number, taskId: string): Promise<CommandHqWatcherFire> {
const [row] = await db
.insert(commandHqWatcherFires)
.values({ sessionRowId, taskId })
.returning({ id: commandHqWatcherFires.id, firedAt: commandHqWatcherFires.firedAt });
return row!;
}
/**
* Disarm a commander's watcher. No product path calls this yet — there is no UI for it — but the
* mechanism needs an off switch to exist before it needs a button, and `watcherTaskId` must be cleared
* alongside `watcherArmed`, not left pointing at a task nothing will ever re-arm from again.
* Disarm a commander's watcher. No product path calls this yet on the web side — there is no UI for it —
* but the mechanism needs an off switch to exist before it needs a button, and `watcherTaskId` must be
* cleared alongside `watcherArmed`, not left pointing at a task nothing will ever re-arm from again.
* `watcherStoppedAt` is set here and ONLY here — the other end of the pair `watcherArmedAt` began.
*/
export async function disarmCommandHqWatcher(userId: number, sessionId: string): Promise<boolean> {
const rows = await db
.update(commandHqSessions)
.set({ watcherArmed: false, watcherTaskId: null })
.set({ watcherArmed: false, watcherTaskId: null, watcherStoppedAt: new Date() })
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
.returning({ id: commandHqSessions.id });
return rows.length > 0;
}
export type CommandHqTurnCost = { inputTokens: number; outputTokens: number; totalUsd: number };
/**
* Record one completed turn's cost — called on every `result` a commander's session produces, not only
* the ones that follow a fire. The raw ledger `docs/command-hq.md` describes: running total, cost over
* time, cost per trigger and turn count are all queries over this table, nothing pre-summed here.
*
* `fireId` is resolved to whichever trigger is CURRENTLY open for this commander — the most recent fire
* row, if any — inside the same statement, so a turn during the setup conversation (before any fire has
* ever happened) correctly gets `fireId: null` rather than a query needing to special-case it.
*/
export async function recordCommandHqTurn(sessionRowId: number, cost: CommandHqTurnCost): Promise<void> {
// Two statements rather than one INSERT ... SELECT: a fire landing in the gap between them would
// misattribute at most one turn to the setup conversation instead of the trigger that just started,
// which is a smaller and more honest error than the complexity of a correlated subquery buys back —
// fires happen far less often than turns complete, so the gap is rarely even open.
const [currentFire] = await db
.select({ id: commandHqWatcherFires.id })
.from(commandHqWatcherFires)
.where(eq(commandHqWatcherFires.sessionRowId, sessionRowId))
.orderBy(desc(commandHqWatcherFires.firedAt))
.limit(1);
await db.insert(commandHqTurns).values({
sessionRowId,
fireId: currentFire?.id ?? null,
inputTokens: cost.inputTokens,
outputTokens: cost.outputTokens,
totalUsd: cost.totalUsd,
});
}
export type CommandHqCostSummary = {
turnCount: number;
inputTokens: number;
outputTokens: number;
totalUsd: number;
};
/** The running total for a commander — every turn it has ever cost, summed. Zeroes, never null, for a commander with no turns recorded yet. */
export async function getCommandHqCostSummary(sessionRowId: number): Promise<CommandHqCostSummary> {
const [row] = await db
.select({
turnCount: sql<number>`count(*)::int`,
inputTokens: sql<number>`coalesce(sum(${commandHqTurns.inputTokens}), 0)::int`,
outputTokens: sql<number>`coalesce(sum(${commandHqTurns.outputTokens}), 0)::int`,
totalUsd: sql<number>`coalesce(sum(${commandHqTurns.totalUsd}), 0)::float8`,
})
.from(commandHqTurns)
.where(eq(commandHqTurns.sessionRowId, sessionRowId));
return row ?? { turnCount: 0, inputTokens: 0, outputTokens: 0, totalUsd: 0 };
}
export type CommandHqTurnRow = {
id: number;
fireId: number | null;
inputTokens: number;
outputTokens: number;
totalUsd: number;
createdAt: Date;
};
/** Every turn's cost for a commander, oldest first — the raw series a client plots cost-over-time from. */
export async function listCommandHqTurns(sessionRowId: number): Promise<CommandHqTurnRow[]> {
return db
.select({
id: commandHqTurns.id,
fireId: commandHqTurns.fireId,
inputTokens: commandHqTurns.inputTokens,
outputTokens: commandHqTurns.outputTokens,
totalUsd: commandHqTurns.totalUsd,
createdAt: commandHqTurns.createdAt,
})
.from(commandHqTurns)
.where(eq(commandHqTurns.sessionRowId, sessionRowId))
.orderBy(commandHqTurns.createdAt);
}
export type CommandHqFireRow = { id: number; taskId: string; firedAt: Date };
/** Every trigger for a commander, oldest first. */
export async function listCommandHqWatcherFires(sessionRowId: number): Promise<CommandHqFireRow[]> {
return db
.select({
id: commandHqWatcherFires.id,
taskId: commandHqWatcherFires.taskId,
firedAt: commandHqWatcherFires.firedAt,
})
.from(commandHqWatcherFires)
.where(eq(commandHqWatcherFires.sessionRowId, sessionRowId))
.orderBy(commandHqWatcherFires.firedAt);
}
/**
* The most recent fire for each of the given commanders, batched — one query for a whole grid rather than
* one per card, the same shape `listCommandHqSessionTags` already uses. "Last fired" has no column of its
* own (dropped deliberately — see the schema file); this is where the grid gets it instead.
*/
export async function getLastFiredAtBySessionRowIds(sessionRowIds: number[]): Promise<Map<number, Date>> {
if (!sessionRowIds.length) return new Map();
const rows = await db
.select({
sessionRowId: commandHqWatcherFires.sessionRowId,
lastFiredAt: sql<Date>`max(${commandHqWatcherFires.firedAt})`,
})
.from(commandHqWatcherFires)
.where(inArray(commandHqWatcherFires.sessionRowId, sessionRowIds))
.groupBy(commandHqWatcherFires.sessionRowId);
return new Map(rows.map((r) => [r.sessionRowId, r.lastFiredAt]));
}
/** Move a commander to a category, or `null` to uncategorise it explicitly (distinct from never having had one). */
export async function setCommandHqSessionCategory(
userId: number,
@@ -1,4 +1,14 @@
import { boolean, integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import {
boolean,
doublePrecision,
index,
integer,
pgTable,
serial,
text,
timestamp,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
/**
@@ -88,13 +98,76 @@ export const commandHqSessions = pgTable(
* exactly the window a card would show as "armed, acting on what just fired" rather than "watching".
*/
watcherTaskId: text('watcher_task_id'),
/** When the watcher last fired. Display only — "Armed, last fired 3h ago". Never read for logic. */
watcherLastFiredAt: timestamp('watcher_last_fired_at', { withTimezone: true }),
/**
* When the CURRENT (or most recent) run started — set the moment `watcherArmed` first flips false→true,
* left untouched by every later self-re-arm, so a watcher that has been cycling for weeks still reports
* its true start rather than its last re-arm. Answers "how long has this been running" while armed, and
* "how long did that run last" (paired with `watcherStoppedAt`) once it isn't. Not a full arm/disarm
* history — only the boundaries of the current or most recent run, which is what a card needs; a table
* of every transition was rejected as more than this needs, the same call `watcherLastFiredAt` lost for
* the opposite reason (not enough — see `commandHqWatcherFires` above).
*/
watcherArmedAt: timestamp('watcher_armed_at', { withTimezone: true }),
/**
* When the current run ENDED — set on disarm, cleared back to null on the next arm. Paired with
* `watcherArmedAt` so a duration is computable even after the watcher stops: while armed this is null
* (`watcherArmedAt` to now); once disarmed both are set and the gap between them is how long that run
* lasted. Both null means never armed at all.
*/
watcherStoppedAt: timestamp('watcher_stopped_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [uniqueIndex('uq_command_hq_sessions_user_session').on(table.userId, table.sessionId)],
);
/**
* One row per time a commander's watcher fired — a "trigger". Nothing but the raw fact and when it
* happened: no cached "last fired" lives on `commandHqSessions` — that is `MAX(firedAt)` for the commander,
* computed at read time, the same "derive it, do not store a copy" rule `running` above already follows.
*
* This is what makes "cost per trigger" answerable at all. Before this table existed, a fire only ever
* updated a single timestamp column, which meant the PREVIOUS fire's time was gone the moment a new one
* landed — fine for "when did it last fire", useless for "how many times, and what did each one cost".
*/
export const commandHqWatcherFires = pgTable(
'command_hq_watcher_fires',
{
id: serial('id').primaryKey(),
sessionRowId: integer('session_row_id')
.notNull()
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
/** Which watcher task fired — `commandHqSessions.watcherTaskId` at the moment this row was written. */
taskId: text('task_id').notNull(),
firedAt: timestamp('fired_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_command_hq_watcher_fires_session').on(table.sessionRowId, table.firedAt)],
);
/**
* One row per completed turn — every `result` message a commander's session produces, cost attached.
* The raw ledger: running total, cost over time, cost per trigger and turn count are all queries over
* this table, nothing pre-summed or cached elsewhere. `totalUsd` is a plain float (`doublePrecision`),
* matching `MessageCost.totalUSD` as the harness itself reports it — this is a monitoring number, not a
* billing ledger, and the accumulated float error over a realistic turn count is far below what the
* harness's own cost estimate is accurate to in the first place.
*/
export const commandHqTurns = pgTable(
'command_hq_turns',
{
id: serial('id').primaryKey(),
sessionRowId: integer('session_row_id')
.notNull()
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
/** Which trigger this turn belongs to. Null for the setup conversation before the first-ever arm. */
fireId: integer('fire_id').references(() => commandHqWatcherFires.id, { onDelete: 'set null' }),
inputTokens: integer('input_tokens').notNull(),
outputTokens: integer('output_tokens').notNull(),
totalUsd: doublePrecision('total_usd').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index('idx_command_hq_turns_session').on(table.sessionRowId, table.createdAt)],
);
/** A user-created label. Many-to-many with commanders, unlike categories — schema only for v1; no UI yet. */
export const commandHqTags = pgTable(
'command_hq_tags',
+39 -1
View File
@@ -33,6 +33,7 @@ import {
getCommandHqSession,
armCommandHqWatcher,
recordCommandHqWatcherFired,
recordCommandHqTurn,
} from 'officerdb';
import { resolveHomeDir } from '@@/user-home';
import { claudeLoginState } from '@@/os-user-claude';
@@ -570,8 +571,12 @@ function createMessageHandler(
const taskId = msg.taskId;
void getCommandHqSession(userId, capturedTranscriptId)
.then((row) => {
// `row.id` — the commander's OWN row id, not `userId` — is the FK `command_hq_watcher_fires`
// actually takes. Passing `userId`/`transcriptId` here type-checks by coincidence (number,
// string) while writing a fire row for the wrong commander entirely; caught by tracing the
// call, not by the compiler.
if (row?.watcherArmed && row.watcherTaskId === taskId) {
return recordCommandHqWatcherFired(userId, capturedTranscriptId);
return recordCommandHqWatcherFired(row.id, taskId);
}
})
.catch((err: unknown) => {
@@ -584,6 +589,39 @@ function createMessageHandler(
}
}
/*
One turn's cost, recorded whenever it ends — not only on a firing's acting turn. The setup
conversation before a watcher is ever armed costs something too, and `fireId` is null for exactly
those turns (`recordCommandHqTurn` resolves it, not this call site). This is the raw ledger
`docs/command-hq.md` describes: running total, cost over time, cost per trigger and turn count are
all queries over it, nothing summed here.
*/
if (msg.type === 'result' && commandHq && transcriptId) {
const userId = session.userId;
if (userId) {
const capturedTranscriptId = transcriptId;
// Named explicitly, not spread — `MessageCost.totalUSD` and `CommandHqTurnCost.totalUsd` differ
// only in casing, which a spread would carry both keys of and TypeScript would wave through
// (excess-property checking does not catch a spread the way it catches a literal).
const cost = {
inputTokens: msg.cost.inputTokens,
outputTokens: msg.cost.outputTokens,
totalUsd: msg.cost.totalUSD,
};
void getCommandHqSession(userId, capturedTranscriptId)
.then((row) => {
if (row) return recordCommandHqTurn(row.id, cost);
})
.catch((err: unknown) => {
logger.warn('Could not record commander turn cost', {
sessionId,
transcriptId: capturedTranscriptId,
error: String(err),
});
});
}
}
/*
A turn ended — take a usage snapshot while the person is reading the answer.
+70 -7
View File
@@ -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 }
*