Files
learnharness/src/server/graph.ts
T

68 lines
2.6 KiB
TypeScript
Raw Normal View History

import * as fs from "node:fs";
import { LEDGER_FILES, POSITIONS_FILE } from "../../config.ts";
import type { GraphSnapshot } from "../core/contracts.ts";
import type { GraphCostSummary } from "../core/cost-summary.ts";
import { graphCostSummary } from "../core/cost-summary.ts";
import { graphSnapshot } from "../core/graph.ts";
import { stores } from "./stores.ts";
/**
* Server-side graph entry for the home view (GET /api/graph). A PURE READ:
* the extraction pipeline (the sole graph writer) is triggered by quiz
* answers in sessions (turn-service), never from here — the home view
* reflects whatever the pipeline has merged so far.
*/
export function graphView(): GraphSnapshot {
return graphSnapshot(stores);
}
/** The graph view's cost surface (GET /api/graph/costs — the 2026-09 cost
* pill): fold every usage channel into stage totals + recent runs. A pure
* read over the chat records (chat-front-door runs), the notes sources
* (notes-front-door runs), and the naming ledger (graph-global runs);
* dollars are inferred by the pure core fold (core/cost-summary.ts +
* prices.ts) — nothing here stores or caches money. */
export function graphCosts(): GraphCostSummary {
const chats = stores.chatStore
.listChats()
.flatMap((summary) => {
const record = stores.chatStore.getChat(summary.id);
return record?.extractions?.length
? [{ title: record.title ?? summary.id, extractions: record.extractions }]
: [];
});
const sources = stores.graphStore
.readSources()
.filter((s) => s.extractions?.length)
.map((s) => ({ filePath: s.filePath, extractions: s.extractions! }));
return graphCostSummary({ chats, sources, naming: stores.graphStore.readNamingRuns() });
}
/** The graph's version stamp (session-deletion wave 6): the max mtime
* across every graph data file — ledgers, positions, embeddings. Moves
* on ANY graph write (merge append, settle, embed, or a deletion's
* atomic rewrite), never moves on reads, and needs no bookkeeping of
* its own: the fs IS the counter. Consumers poll it (the activity feed
* carries it; the DELETE response returns it) to know clusters/layout
* must refetch — the "graph changed underneath me" signal. */
export function graphRevision(): number {
let revision = 0;
for (const file of [
LEDGER_FILES.nodes,
LEDGER_FILES.edges,
LEDGER_FILES.events,
LEDGER_FILES.mappings,
LEDGER_FILES.sessions,
LEDGER_FILES.coverage,
LEDGER_FILES.namingRuns,
POSITIONS_FILE,
]) {
try {
revision = Math.max(revision, fs.statSync(file).mtimeMs);
} catch {
/* absent — nothing has ever written it */
}
}
return revision;
}