Files
learnharness/app/api/chats/[id]/route.ts
T
amosblomqvist a2db80b72d compaction: the usage-anchored trigger + visible session state (round 15)
The relational-databases incident, diagnosed and fixed:

TRIGGER — pi's estimator law, adopted exactly: anchor on the last
assistant message's provider-reported usage (totalTokens || the field
sum) and chars/4-estimate ONLY what was appended after it. The anchor
scan skips aborted/zero-usage assistants and STOPS at the newest
compaction marker (pre-cut usage described context the next request
will not carry — pi's distrust rule). The old estimator
(JSON.stringify of every message /4) tripled the true context
(measured: 84,412 'tokens' estimated vs 38,799 provider-reported on
the incident record) — scaffolding, escaped LaTeX, thinking blocks
and app-side details all counted. estimateMessageTokens now counts
wire text only (text + thinking + toolCall name/args; content text
for user/toolResult/custom). The unanchored fallback runs the plain
heuristic over the CUT-HONORED projection plus the additive prompt
term; the anchored path adds nothing (usage already billed prompt
and tools).

OBSERVABILITY — compaction is a visible session-wide state (owner
spec): the transcript-bottom placeholder register gains COMPACTING
CONTEXT… (BrailleSpinner + Shimmer, the THINKING placeholder's twin,
mutually exclusive by construction — the gate precedes beginTurn),
and the sidebar row shows 'Compacting context · <ago>' with the
spinner (sessionMeta outranks busy; compacting-changed joins
turn-started/turn-settled as refresh triggers). Liveness rides the
API like turn-busy: ChatListItem.compacting + compactionInFlight on
both GET /api/chats and GET /api/chats/[id] (initiallyCompacting
covers the navigate-back-mid-compaction mount — the event already
fired), exported through the server barrel.

Also: the relational-databases record rolled back to pre-compaction
(marker, post-marker turns, tree reset; backup in /tmp) for a clean
retest. The summarizer-usage 'gap' was a misread — steps[] was
populated all along.
2026-10-06 00:38:15 +03:00

97 lines
4.0 KiB
TypeScript

import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, stores, type ChatDeleteMode } from "@/src/server";
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const chat = getChat(id);
if (!chat) return Response.json({ error: "Not found" }, { status: 404 });
// turnInFlight lets a returning client re-attach to a running turn
// (chat-handler write-ahead + registry — see src/server/turn-liveness.ts).
// coverage (extraction-UX redesign, 2026-09-24): the chat view's ring
// meter + sidebar block — THE coverage surface (the all-sessions GET
// route died with the redesign; the Extract POST stays). getChat
// already 404'd, so the block exists.
// Wave D (owner-approved 2026-09-26): the record's messages are native
// agent history, served VERBATIM — the client derives its view
// (core/view.ts projectView). turnInFlight lets a returning client
// re-attach to a running turn; coverage is the chat view's ring meter.
return Response.json({
...chat,
messages: chat.messages,
turnInFlight: isTurnRunning(id),
compacting: compactionInFlight(id),
coverage: sessionCoverage(id),
});
}
/**
* Rename (2026-09-17): the header's click-to-edit title commits here.
* Validation is a single rule — a non-empty string after trim (the store's
* "Untitled session" floor is never reached from this route). updatedAt is
* deliberately NOT bumped: a rename must not reorder the sidebar (same
* rule as the settings PUT).
*/
export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
if (!getChat(id)) return Response.json({ error: "Not found" }, { status: 404 });
const body = (await req.json().catch(() => null)) as { title?: unknown } | null;
if (typeof body?.title !== "string" || body.title.trim() === "") {
return Response.json({ error: "title must be a non-empty string" }, { status: 400 });
}
const title = body.title.trim();
const record = stores.chatStore.renameChat(id, title);
return Response.json({ title: record.title });
}
/**
* DELETE /api/chats/[id]?mode=preserve-graph|delete-nodes — session deletion
* (wave 6, owner rulings 2026-09-22/23). The mode is REQUIRED and explicit:
* no default, because the two differ in destructiveness —
*
* preserve-graph the session record, its jobs, and its extraction
* bookkeeping (coverage/checkpoint rows) die; the graph
* keeps every contribution the session merged.
* delete-nodes "this session never happened": every ledger trace of
* the session goes too — provenance, link lines,
* events, mappings — and orphaned nodes die entirely
* (shared nodes survive automatically, their other
* sessions' content intact).
*
* Gates are the server orchestration's (server/chats.ts deleteChatSession):
* 409 while a turn or background job is running, 503 while the session's
* extraction is winding down after its abort — both retryable. The
* response carries what was removed plus the new graphRevision (the
* version stamp the UI polls to refetch clusters/layout).
*/
export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const mode = new URL(req.url).searchParams.get("mode");
if (mode !== "preserve-graph" && mode !== "delete-nodes") {
return Response.json(
{
error: 'mode is required and must be "preserve-graph" or "delete-nodes"',
code: "bad_mode",
},
{ status: 400 },
);
}
const outcome = await deleteChatSession(id, mode as ChatDeleteMode);
if (!outcome.ok) {
return Response.json(
{
error: outcome.error,
code: outcome.code,
...("jobIds" in outcome ? { jobIds: outcome.jobIds } : {}),
},
{ status: outcome.status },
);
}
return Response.json({
ok: true,
mode: outcome.mode,
stats: outcome.stats,
jobsRemoved: outcome.jobsRemoved,
graphRevision: outcome.graphRevision,
});
}