practice slice 4 — pipeline, registry, API, feed

src/agent/subagents/practice-pipeline.ts (sibling of practice.ts —
orchestration vs calls): createPracticePipeline(graphStore+practiceStore
+chatStore+PracticeModels). Fire-and-forget front doors returning
{set, done}: createProblemSet (refuse → resolve selection (chatId =
ANY-provenance membership per brief; nodeIds raw) → chains → snapshot →
createSet partitioning → registry row → runPartitioner → stubs land
(generating) → ONE generator per problem behind the process-wide
3-permit abort-aware semaphore (__lhPracticeSlots, extraction idiom) →
per-problem failure isolation → ready (errored problems included);
abortPractice (idempotent: controllers + 'Aborted' marks; dropped
grades keep answers, re-submittable); submitAnswers (ready-only,
already-graded rejects, one grading job per answer under the same
semaphore); deleteProblemSet (abort-first). Per-set write mutex on
globalThis (__lhPracticeLocks, audit-lock idiom); registry
__lhPracticeRuns one row per set per kind; boot sweep marks orphaned
partitioning/generating sets 'Interrupted (server restarted)'. Typed
errors: EmptySelection/UnknownChat/SetNotFound/SetNotReady/BadAnswers.
Usage folds stage logs into the registry row (v1: completion-folded).

src/server/practice.ts: thin service — strict params parsing, typed
error → response mapping (503 envelope family, 400/404/409), list/get.
Routes: POST+GET /api/practice/sets (202 {id}), GET+DELETE
/api/practice/sets/[id], POST /api/practice/sets/[id]/answers (202).
Composition root: chat-handler wires createPracticePipeline (engineFor
fresh + providerKeyMissing gate) exported as ; barrel re-
exports. Feed: observability activityFeed() += practice rows
(ActivityPractice); dock blobs (process 'practice', GraduationCap
icon, set-keyed abort); /api/activity/abort kind 'practice'.

tests/practice-pipeline.test.ts — 13 tests: end-to-end (stubs mid-
flight via gated generators, coverage complete), failure isolation,
3-permit semaphore pin, abort mid-generation, registry shape pins,
refusal-before-create, chatId any-provenance selection, typed errors,
answers→grades land + graded rejects, boot sweep (orphan + live-row),
delete. 1114/1114, tsc 0, lint 0, build 0.
This commit is contained in:
amosblomqvist
2026-09-29 18:31:53 +03:00
parent da0cd558d0
commit 27c5d9df19
11 changed files with 1451 additions and 19 deletions
+19
View File
@@ -3,6 +3,7 @@ import { createProberAgent, createTeacherAgent, type AgentBuildOptions, type Tea
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
import { createJobRunner } from "../agent/subagents/runner.ts";
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
import { createPracticePipeline } from "../agent/subagents/practice-pipeline.ts";
import type { ModelRole } from "../../config.ts";
import {
deliverJobResult,
@@ -97,6 +98,24 @@ export const extraction = createExtractionPipeline({
providerKeyPresent(provider) ? null : providerKeyMissingEnvelope(provider, [tier]),
});
/** The app's ONE practice pipeline instance — the API routes reach it
* through the server barrel / service, same discipline as extraction. */
export const practice = createPracticePipeline({
graphStore: stores.graphStore,
practiceStore: stores.practiceStore,
chatStore: stores.chatStore,
// BYOK (owner, 2026-09-22): the practice stages resolve the chain —
// role override → slot → NOTHING — read FRESH per stage call; an empty
// chain flows through as undefined and the pipeline refuses the run
// with the typed ModelsNotConfiguredError. Direct test values still win.
engineFor: (role) =>
resolveEngine(role),
// Key-presence gate: same check extraction gets — a stage engine whose
// provider key is gone refuses BEFORE any set is created.
providerKeyMissing: (provider, tier) =>
providerKeyPresent(provider) ? null : providerKeyMissingEnvelope(provider, [tier]),
});
// Wake-pipeline wiring (contracts.ts "THE ASYNC PROTOCOL" §4): the pipeline
// delivers its steers through THIS composition root (one event: the steer
// marker, appended natively under the turn claim), and crash recovery
+1
View File
@@ -6,6 +6,7 @@
export * from "./chat-handler.ts";
export * from "./chats.ts";
export * from "./practice.ts";
export * from "./graph.ts";
export * from "./commit-positions.ts";
export * from "./jobs.ts";
+26 -1
View File
@@ -20,6 +20,7 @@
*/
import { extractionStatuses, type ExtractionPhase } from "../agent/subagents/extraction.ts";
import { practiceStatuses, type PracticePhase } from "../agent/subagents/practice-pipeline.ts";
import { chatStore } from "../store/chats.ts";
import { graphRevision } from "./graph.ts";
import { catalogContextLimits } from "./model-catalog.ts";
@@ -27,6 +28,7 @@ import { listJobs } from "./jobs.ts";
import type { JobRecord } from "../core/contracts.ts";
export { abortExtraction, extractionStatuses, type ExtractionPhase, type RunningExtraction } from "../agent/subagents/extraction.ts";
export { practiceStatuses, type PracticePhase, type RunningPractice } from "../agent/subagents/practice-pipeline.ts";
export { chatStore } from "../store/chats.ts";
/** A running sub-agent job, joined with its chat's title for the dock.
@@ -54,6 +56,16 @@ export interface ActivityExtraction {
startedAt: number;
}
/** A live practice run in the dock's feed — mirrors ActivityExtraction
* (kind names which pipeline half: generation or grading). */
export interface ActivityPractice {
id: string;
kind: "generation" | "grading";
title: string;
phase: PracticePhase;
startedAt: number;
}
/** 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:
@@ -65,7 +77,12 @@ export interface ActivityExtraction {
* graph changed underneath me" (a merge landed, a deletion purged) and
* refetch clusters/layout. Reads never move it; the DELETE response
* returns the same stamp. */
export function activityFeed(): { jobs: ActivityJob[]; extractions: ActivityExtraction[]; graphRevision: number } {
export function activityFeed(): {
jobs: ActivityJob[];
extractions: ActivityExtraction[];
practice: ActivityPractice[];
graphRevision: number;
} {
const chats = chatStore.listChats();
const limits = catalogContextLimits();
const jobs: ActivityJob[] = [];
@@ -93,11 +110,19 @@ export function activityFeed(): { jobs: ActivityJob[]; extractions: ActivityExtr
phase: run.phase,
startedAt: run.startedAt,
}));
const practice: ActivityPractice[] = practiceStatuses().map((row) => ({
id: row.setId,
kind: row.kind,
title: row.label,
phase: row.phase,
startedAt: row.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),
practice: practice.sort((a, b) => a.startedAt - b.startedAt),
graphRevision: graphRevision(),
};
}
+132
View File
@@ -0,0 +1,132 @@
/**
* THE PRACTICE SERVICE (slice 4, 2026-09-29): the thin, route-facing
* surface over the practice pipeline — request parsing/validation, typed
* error → response mapping. Routes call THIS, never the pipeline
* internals (the chats/graph service discipline).
*
* The pipeline instance is the chat-handler composition root's export
* (`practice`, next to `extraction`): engines resolve fresh per stage
* from BYOK settings, and refusals map to the 503 envelope family.
*/
import { practice } from "./chat-handler.ts";
import { stores } from "./stores.ts";
import type { PracticeParams, ProblemSet } from "../core/contracts.ts";
import {
BadAnswersError,
EmptySelectionError,
PracticeSetNotFound,
PracticeSetNotReady,
UnknownChatError,
} from "../agent/subagents/practice-pipeline.ts";
import {
ModelsNotConfiguredError,
ProviderKeyMissingError,
PROVIDER_KEY_MISSING_CODE,
} from "../core/settings.ts";
// app/api uses base-URL charset ids (the activity/abort precedent).
const ID_OK = /^[A-Za-z0-9_-]+$/;
/** GET /api/practice/sets — the list, createdAt desc (store's order). */
export function listPracticeSets(): ProblemSet[] {
return stores.practiceStore.listSets();
}
/** GET /api/practice/sets/[id] — one set, or null (→ 404). */
export function getPracticeSet(id: string): ProblemSet | null {
return stores.practiceStore.getSet(id);
}
/** Strict params: three finite numbers in [0, 1]. */
function parseParams(raw: unknown): PracticeParams | null {
if (typeof raw !== "object" || raw === null) return null;
const p = raw as Record<string, unknown>;
const dial = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 1;
if (!dial(p.searchSolutionRatio) || !dial(p.cueDistance) || !dial(p.presentation)) return null;
return {
searchSolutionRatio: p.searchSolutionRatio,
cueDistance: p.cueDistance,
presentation: p.presentation,
};
}
/** The typed pipeline errors → HTTP (refusals → the 503 envelope family,
* exactly like the import route's extraction mapping). */
export function practiceErrorResponse(err: unknown): Response {
if (err instanceof ModelsNotConfiguredError) {
return Response.json(
{ error: err.envelope.error, code: "models_not_configured", missing: err.envelope.missing },
{ status: 503 },
);
}
if (err instanceof ProviderKeyMissingError) {
return Response.json(
{
error: err.envelope.error,
code: PROVIDER_KEY_MISSING_CODE,
provider: err.envelope.provider,
slots: err.envelope.slots,
},
{ status: 503 },
);
}
if (err instanceof EmptySelectionError) return Response.json({ error: err.message, code: "empty_selection" }, { status: 400 });
if (err instanceof UnknownChatError) return Response.json({ error: err.message, code: "unknown_chat" }, { status: 404 });
if (err instanceof BadAnswersError) return Response.json({ error: err.message, code: "bad_answers" }, { status: 400 });
if (err instanceof PracticeSetNotFound) return Response.json({ error: err.message, code: "not_found" }, { status: 404 });
if (err instanceof PracticeSetNotReady) return Response.json({ error: err.message, code: "not_ready" }, { status: 409 });
console.error("[practice] unexpected:", err);
return Response.json({ error: "Internal error" }, { status: 500 });
}
/** POST /api/practice/sets — 202 {id}; the set generates in the
* background (fire-and-forget pipeline). */
export async function createPracticeSetResponse(body: unknown): Promise<Response> {
const b = (body ?? {}) as Record<string, unknown>;
const params = parseParams(b.params);
if (!params) {
return Response.json(
{ error: "params must carry searchSolutionRatio, cueDistance, presentation as numbers in [0,1]" },
{ status: 400 },
);
}
try {
if (typeof b.chatId === "string" && b.chatId !== "") {
if (!ID_OK.test(b.chatId)) return Response.json({ error: "Invalid chatId" }, { status: 400 });
const { set } = await practice.createProblemSet({ chatId: b.chatId }, params);
return Response.json({ id: set.id }, { status: 202 });
}
if (Array.isArray(b.nodeIds) && b.nodeIds.every((id): id is string => typeof id === "string" && ID_OK.test(id))) {
if (b.nodeIds.length === 0) {
return Response.json({ error: "nodeIds is empty", code: "empty_selection" }, { status: 400 });
}
const { set } = await practice.createProblemSet({ nodeIds: b.nodeIds }, params);
return Response.json({ id: set.id }, { status: 202 });
}
} catch (err) {
return practiceErrorResponse(err);
}
return Response.json({ error: 'Body must be {chatId, params} or {nodeIds, params}' }, { status: 400 });
}
/** POST /api/practice/sets/[id]/answers — 202; grades land in the
* background. Body: {answers: [{problemId, text}]}. */
export async function submitPracticeAnswersResponse(setId: string, body: unknown): Promise<Response> {
if (!ID_OK.test(setId)) return Response.json({ error: "Invalid set id" }, { status: 400 });
const b = (body ?? {}) as Record<string, unknown>;
const raw = b.answers;
if (!Array.isArray(raw) || raw.length === 0 || !raw.every((a): a is { problemId: string; text: string } =>
typeof a === "object" && a !== null &&
typeof (a as Record<string, unknown>).problemId === "string" &&
typeof (a as Record<string, unknown>).text === "string",
)) {
return Response.json({ error: 'Body must be {answers: [{problemId: string, text: string}]}' }, { status: 400 });
}
try {
const { set } = await practice.submitAnswers(setId, raw);
return Response.json({ id: set.id }, { status: 202 });
} catch (err) {
return practiceErrorResponse(err);
}
}