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:
@@ -17,7 +17,7 @@
|
||||
* Extraction abort is idempotent by design (aborting with nothing in
|
||||
* flight is success — the blob is gone either way) -> 200.
|
||||
*/
|
||||
import { abortExtraction, abortJob, abortTurn } from "@/src/server";
|
||||
import { abortExtraction, abortJob, abortTurn, practice } from "@/src/server";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: unknown;
|
||||
@@ -27,8 +27,8 @@ export async function POST(req: Request) {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
const { kind, id } = (body ?? {}) as { kind?: string; id?: string };
|
||||
if ((kind !== "job" && kind !== "extraction" && kind !== "turn") || typeof id !== "string" || id === "") {
|
||||
return Response.json({ error: 'Body must be {kind: "job"|"extraction"|"turn", id: string}' }, { status: 400 });
|
||||
if ((kind !== "job" && kind !== "extraction" && kind !== "turn" && kind !== "practice") || typeof id !== "string" || id === "") {
|
||||
return Response.json({ error: 'Body must be {kind: "job"|"extraction"|"turn"|"practice", id: string}' }, { status: 400 });
|
||||
}
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(id)) {
|
||||
return Response.json({ error: "Invalid id" }, { status: 400 });
|
||||
@@ -49,6 +49,13 @@ export async function POST(req: Request) {
|
||||
}
|
||||
// kind === "extraction": true cancellation; idempotent (nothing in
|
||||
// flight is success — same dock outcome)
|
||||
if (kind === "practice") {
|
||||
// practice slice 4 (2026-09-29): abort BOTH of a set's live runs
|
||||
// (generation + grading) and mark the persisted "Aborted" state —
|
||||
// idempotent like extraction (nothing in flight is success).
|
||||
practice.abortPractice(id);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
abortExtraction(id);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* POST /api/practice/sets/[id]/answers — submit answers for grading
|
||||
* (202; one grading job per answered problem runs in the background).
|
||||
* Body: {answers: [{problemId, text}]}. Set must be ready; answers must
|
||||
* be non-empty text on known, not-yet-graded problems (400/409 via the
|
||||
* service's typed-error mapping).
|
||||
*/
|
||||
import { submitPracticeAnswersResponse } from "@/src/server";
|
||||
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
return submitPracticeAnswersResponse(id, body);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* GET /api/practice/sets/[id] — one problem set (the exam page polls
|
||||
* this while status ≠ ready; graph-view's reveal-poll idiom).
|
||||
* DELETE — abort the set's live jobs, then drop the record (the unified
|
||||
* delete doctrine: no orphans, no zombies). 404 when never existed.
|
||||
*/
|
||||
import { getPracticeSet, practice, practiceErrorResponse } from "@/src/server";
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const set = getPracticeSet(id);
|
||||
if (!set) return Response.json({ error: "no practice set", code: "not_found" }, { status: 404 });
|
||||
return Response.json({ set });
|
||||
} catch (err) {
|
||||
return practiceErrorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const existed = await practice.deleteProblemSet(id);
|
||||
if (!existed) return Response.json({ error: "no practice set", code: "not_found" }, { status: 404 });
|
||||
return Response.json({ ok: true });
|
||||
} catch (err) {
|
||||
return practiceErrorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* POST /api/practice/sets — create a problem set (202 {id}; generation
|
||||
* runs in the background — the fire-and-forget pipeline). Body:
|
||||
* {chatId, params} — everything this session touched
|
||||
* {nodeIds, params} — a manual node selection
|
||||
* Validation + typed-error mapping live in the service (server/practice).
|
||||
* GET — the set list (createdAt desc, the store's order).
|
||||
*/
|
||||
import { createPracticeSetResponse, listPracticeSets, practiceErrorResponse } from "@/src/server";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
return createPracticeSetResponse(body);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return Response.json({ sets: listPracticeSets() });
|
||||
} catch (err) {
|
||||
return practiceErrorResponse(err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user