Observability contracts: usage accounting, plan blocks, price table (Phase 0)
The 2026-09 observability wave's type-level foundation, landed before the streams so nothing renders against guessed shapes: - contracts/chat.ts: CallUsage + TurnUsage (per-model-call token accounting, one entry per teacher/wake turn keyed by messageId) and ChatRecord.turns — a DELIBERATELY parallel append-only usage log, not per-message metadata: the client re-POSTs its message list every turn and its copies never carry server-stamped fields, so anything inside messages gets clobbered by the next persist (the wave-3 merge-rule lesson). - contracts/chat.ts: PlanBlockData — the persisted data-plan-block part (full DAG on plan_init, one-level neighborhood on plan updates, snapshot semantics so history renders what was true then). - contracts/jobs.ts: JobUsage + JobRecord.usage — a sub-agent run's accumulated accounting, written by the runner. - core/ports.ts + store/chats.ts: ChatStore.appendTurnUsage — idempotent per messageId (abort replays / wake retries append once); listChats no longer leaks turns into the list payload. - core/prices.ts: PRICES + CONTEXT_LIMITS (generated from OpenRouter's published pricing via scripts/sync-pricing.ts) + pure folds: callCost, turnCost, sessionCost, contextWindow, jobCost. Unknown model or absent fields render as absent, never zero. - server/observability.ts: barrel-approved surface for the transport layer (fixes the import-law violation in the graph status route that slipped through the wave-3 merge verification). Capture wiring (agent onStepFinish -> persist seam, plan-block emission) lands next as master seam work; the streams start against these shapes.
This commit is contained in:
+17
-2
@@ -11,7 +11,7 @@ import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { PATHS } from "../../config.ts";
|
||||
import type { ChatStore } from "../core/ports.ts";
|
||||
import type { ChatRecord, ChatSummary, PlanTree } from "../core/contracts.ts";
|
||||
import type { ChatRecord, ChatSummary, PlanTree, TurnUsage } from "../core/contracts.ts";
|
||||
import type { UIMessage } from "ai";
|
||||
import { findPendingToolCall } from "../core/pending.ts";
|
||||
import { phaseOfTree } from "../core/phases.ts";
|
||||
@@ -51,7 +51,9 @@ function listChats(): ChatSummary[] {
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => getChat(f.slice(0, -5)))
|
||||
.filter((r): r is ChatRecord => r !== null)
|
||||
.map(({ messages, tree, ...summary }) => ({
|
||||
.map(({ messages, tree, turns, ...summary }) => ({
|
||||
// turns stay out of the list payload (list = summaries; usage
|
||||
// aggregates ride the full record, the picker never needs them).
|
||||
...summary,
|
||||
messageCount: messages.length,
|
||||
// Record-derived status for the session picker: phase from the
|
||||
@@ -76,6 +78,18 @@ function saveMessages(id: string, messages: UIMessage[]): void {
|
||||
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
||||
}
|
||||
|
||||
/** Append one usage entry to the record's turn log — idempotent per
|
||||
* messageId: abort replays and wake-turn retries persist the same turn
|
||||
* twice; the second append is a no-op. */
|
||||
function appendTurnUsage(id: string, entry: TurnUsage): void {
|
||||
const record = getChat(id);
|
||||
if (!record) throw new Error(`Chat not found: ${id}`);
|
||||
record.turns = record.turns?.some((t) => t.messageId === entry.messageId)
|
||||
? record.turns
|
||||
: [...(record.turns ?? []), entry];
|
||||
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
||||
}
|
||||
|
||||
/** Persist the session plan tree (plan tools call this in execute). */
|
||||
function saveTree(id: string, tree: PlanTree): void {
|
||||
const record = getChat(id);
|
||||
@@ -92,4 +106,5 @@ export const chatStore: ChatStore = {
|
||||
listChats,
|
||||
saveMessages,
|
||||
saveTree,
|
||||
appendTurnUsage,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user