diff --git a/src/databases/officer_db/src/command-hq/queries.ts b/src/databases/officer_db/src/command-hq/queries.ts index 39e5903f..1bfec196 100644 --- a/src/databases/officer_db/src/command-hq/queries.ts +++ b/src/databases/officer_db/src/command-hq/queries.ts @@ -55,6 +55,8 @@ export async function deleteCommandHqCategory(userId: number, id: number): Promi export type CommandHqSessionRow = { id: number; sessionId: string; + /** What the owner called it. Null for rows filed before the column existed — fall back to the transcript. */ + name: string | null; categoryId: number | null; watcherArmed: boolean; watcherTaskId: string | null; @@ -67,6 +69,7 @@ export type CommandHqSessionRow = { const SESSION_ROW_COLUMNS = { id: commandHqSessions.id, sessionId: commandHqSessions.sessionId, + name: commandHqSessions.name, categoryId: commandHqSessions.categoryId, watcherArmed: commandHqSessions.watcherArmed, watcherTaskId: commandHqSessions.watcherTaskId, @@ -84,10 +87,11 @@ export async function createCommandHqSession( userId: number, sessionId: string, categoryId: number | null = null, + name: string | null = null, ): Promise { const [row] = await db .insert(commandHqSessions) - .values({ userId, sessionId, categoryId }) + .values({ userId, sessionId, categoryId, name }) .onConflictDoNothing({ target: [commandHqSessions.userId, commandHqSessions.sessionId] }) .returning(SESSION_ROW_COLUMNS); // `onConflictDoNothing` returns nothing on the conflict path, so the existing row is fetched explicitly — diff --git a/src/databases/officer_db/src/command-hq/schema.ts b/src/databases/officer_db/src/command-hq/schema.ts index d74a138a..a44e2805 100644 --- a/src/databases/officer_db/src/command-hq/schema.ts +++ b/src/databases/officer_db/src/command-hq/schema.ts @@ -86,6 +86,18 @@ export const commandHqSessions = pgTable( * id spaces both agree on only sometimes. */ sessionId: text('session_id').notNull(), + /** + * What the owner called it, held here rather than only in the transcript's summary record. + * + * The summary is the harness's, and it can only be written once the transcript file exists — which is + * after the first turn. So a list reading the transcript showed the opening brief until that turn ended, + * while the header beside it showed the name from local state. Two names for one commander, and every + * fix for it was a cache-invalidation trick timed against somebody else's write. + * + * The row is written when the commander is filed, before any of that. Nullable for the rows that predate + * this, which fall back to the transcript. + */ + name: text('name'), /** Nullable — a commander may be uncategorised. `onDelete: 'set null'`: deleting a category uncategorises its commanders rather than deleting them. */ categoryId: integer('category_id').references(() => commandHqCategories.id, { onDelete: 'set null' }), /** Should the platform keep re-arming this commander's watcher on every fire? See the file docstring. */ diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index f23b7424..326367b7 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -474,7 +474,10 @@ function createMessageHandler( const userId = sessionManager.getSession(sessionId)?.userId; if (!userId) return; const filedId = transcriptId; - void createCommandHqSession(userId, filedId) + // The name goes in with the row, not only to the transcript: the list reads it from here and is + // right immediately, rather than waiting on a summary record the harness cannot write until the + // first turn has finished. + void createCommandHqSession(userId, filedId, null, commanderName?.trim() || null) .then(async () => { /* The chosen name, applied once — and RETRIED, because the first attempt usually fails. diff --git a/src/servers/api/command-hq/router.ts b/src/servers/api/command-hq/router.ts index 58641d31..f4d1456a 100644 --- a/src/servers/api/command-hq/router.ts +++ b/src/servers/api/command-hq/router.ts @@ -248,6 +248,10 @@ commandHqRouter.get('/commanders', async (ctx) => { const commanders: CommanderSummary[] = filed .map((row) => ({ ...liveById.get(row.sessionId)!, + // The row's name wins over the transcript's. It is written when the commander is filed, while the + // summary record cannot exist until the first turn ends — so until then the transcript still reports + // the opening brief, and the row is the only source that is right from the start. + ...(row.name ? { title: row.name } : {}), categoryId: row.categoryId, tagIds: tagsByRow.get(row.id) ?? [], running: runningById.get(row.sessionId) ?? false, @@ -393,6 +397,7 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => { ]); return ctx.json({ sessionId: row.sessionId, + name: row.name, categoryId: row.categoryId, tagIds: tagsByRow.get(row.id) ?? [], running,