Storage ports: ChatStore/GraphStore/JobStore + adapters + core inversion (stage 3)
Per-entity semantic ports (src/core/ports.ts), one adapter family
(src/store/*, fs JSON/JSONL), exactly one composition point
(src/server/stores.ts). Dependency direction is now machine-checked:
adapters implement core interfaces; core never imports an adapter.
The inversion:
- core/graph.ts (emitChat/getGraph/graphSnapshot/foldStaleChats) is now
pure — it takes the storage ports as explicit parameters (GraphDeps).
No store imports; newGraphNodeId is local; node id format unchanged.
- core/graph-context.ts buildGraphContext() no longer defaults to the
live ledgers: the chat-handler hands in getGraph(graphStore) per turn.
- store adapters export exactly one object each implementing their port
(chatStore/graphStore/jobStore); bodies otherwise verbatim.
NodeMapping + SessionCheckpoint moved store/graph -> core/contracts
(they ARE contract shapes); CreateJobInit/JobPatch live in ports.
- titleFromPrompt moved store/chats -> core/contracts (pure helper,
planNodeStatus precedent). Store modules now contain persistence only.
- server/stores.ts is the composition root; server/{chats,graph,jobs}.ts
and chat-handler wire from it; agent (plan tools, sub-agent runner)
call the port objects. The shell surface (@/src/server exports) is
unchanged.
Lint law extended: the whole of src/core is now a purity zone (was a
file glob with a graph/graph-context exemption). Verified it fires on
core->store imports; tsc clean, 190/190 tests, eslint clean, prod build
clean (prerender 6/6). Tests pass deps explicitly — graph.test uses the
same fs-adapter pair stores.ts wires in production; graph-context.test
keeps its in-memory ledgers as a GraphStore implementation.
This commit is contained in:
+18
-18
@@ -43,8 +43,8 @@ import { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts";
|
||||
import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts";
|
||||
import { demoModelOverride, runDemoJob, runVisualJob, spawnPendingJobs, visualModelOverride } from "../src/agent/subagents/runner.ts";
|
||||
import { runVisualMaker } from "../src/agent/subagents/visual-maker.ts";
|
||||
import { createChat, getChat, saveMessages } from "../src/store/chats.ts";
|
||||
import { createJob, findJobByToolCallId, getJob, listJobs } from "../src/store/jobs.ts";
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
import { jobStore } from "../src/store/jobs.ts";
|
||||
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
||||
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||
import { artifactRenderers } from "../components/artifacts/index.ts";
|
||||
@@ -447,9 +447,9 @@ async function drain(res: Response): Promise<string> {
|
||||
|
||||
describe("demo/visual job runners", () => {
|
||||
it("runDemoJob fulfills the part with a tested DemoResult and marks the job done", async () => {
|
||||
const chat = createChat("Test: demo runner");
|
||||
const chat = chatStore.createChat("Test: demo runner");
|
||||
createdChats.push(chat.id);
|
||||
saveMessages(chat.id, [
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "show me compounding" }] },
|
||||
{
|
||||
id: "a1",
|
||||
@@ -458,7 +458,7 @@ describe("demo/visual job runners", () => {
|
||||
},
|
||||
] as never);
|
||||
|
||||
const job = createJob({ kind: "demo", chatId: chat.id, toolCallId: "call-d1" });
|
||||
const job = jobStore.createJob({ kind: "demo", chatId: chat.id, toolCallId: "call-d1" });
|
||||
createdJobs.push(job.id);
|
||||
|
||||
// runDemoJob has no headless injection point — the module override is the seam.
|
||||
@@ -475,14 +475,14 @@ describe("demo/visual job runners", () => {
|
||||
headlessOverride.current = undefined;
|
||||
}
|
||||
|
||||
const done = getJob(job.id)!;
|
||||
const done = jobStore.getJob(job.id)!;
|
||||
assert.equal(done.status, "done");
|
||||
assert.equal(done.activity, "run_demo: clean", "final activity line recorded");
|
||||
const result = done.result as { title: string; html: string; tested: boolean };
|
||||
assert.equal(result.title, "Compound interest");
|
||||
assert.equal(result.tested, true);
|
||||
|
||||
const saved = getChat(chat.id)!;
|
||||
const saved = chatStore.getChat(chat.id)!;
|
||||
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-demo");
|
||||
assert.ok(part && part.type === "tool-demo", "demo tool part persisted");
|
||||
assert.equal(part.state, "output-available", "part fulfilled");
|
||||
@@ -490,9 +490,9 @@ describe("demo/visual job runners", () => {
|
||||
});
|
||||
|
||||
it("runVisualJob fulfills the part even on failure and marks the job error", async () => {
|
||||
const chat = createChat("Test: visual runner failure");
|
||||
const chat = chatStore.createChat("Test: visual runner failure");
|
||||
createdChats.push(chat.id);
|
||||
saveMessages(chat.id, [
|
||||
chatStore.saveMessages(chat.id, [
|
||||
{ id: "u1", role: "user", parts: [{ type: "text", text: "draw it" }] },
|
||||
{
|
||||
id: "a1",
|
||||
@@ -501,16 +501,16 @@ describe("demo/visual job runners", () => {
|
||||
},
|
||||
] as never);
|
||||
|
||||
const job = createJob({ kind: "visual", chatId: chat.id, toolCallId: "call-v1" });
|
||||
const job = jobStore.createJob({ kind: "visual", chatId: chat.id, toolCallId: "call-v1" });
|
||||
createdJobs.push(job.id);
|
||||
|
||||
await runVisualJob(job.id, { model: scriptedModel([{ text: "cannot" }]) });
|
||||
|
||||
const errored = getJob(job.id)!;
|
||||
const errored = jobStore.getJob(job.id)!;
|
||||
assert.equal(errored.status, "error");
|
||||
assert.match(errored.error ?? "", /never wrote an SVG/);
|
||||
|
||||
const saved = getChat(chat.id)!;
|
||||
const saved = chatStore.getChat(chat.id)!;
|
||||
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-visual");
|
||||
assert.ok(part && part.type === "tool-visual", "visual tool part persisted");
|
||||
assert.equal(part.state, "output-available", "failure still fulfills the part");
|
||||
@@ -520,7 +520,7 @@ describe("demo/visual job runners", () => {
|
||||
});
|
||||
|
||||
it("the spawn hook dispatches a demo part to the demo runner (kind→runner map)", async () => {
|
||||
const chat = createChat("Test: demo spawn dispatch");
|
||||
const chat = chatStore.createChat("Test: demo spawn dispatch");
|
||||
createdChats.push(chat.id);
|
||||
|
||||
// A teacher that calls demo on turn 1 (scripted stream, like jobs.test.ts).
|
||||
@@ -571,25 +571,25 @@ describe("demo/visual job runners", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const job = findJobByToolCallId(chat.id, "call-d2");
|
||||
const job = jobStore.findJobByToolCallId(chat.id, "call-d2");
|
||||
assert.ok(job, "spawn hook created a job for the demo part");
|
||||
assert.equal(job!.kind, "demo");
|
||||
createdJobs.push(job!.id);
|
||||
|
||||
// The background runner (fire-and-forget) completes with the overrides.
|
||||
await waitFor(() => getJob(job!.id)!.status !== "running", 5000, "demo job completion");
|
||||
const done = getJob(job!.id)!;
|
||||
await waitFor(() => jobStore.getJob(job!.id)!.status !== "running", 5000, "demo job completion");
|
||||
const done = jobStore.getJob(job!.id)!;
|
||||
assert.equal(done.status, "done");
|
||||
assert.equal(done.activity, "run_demo: clean");
|
||||
|
||||
const saved = getChat(chat.id)!;
|
||||
const saved = chatStore.getChat(chat.id)!;
|
||||
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-demo");
|
||||
assert.ok(part && part.type === "tool-demo", "demo tool part persisted");
|
||||
assert.equal(part.state, "output-available", "demo part fulfilled by the background runner");
|
||||
|
||||
// Idempotency: re-running the spawn hook creates no second job.
|
||||
spawnPendingJobs(chat.id, saved.messages);
|
||||
assert.equal(listJobs(chat.id).length, 1, "no duplicate job for the same tool part");
|
||||
assert.equal(jobStore.listJobs(chat.id).length, 1, "no duplicate job for the same tool part");
|
||||
} finally {
|
||||
demoModelOverride.current = undefined;
|
||||
headlessOverride.current = undefined;
|
||||
|
||||
Reference in New Issue
Block a user