Files
learnharness/src/server/turn-service.ts
T
amosblomqvist e4049e4bf8 Key-presence refusal at every gate: a pinned model is not enough
Owner-diagnosed: the turn gate checked only model coordinates, so a
pinned slot whose provider key was deleted passed the gate and died
mid-stream masked to 'An error occurred.' The key is now gated where
the model is — by what would RUN (env-first keyValue truth, so an
env-shadowed key passes).

- core/settings.ts: the provider_key_missing envelope family (code,
  provider, slots, human line naming the provider from AUTH_PROVIDER_KEYS
  labels) + ProviderKeyMissingError + isProviderKeyRefusal matcher.
- chat-handler turn gate: refuses 503 before beginTurn when the resolved
  conversation's provider key is gone (slots: [strong]).
- Job runner: same check via an injected dep (agent layer cannot read
  the key store) — a keyless engine refuses the spawn through the
  existing failure-shape convention, no phantom run.
- Extraction pipeline: refuseIfStagesUnready refuses BEFORE any batch
  (first offender: extractor before auditor); the trigger skips info-
  level, /api/graph/import surfaces the 503 envelope per file.
- Tests: 8 new pins (turn gate both ways, runner refusal, pipeline
  first-offender + pass-through, envelope factory, matcher); the three
  turn-driving suites seed OPENROUTER_API_KEY like any real setup.
- Doc fix: stale EXTRACTION.chunkTokens comment in the manual-extract
  route (left over from eed4b4e).

Known residual (pre-existing, flagged for master): the manual extract
route logs BOTH config refusals as console.error (fire-and-forget path);
the coverage ledger keeps everything uncovered either way.
2026-09-24 22:51:12 +03:00

123 lines
5.8 KiB
TypeScript

/**
* The chat-turn services — one named, error-contained seam per turn
* responsibility, extracted from the chat-handler so it composes instead of
* implementing. Each takes its dependencies explicitly (storage ports, the
* job runner) — no module here imports an adapter.
*
* persistTurn — the canonical state write (client + agent messages)
* triggerExtraction — the graph write channel: fires the extraction
* pipeline (the sole graph writer) once a chat
* crosses the v1 quiz-answer threshold. The trigger
* mechanism is a v1 placeholder — this seam is the
* ONLY thing to swap when a better one lands.
*
* Logs are the minimal-observability baseline: one line per failure, tagged
* [chat <id>] — structured logging lands behind these seams later.
*/
import type { UIMessage } from "ai";
import { withPlanBlocks } from "../core/plan-blocks.ts";
import { coverageOf } from "../core/coverage.ts";
import type { ChatStore, GraphStore } from "../core/ports.ts";
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/settings.ts";
/**
* The canonical state write: the client owns the message list between turns
* (useChat); this persists client messages + the agent response. Persists
* even when aborted — the partial transcript (including any pending
* question part) is what makes resume work.
*/
/**
* The SDK assigns assistant-message ids CLIENT-side (useChat); the server's
* onFinish synthesis leaves them EMPTY. A message persisted only server-side
* (the last turn's, or a wake turn's — nobody re-POSTs it with a client id)
* would otherwise land in the record with id "" — and two of those in one
* transcript break React keys (the chat map) and steer-dedup lookups. Fill any
* missing id here, at the single canonical write: client-authored ids are
* NEVER touched (the client's next POST still carries them verbatim).
*/
export function withMessageIds(messages: UIMessage[]): UIMessage[] {
const filled = messages.map((m) =>
m.id ? m : { ...m, id: `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}` },
);
// Same id twice = the SDK's continuation merge couldn't happen in place
// (continuation coalescing appends a user-role steer AFTER the assistant
// message being continued, so onFinish carries the input copy AND the
// extended response copy under one id). Keep the LAST occurrence — it is
// the SDK's final merge state (a superset of the earlier copy).
const seen = new Set<string>();
const deduped: UIMessage[] = [];
for (let i = filled.length - 1; i >= 0; i--) {
const m = filled[i]!;
if (seen.has(m.id)) continue;
seen.add(m.id);
deduped.unshift(m);
}
return deduped;
}
export function persistTurn(chatStore: ChatStore, chatId: string, messages: UIMessage[], isAborted: boolean): void {
try {
const deduped = withMessageIds(messages);
// Plan-block emission (wave 4): blocks are frozen transcript content —
// attached here, once, at the canonical state write. The tree was
// saved by the plan tool during the turn; snapshots derive now.
const tree = chatStore.getChat(chatId)?.tree;
chatStore.saveMessages(chatId, withPlanBlocks(deduped, tree));
} catch (err) {
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
}
}
/**
* The graph write channel (vision §1 construction): the extraction pipeline
* (extractor → auditor → merger — the sole graph writer) runs ASYNC once a
* chat's UNCOVERED transcript tokens reach the chunk budget
* (vision-extraction-coverage.md, 2026-09-15 — replacing the old
* every-3-quiz-answers count: content size is the honest signal, and
* quiz-less sessions extract too). The budget is the graph section's
* extractionChunkTokens setting (extraction-UX redesign, 2026-09-24) —
* the composition root passes the per-turn effective read (chat-handler
* resolves fresh per turn, same as every other gate). The pipeline
* itself extracts only the uncovered batches. Fire-and-forget — never blocks the turn; failures are
* logged by the pipeline and leave coverage untouched, so the next trigger
* (or the manual extract button) re-runs what's left.
*
* Deliberately isolated in this one seam — the pipeline is
* trigger-agnostic. Skipped under tests: scripted chats are fixtures, not
* knowledge (the pipeline itself is tested directly with mock models).
*/
export function triggerExtraction(
deps: { chatStore: ChatStore; graphStore: GraphStore },
extraction: ExtractionPipeline,
chatId: string,
chunkTokens: number,
): void {
if (process.env.NODE_ENV === "test") return;
try {
const record = deps.chatStore.getChat(chatId);
if (!record) return;
const covered = coverageOf(record.messages as UIMessage[], deps.graphStore.readCoverage(), chatId);
if (covered.uncoveredTokens < chunkTokens) return;
void extraction.runExtraction(chatId).catch((err) => {
// BYOK (owner, 2026-09-22) + key presence (provider-key interlude,
// 2026-09-25): an unconfigured OR keyless write channel SKIPS (the
// coverage ledger keeps the messages uncovered — the next trigger
// retries once slots/keys are set); only real failures log.
if (err instanceof ModelsNotConfiguredError) {
console.info(`[chat ${chatId}] extraction skipped — ${err.envelope.error} (missing: ${err.envelope.missing.join(", ")})`);
return;
}
if (err instanceof ProviderKeyMissingError) {
console.info(`[chat ${chatId}] extraction skipped — ${err.envelope.error} (provider: ${err.envelope.provider})`);
return;
}
console.error(`[chat ${chatId}] extraction pipeline failed:`, err);
});
} catch (err) {
console.error(`[chat ${chatId}] extraction trigger failed:`, err);
}
}