Activity feed: one dock-shaped GET /api/activity (cross-chat processes)

The dock is visible in every view, so it needs every running process —
sub-agent jobs across ALL chats (not just the open one) plus the global
extraction runs — in one poll. /api/jobs stays chat-scoped and untouched:
it is the transcript machinery's poll and carries turnInFlight for the
re-attach seam (chat-transport), the seam we spent three live rounds
stabilizing; making it polymorphic would fork its response shape by mode
and put regression risk on exactly the wrong path. The dock is a different
consumer with a different shape (cross-chat, running-only, cost-bearing),
served by a thin route over server/observability.ts:

- activityFeed(): running jobs from every chat (title-joined, oldest-first
  on last activity) + extraction runs + busyChats (chats with a teacher
  turn in flight — the biggest spender; data made available, rendering is
  a dock decision).
- GET /api/activity — the dock's single poll everywhere.
- Cost math stays client-side (core/prices.ts folds); raw usage rides the
  records and the transport stays thin.

Decided with the user over the polymorphic-/api/jobs and
separate-all-jobs-endpoint alternatives.
This commit is contained in:
amosblomqvist
2026-09-11 02:54:31 +03:00
parent 83a00800e9
commit f8ba5b1628
3 changed files with 136 additions and 6 deletions
+68 -6
View File
@@ -2,11 +2,73 @@
* Server-side observability surface — the barrel-approved exports the
* transport layer (app/api/*) reads for background-process status.
*
* The dock (2026-09 observability design) needs: extraction-pipeline runs
* (from the pipeline's module-level registry) and the store handles for
* title joins. app/ routes import ONLY from @/src/core and @/src/server —
* this file is where the deep imports surface, so routes stay clean.
* THE ACTIVITY FEED (2026-09 observability design): the bottom-right dock is
* visible in every view, so it needs the complete running-process picture —
* sub-agent jobs across ALL chats (not just the open one) plus the global
* extraction runs — in one poll. /api/jobs stays chat-scoped untouched: it
* is the transcript machinery's poll and carries turnInFlight for the
* re-attach seam (chat-transport); the dock is a different consumer with a
* different shape, served here. Single sources of truth are unchanged: the
* job store, the pipeline registry, the turn-liveness registry — this file
* only composes them.
*/
export { extractionStatuses, type ExtractionPhase } from "../agent/subagents/extraction.ts";
export { chatStore } from "../store/chats.ts";
import { extractionStatuses, type ExtractionPhase } from "../agent/subagents/extraction.ts";
import { chatStore } from "../store/chats.ts";
import { isTurnRunning } from "./turn-liveness.ts";
import { listJobs } from "./jobs.ts";
import type { JobRecord } from "../core/contracts.ts";
export { extractionStatuses, type ExtractionPhase };
export { chatStore };
/** A running sub-agent job, joined with its chat's title for the dock. */
export interface ActivityJob extends JobRecord {
title: string;
}
/** One extraction-pipeline run (title joined), oldest first. */
export interface ActivityExtraction {
chatId: string;
title: string;
phase: ExtractionPhase;
startedAt: number;
}
/** A chat with a teacher turn in flight right now (the wake-turn process —
* the biggest spender; data made available, rendering it is a dock
* decision). */
export interface ActivityChatTurn {
chatId: string;
title: string;
}
/** The dock's whole world in one payload: every running process, every view.
* Jobs are RUNNING-ONLY — finished jobs belong to their chat's transcript;
* the blob disappears when the process ends. Cost math is NOT done here:
* raw usage rides on the records and the client folds it with the pure
* helpers in core/prices.ts (transport stays thin). */
export function activityFeed(): { jobs: ActivityJob[]; extractions: ActivityExtraction[]; busyChats: ActivityChatTurn[] } {
const chats = chatStore.listChats();
const jobs: ActivityJob[] = [];
const busyChats: ActivityChatTurn[] = [];
for (const chat of chats) {
for (const job of listJobs(chat.id)) {
if (job.status === "running") {
jobs.push({ ...job, title: chat.title ?? chat.id });
}
}
if (isTurnRunning(chat.id)) {
busyChats.push({ chatId: chat.id, title: chat.title ?? chat.id });
}
}
const extractions: ActivityExtraction[] = extractionStatuses().map((run) => ({
chatId: run.chatId,
title: chatStore.getChat(run.chatId)?.title ?? run.chatId,
phase: run.phase,
startedAt: run.startedAt,
}));
// oldest-first on last activity: the stable blob order (ISO strings sort chronologically)
jobs.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt));
return { jobs, extractions: extractions.sort((a, b) => a.startedAt - b.startedAt), busyChats };
}