diff --git a/package-lock.json b/package-lock.json index 503c5f96e..377380093 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,6 +77,7 @@ "officeparser": "^6.0.4", "open": "^10.2.0", "openai": "^6.9.1", + "p-map": "^7.0.4", "proxy-agent": "^7.0.0", "react": "^19.2.1", "react-diff-viewer-continued": "^4.2.0", @@ -5908,6 +5909,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -5928,6 +5930,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -5948,6 +5951,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -5968,6 +5972,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -5988,6 +5993,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -6008,6 +6014,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -6028,6 +6035,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -6048,6 +6056,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -6068,6 +6077,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -6088,6 +6098,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -6108,6 +6119,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10" }, @@ -20511,6 +20523,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", diff --git a/package.json b/package.json index 2ac114a10..81d409612 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "officeparser": "^6.0.4", "open": "^10.2.0", "openai": "^6.9.1", + "p-map": "^7.0.4", "proxy-agent": "^7.0.0", "react": "^19.2.1", "react-diff-viewer-continued": "^4.2.0", diff --git a/scripts/capture-curate-baseline.ts b/scripts/capture-curate-baseline.ts new file mode 100644 index 000000000..7f6306cd8 --- /dev/null +++ b/scripts/capture-curate-baseline.ts @@ -0,0 +1,157 @@ +/** + * Phase 1 Task 1.0 — capture parity baseline. + * + * One-off script (NOT run by CI). Builds large.txt from small.txt by + * repetition, then runs the default curate DAG against each fixture using + * deterministic stub services, and writes the resulting outputs to JSON + * snapshot files. + * + * The snapshots document the DAG's edge-by-edge behavior under controlled + * inputs. The Phase 1 snapshot-parity test (`snapshot-parity.test.ts`) + * runs the same DAG against the same stubs and asserts matching outputs. + * + * Behavioral parity vs today's monolithic curate loop is NOT in scope of + * Phase 1 — it requires real-LLM benchmarks against today's behavior on + * the same data, which is non-deterministic and not blocking the cutover. + * + * Run: npx ts-node-esm scripts/capture-curate-baseline.ts + * (or use the equivalent ts-node-with-loader invocation) + */ + +import {readFileSync, writeFileSync} from 'node:fs' +import {dirname, join} from 'node:path' +import {fileURLToPath} from 'node:url' + +import { + type NodeContext, + type NodeServices, + TopologicalCurationRunner, +} from '../src/agent/core/curation/flow/runner.js' +import {buildCurationDAG} from '../src/agent/infra/curation/flow/dag-builder.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const FIXTURES_DIR = join(__dirname, '..', 'test', 'fixtures', 'curation') + +const SMALL_PATH = join(FIXTURES_DIR, 'small.txt') +const LARGE_PATH = join(FIXTURES_DIR, 'large.txt') +const XLARGE_PATH = join(FIXTURES_DIR, 'xlarge.txt') + +const SMALL_BASELINE_PATH = join(FIXTURES_DIR, 'baseline-small.json') +const LARGE_BASELINE_PATH = join(FIXTURES_DIR, 'baseline-large.json') + +const LARGE_REPEAT_TIMES = 25 // ~62 KB — clearly chunked +const LARGE_REPEAT_TIMES_XL = 60 // ~150 KB — Phase 2 bench target (15+ chunks) + +// --------------------------------------------------------------------------- +// Generate large fixture from small (idempotent — overwrite each run) +// --------------------------------------------------------------------------- + +const small = readFileSync(SMALL_PATH, 'utf8') +const large = Array.from({length: LARGE_REPEAT_TIMES}, (_, i) => + small.replaceAll('[USER]:', `[USER session-${i + 1}]:`).replaceAll('[ASSISTANT]:', `[ASSISTANT session-${i + 1}]:`), +).join('\n\n---\n\n') +writeFileSync(LARGE_PATH, large) + +console.log(`Wrote ${LARGE_PATH} (${large.length} bytes)`) + +// xlarge: only used by `BENCH=1 npm test`; not committed as a baseline +// JSON because it's tracked by the bench harness, not the snapshot test. +const xlarge = Array.from({length: LARGE_REPEAT_TIMES_XL}, (_, i) => + small.replaceAll('[USER]:', `[USER session-${i + 1}]:`).replaceAll('[ASSISTANT]:', `[ASSISTANT session-${i + 1}]:`), +).join('\n\n---\n\n') +writeFileSync(XLARGE_PATH, xlarge) + +console.log(`Wrote ${XLARGE_PATH} (${xlarge.length} bytes)`) + +// --------------------------------------------------------------------------- +// Deterministic stub services — same per run, so baselines are stable. +// --------------------------------------------------------------------------- + +function makeStubServices(): NodeServices { + return { + detectConflicts: async (facts) => ({ + decisions: facts.map((fact) => ({action: 'add' as const, fact})), + }), + async extract(chunk, _taskId) { + // Synthetic extraction: pull two facts per chunk based on string content. + const facts: Array<{statement: string; subject: string}> = [] + if (chunk.includes('JWT')) { + facts.push({statement: 'Auth uses JWT in httpOnly cookies', subject: 'auth'}) + } + + if (chunk.includes('PostgreSQL')) { + facts.push({statement: 'Database is PostgreSQL 15', subject: 'database'}) + } + + if (chunk.includes('rate limit') || chunk.includes('rate-limit')) { + facts.push({statement: 'Rate limit is 100/min per IP', subject: 'rate-limit'}) + } + + return { + facts, + failed: 0, + succeeded: facts.length, + total: 1, + } + }, + write: async (decisions) => ({ + applied: decisions.map((d) => ({ + confidence: 'high' as const, + impact: 'low' as const, + needsReview: false, + path: `${d.fact.subject ?? 'misc'}/${d.fact.statement.slice(0, 30)}.md`, + reason: 'baseline capture', + status: 'success' as const, + type: 'ADD' as const, + })), + summary: { + added: decisions.length, + deleted: 0, + failed: 0, + merged: 0, + updated: 0, + }, + }), + } +} + +// --------------------------------------------------------------------------- +// Run DAG against a fixture and capture outputs +// --------------------------------------------------------------------------- + +async function captureBaseline(label: string, fixturePath: string, outPath: string): Promise { + const context = readFileSync(fixturePath, 'utf8') + const ctx: NodeContext = { + initialInput: {context, history: {}, meta: {}}, + services: makeStubServices(), + taskId: `baseline-${label}`, + } + + const dag = buildCurationDAG() + const runner = new TopologicalCurationRunner() + const result = await runner.run(dag, ctx) + + const baseline = { + failures: result.failures, + fixture: { + bytes: context.length, + path: `test/fixtures/curation/${label}.txt`, + }, + outputs: Object.fromEntries( + [...result.outputs.entries()].map(([k, v]) => [k, v]), + ), + } + + writeFileSync(outPath, JSON.stringify(baseline, null, 2)) + console.log(`Wrote ${outPath}`) + console.log(` recon.suggestedMode: ${(baseline.outputs.recon as {suggestedMode: string}).suggestedMode}`) + console.log(` chunk.totalChunks: ${(baseline.outputs.chunk as {totalChunks: number}).totalChunks}`) + console.log(` extract.facts: ${(baseline.outputs.extract as {facts: unknown[]}).facts.length}`) + console.log(` write.summary: ${JSON.stringify((baseline.outputs.write as {summary: unknown}).summary)}`) +} + +await captureBaseline('small', SMALL_PATH, SMALL_BASELINE_PATH) +await captureBaseline('large', LARGE_PATH, LARGE_BASELINE_PATH) + +console.log('\nBaseline capture complete.') diff --git a/src/agent/core/curation/flow/index.ts b/src/agent/core/curation/flow/index.ts new file mode 100644 index 000000000..ac1db0af7 --- /dev/null +++ b/src/agent/core/curation/flow/index.ts @@ -0,0 +1,9 @@ +/** + * Curate-flow barrel export. + * + * Public API for the typed-slot curation DAG that supersedes the monolithic + * agent loop in `curate-executor.ts`. See plan/agent-driven-graph/DESIGN.md. + */ + +export {NODE_SLOT_ORDER} from './types.js' +export type {NodeSlot} from './types.js' diff --git a/src/agent/core/curation/flow/metrics.ts b/src/agent/core/curation/flow/metrics.ts new file mode 100644 index 000000000..9fa027089 --- /dev/null +++ b/src/agent/core/curation/flow/metrics.ts @@ -0,0 +1,82 @@ +/** + * Curate-flow metrics collector. + * + * Captures per-node timings and fallback signals during a single curate run. + * Phase 1 ships the data-shape only; daemon event-bus wire-up arrives in + * Phase 4 alongside `OutcomeRecorder` (see plan/agent-driven-graph/PLAN.md §5). + */ + +import {NODE_SLOT_ORDER, type NodeSlot} from './types.js' + +export interface CurateFlowRunEvent { + fallbacksTriggered: string[] + /** + * Per-slot timings in milliseconds. ALL slots are present (zero-filled + * to 0 for slots that did not run) so downstream event consumers can + * rely on `Record` shape without defensive lookups. + * See PHASE-1-IMPLEMENTATION.md Task 1.4 contract. + */ + nodeTimings: Record + taskId: string + totalWallClockMs: number + type: 'curate-flow:run' +} + +export class MetricsCollector { + private readonly fallbacks: string[] = [] + private firstStartMs?: number + private lastEndMs?: number + private readonly startTimes = new Map() + private readonly taskId: string + private readonly timings: Partial> = {} + + constructor(taskId: string) { + this.taskId = taskId + } + + public emit(): CurateFlowRunEvent { + const totalWallClockMs = + this.firstStartMs === undefined || this.lastEndMs === undefined + ? 0 + : this.lastEndMs - this.firstStartMs + + // Zero-fill ALL slots so downstream consumers can index by NodeSlot + // unconditionally. Slots that did not run report 0. + const nodeTimings = {} as Record + for (const slot of NODE_SLOT_ORDER) { + nodeTimings[slot] = this.timings[slot] ?? 0 + } + + return { + fallbacksTriggered: [...this.fallbacks], + nodeTimings, + taskId: this.taskId, + totalWallClockMs, + type: 'curate-flow:run', + } + } + + public endNode(slot: NodeSlot): void { + const start = this.startTimes.get(slot) + if (start === undefined) { + throw new Error(`MetricsCollector.endNode: no matching startNode for slot "${slot}"`) + } + + const now = performance.now() + this.timings[slot] = now - start + this.lastEndMs = now + this.startTimes.delete(slot) + } + + public recordFallback(label: string): void { + this.fallbacks.push(label) + } + + public startNode(slot: NodeSlot): void { + const now = performance.now() + this.startTimes.set(slot, now) + if (this.firstStartMs === undefined) { + this.firstStartMs = now + } + } +} diff --git a/src/agent/core/curation/flow/runner.ts b/src/agent/core/curation/flow/runner.ts new file mode 100644 index 000000000..e1a23a00b --- /dev/null +++ b/src/agent/core/curation/flow/runner.ts @@ -0,0 +1,318 @@ +/** + * Topological curation runner. + * + * Phase 1 — executes a `CurationDAG` via Kahn's algorithm with `pMap` + * bounded concurrency at each topological level. Per-node fail-open: + * if a node throws, the failure is recorded and downstream branches + * fed by that node's output are skipped, while parallel branches + * continue. Cycle detection: if Kahn's leaves any node with non-zero + * in-degree, throws `CycleDetectedError`. + * + * Phase 2 — every node executes inside a per-slot sandbox (timeout + + * tool allowlist + parent-signal propagation) and through a soft-mode + * schema gate (records validation issues without stranding downstream). + * The sandbox is the substrate Phase 3 plugs into when agent-supplied + * JS code starts running inside the slot. `ctx.sandboxed = false` is a + * test escape hatch for tests using synthetic input shapes. + * + * Algorithm port reference: + * - GPTSwarm/swarm/graph/graph.py:111 (Kahn's) + * - byterover-cli/src/agent/infra/swarm/engine/swarm-graph.ts (cycle detection) + * + * Input plumbing convention (consumed by `node.execute(input, ctx)`): + * - 0 predecessors → input = `ctx.initialInput` + * - 1 predecessor → input = predecessor's output verbatim + * - N predecessors → input = `Record` + * + * Phase 1 default DAG is purely linear (recon → ... → write), so the + * 0/1-predecessor cases dominate. The N-predecessor case exists for + * forward-compat with Phase 8 positional insertions. + */ + +import type {z} from 'zod' + +import pMap from 'p-map' + +import type { + conflictOutputSchema, + extractOutputSchema, + writeOutputSchema, +} from './slots/schemas.js' +import type {NodeSlot} from './types.js' + +import {type MetricsCollector} from './metrics.js' +import {SchemaValidationError} from './sandbox/errors.js' +import {validateAndRun} from './sandbox/schema-gate.js' +import {buildSlotSandbox} from './sandbox/slot-sandbox-builder.js' +import {slotContracts} from './slots/contracts.js' + +/** + * Per-slot service functions injected by Task 1.8 (cutover) and stubbed + * in node-level tests. Phase 2 will replace direct calls with sandboxed + * execution; the service interface stays the same so node code does not + * need to change. + */ +export interface NodeServices { + /** + * Detect conflicts between newly extracted facts and existing memory. + * + * The implementation is responsible for looking up its own existing + * memory (e.g., via SearchKnowledgeService). Earlier versions threaded + * an `existing` parameter from `ctx.initialInput.existing`, but the + * live adapter ignored it (test/prod mismatch); the signature is kept + * single-arg to keep both stubs and production aligned. + */ + readonly detectConflicts?: ( + facts: ReadonlyArray<{statement: string; subject?: string}>, + ) => Promise> + /** Run LLM extraction on a single chunk. */ + readonly extract?: ( + chunk: string, + taskId: string, + ) => Promise> + /** Apply conflict decisions to the context tree via curate-tool. */ + readonly write?: ( + decisions: z.infer['decisions'], + ) => Promise> +} + +export interface CurationNode { + execute(input: In, ctx: NodeContext): Promise + readonly id: string + readonly slot: NodeSlot +} + +export interface CurationDAG { + readonly edges: ReadonlyArray<{from: string; to: string}> + readonly entryNodeIds: ReadonlyArray + readonly exitNodeIds: ReadonlyArray + readonly maxConcurrency: number + readonly nodes: Readonly>> +} + +export interface NodeContext { + /** + * Concurrency for ExtractNode's per-chunk fan-out (Phase 2 Task 2.4). + * Defaults to 4 inside `extract-node.ts`. Tunable via curate-executor. + */ + readonly extractConcurrency?: number + readonly initialInput?: unknown + readonly metricsCollector?: MetricsCollector + /** + * When true (default), every node runs through the per-slot sandbox + * (timeout + tool allowlist + parent-signal propagation) and a + * soft-mode schema gate. Set false ONLY in tests using synthetic + * input shapes that don't match the real slot schemas. + */ + readonly sandboxed?: boolean + readonly services?: NodeServices + /** + * Externally-provided abort signal. Propagated to every node's slot + * sandbox so cancelling the parent (e.g., daemon shutdown) cascades + * into per-slot AbortControllers. + */ + readonly signal?: AbortSignal + /** + * Per-test override of `slotContracts[slot].timeoutMs`. Lets timeout + * tests run in tens of milliseconds without changing real defaults. + */ + readonly slotTimeoutOverrideMs?: number + readonly taskId: string + /** + * Tools surface exposed inside the sandbox. Phase 2 default nodes + * don't touch `tools.*` (they call `ctx.services.*` via closure), so + * an empty `{}` is the production default. Phase 3 wires the real + * `ToolsSDK` here when agent-supplied JS starts running in-slot. + */ + readonly tools?: Record +} + +export interface CurationRunResult { + readonly failures: ReadonlyArray<{error: string; nodeId: string}> + readonly outputs: Map +} + +export class CycleDetectedError extends Error { + constructor(remainingNodeIds: ReadonlyArray) { + super( + `Cycle detected in curation DAG: ${remainingNodeIds.length} node(s) still have unresolved predecessors after topological sort: ${remainingNodeIds.join(', ')}`, + ) + this.name = 'CycleDetectedError' + } +} + +export class TopologicalCurationRunner { + public async run(graph: CurationDAG, ctx: NodeContext): Promise { + const nodeIds = Object.keys(graph.nodes) + const inDegree = new Map() + const successors = new Map() + const predecessors = new Map() + + for (const id of nodeIds) { + inDegree.set(id, 0) + successors.set(id, []) + predecessors.set(id, []) + } + + for (const {from, to} of graph.edges) { + inDegree.set(to, (inDegree.get(to) ?? 0) + 1) + successors.get(from)?.push(to) + predecessors.get(to)?.push(from) + } + + const outputs = new Map() + const failures: Array<{error: string; nodeId: string}> = [] + const failed = new Set() + const completed = new Set() + + let ready = nodeIds.filter((id) => inDegree.get(id) === 0) + + while (ready.length > 0) { + const batch = ready + ready = [] + + // eslint-disable-next-line no-await-in-loop + await pMap( + batch, + async (nodeId) => { + const preds = predecessors.get(nodeId) ?? [] + + // Skip if any predecessor failed — downstream is stranded. + if (preds.some((p) => failed.has(p))) { + failed.add(nodeId) + failures.push({error: `skipped: predecessor failed`, nodeId}) + return + } + + let input: unknown + if (preds.length === 0) { + input = ctx.initialInput + } else if (preds.length === 1) { + input = outputs.get(preds[0]) + } else { + const merged: Record = {} + for (const p of preds) { + merged[p] = outputs.get(p) + } + + input = merged + } + + const node = graph.nodes[nodeId] + ctx.metricsCollector?.startNode(node.slot) + try { + const output = await this.executeNode(node, input, ctx) + outputs.set(nodeId, output.value) + completed.add(nodeId) + // Soft-mode schema warnings: record but treat output as valid + // for downstream so a too-strict schema doesn't strand the rest + // of the graph (Phase 2 plan §11 finding F5). + if (output.warning) { + failures.push({error: output.warning, nodeId}) + } + } catch (error) { + failed.add(nodeId) + const message = error instanceof Error ? error.message : String(error) + failures.push({error: message, nodeId}) + } finally { + ctx.metricsCollector?.endNode(node.slot) + } + }, + {concurrency: Math.max(1, graph.maxConcurrency)}, + ) + + // Release successors whose predecessors are all settled (completed or failed). + for (const nodeId of batch) { + for (const succId of successors.get(nodeId) ?? []) { + inDegree.set(succId, (inDegree.get(succId) ?? 0) - 1) + if ( + inDegree.get(succId) === 0 && + !completed.has(succId) && + !failed.has(succId) + ) { + ready.push(succId) + } + } + } + } + + // Cycle check: any node not yet settled means it has an in-edge cycle. + const unsettled = nodeIds.filter((id) => !completed.has(id) && !failed.has(id)) + if (unsettled.length > 0) { + throw new CycleDetectedError(unsettled) + } + + return {failures, outputs} + } + + /** + * Wraps a node execution with the per-slot sandbox + soft-mode schema + * gate. Returns the node's output value and an optional warning string + * (set when the schema gate flags input/output as invalid but we elect + * to keep the data flowing — Phase 2 plan §11 finding F5). + * + * `ToolAccessViolation` and `NodeTimeoutError` propagate as thrown + * errors so the caller can mark the node failed and strand downstream. + */ + private async executeNode( + node: CurationNode, + input: unknown, + ctx: NodeContext, + ): Promise<{value: unknown; warning?: string}> { + const sandboxed = ctx.sandboxed ?? true + + if (!sandboxed) { + const value = await node.execute(input, ctx) + return {value} + } + + const contract = slotContracts[node.slot] + const sandbox = buildSlotSandbox(node.slot, ctx.tools ?? {}, { + parentSignal: ctx.signal, + timeoutMsOverride: ctx.slotTimeoutOverrideMs, + }) + + const gateResult = await sandbox.runInSlot(async ({signal, tools}) => { + // Thread the proxied tools (allowlist-enforced) into slotCtx along + // with the per-slot signal. CRITICAL: spreading `...ctx` first then + // overriding tools/signal — if we kept `ctx.tools`, nodes could + // bypass the allowlist by reaching into the unfiltered surface. + // (PHASE-2-CODE-REVIEW E1.) + const slotCtx: NodeContext = {...ctx, signal, tools} + return validateAndRun({ + fn: async (validatedInput) => node.execute(validatedInput, slotCtx), + input, + inputSchema: contract.inputSchema, + mode: 'soft', + outputSchema: contract.outputSchema, + slot: node.slot, + }) + }) + + if (gateResult.ok) { + return {value: gateResult.value} + } + + // Output soft-fail (PHASE-2-CODE-REVIEW W4 + plan §11 finding F5): + // when the OUTPUT shape drifts, forward `rawOutput` so downstream + // can still consume the data; record a non-fatal warning. The raw + // output has the right schema family — only some field is off. + // + // INPUT-fail strands (W4 fix): when the INPUT shape is wrong, we + // have NO output to forward — passing raw `input` (which has a + // different schema family entirely) cascades confusing warnings + // across every downstream node. Throwing here strands downstream + // with a single clear failure, which is what the user expects. + if (gateResult.phase === 'input') { + throw new SchemaValidationError(node.slot, 'input', gateResult.issues) + } + + const issueSummary = gateResult.issues + .slice(0, 3) + .map((i) => `${i.path.join('.') || ''}: ${i.message}`) + .join('; ') + const warning = `slot '${node.slot}' output schema warning: ${issueSummary}` + + return {value: gateResult.rawOutput, warning} + } +} diff --git a/src/agent/core/curation/flow/sandbox/errors.ts b/src/agent/core/curation/flow/sandbox/errors.ts new file mode 100644 index 000000000..ec15ffccc --- /dev/null +++ b/src/agent/core/curation/flow/sandbox/errors.ts @@ -0,0 +1,58 @@ +/** + * Sandbox boundary errors for the curate-flow runtime. + * + * Each error carries the offending slot name so callers can route + * failures into per-slot recording without re-parsing message text. + * + * Phase 2 introduces these via plain JS wrappers; Phase 3 reuses the + * exact same types when promoting to the `node:vm` sandbox so the + * pause-on-failure protocol can switch on `error instanceof X`. + */ + +import type {NodeSlot} from '../types.js' + +export class ToolAccessViolation extends Error { + public readonly slot: NodeSlot + public readonly toolPath: string + + constructor(slot: NodeSlot, toolPath: string) { + super(`slot '${slot}' is not allowed to access tool '${toolPath}' (not in toolAllowlist)`) + this.name = 'ToolAccessViolation' + this.slot = slot + this.toolPath = toolPath + } +} + +export class NodeTimeoutError extends Error { + public readonly slot: NodeSlot + public readonly timeoutMs: number + + constructor(slot: NodeSlot, timeoutMs: number) { + super(`slot '${slot}' exceeded its ${timeoutMs}ms timeout and was aborted`) + this.name = 'NodeTimeoutError' + this.slot = slot + this.timeoutMs = timeoutMs + } +} + +export class SchemaValidationError extends Error { + public readonly issues: ReadonlyArray<{message: string; path: ReadonlyArray}> + public readonly phase: 'input' | 'output' + public readonly slot: NodeSlot + + constructor( + slot: NodeSlot, + phase: 'input' | 'output', + issues: ReadonlyArray<{message: string; path: ReadonlyArray}>, + ) { + const issueSummary = issues + .slice(0, 3) + .map((i) => `${i.path.join('.') || ''}: ${i.message}`) + .join('; ') + super(`slot '${slot}' ${phase} schema validation failed: ${issueSummary}`) + this.name = 'SchemaValidationError' + this.slot = slot + this.phase = phase + this.issues = issues + } +} diff --git a/src/agent/core/curation/flow/sandbox/schema-gate.ts b/src/agent/core/curation/flow/sandbox/schema-gate.ts new file mode 100644 index 000000000..6baa9369b --- /dev/null +++ b/src/agent/core/curation/flow/sandbox/schema-gate.ts @@ -0,0 +1,73 @@ +/** + * Schema validation gate for slot I/O. + * + * Wraps a slot function with Zod `safeParse` on input and output. Two + * modes: + * + * - `strict` — throws `SchemaValidationError` on either side. + * - `soft` — returns `{ok: false, phase, issues, rawOutput?}` so + * callers can record + continue (Phase 2 semantics per + * §11 finding F5; promoted to strict in Phase 3). + * + * Both modes share the same input-validation-before-fn-invocation + * contract: a schema-invalid input is never handed to `fn`. + */ + +import type {z} from 'zod' + +import type {NodeSlot} from '../types.js' + +import {SchemaValidationError} from './errors.js' + +export {SchemaValidationError} from './errors.js' + +export interface ValidateAndRunArgs { + fn: (input: In) => Promise + input: unknown + inputSchema: z.ZodType + mode: 'soft' | 'strict' + outputSchema: z.ZodType + slot: NodeSlot +} + +export type ValidateAndRunResult = + | {issues: ReadonlyArray<{message: string; path: ReadonlyArray}>; ok: false; phase: 'input' | 'output'; rawOutput?: unknown} + | {ok: true; value: Out} + +export async function validateAndRun( + args: ValidateAndRunArgs, +): Promise> { + const {fn, input, inputSchema, mode, outputSchema, slot} = args + + const inputResult = inputSchema.safeParse(input) + if (!inputResult.success) { + const issues = inputResult.error.issues.map((i) => ({ + message: i.message, + path: i.path, + })) + + if (mode === 'strict') { + throw new SchemaValidationError(slot, 'input', issues) + } + + return {issues, ok: false, phase: 'input'} + } + + const rawOutput = await fn(inputResult.data) + + const outputResult = outputSchema.safeParse(rawOutput) + if (!outputResult.success) { + const issues = outputResult.error.issues.map((i) => ({ + message: i.message, + path: i.path, + })) + + if (mode === 'strict') { + throw new SchemaValidationError(slot, 'output', issues) + } + + return {issues, ok: false, phase: 'output', rawOutput} + } + + return {ok: true, value: outputResult.data} +} diff --git a/src/agent/core/curation/flow/sandbox/slot-sandbox-builder.ts b/src/agent/core/curation/flow/sandbox/slot-sandbox-builder.ts new file mode 100644 index 000000000..4aa60173a --- /dev/null +++ b/src/agent/core/curation/flow/sandbox/slot-sandbox-builder.ts @@ -0,0 +1,176 @@ +/** + * Per-slot sandbox builder. + * + * Phase 2 implementation uses **plain JS wrappers** (no `node:vm`) per + * Phase 2 plan §11 finding F2: no untrusted code runs inside in Phase 2, + * so VM overhead (~10ms/slot × 7 slots) is unjustified. The interface is + * identical to a future VM-based variant — Phase 3 swaps the body when + * agent-supplied JS code starts running here. + * + * Two boundaries enforced today: + * 1. **Tool allowlist** — `tools` is a Proxy that throws + * `ToolAccessViolation` for any dotted key not in + * `slotContracts[slot].toolAllowlist`. + * 2. **Per-slot timeout** — `runInSlot` races the user fn against + * `setTimeout(timeoutMs)` via an AbortController; on timeout the + * shared `signal` fires (so cooperative fns can bail out) and + * `runInSlot` rejects with `NodeTimeoutError`. + * + * `parentSignal` lets the caller plumb an externally-provided + * AbortSignal (e.g., `NodeContext.signal`) — when the parent aborts, the + * slot's internal signal aborts too, resolving CODE-REVIEW finding #7. + * + * ### Known limit — sync-code timeout escape + * + * Plain JS `setTimeout`+`Promise.race` cannot preempt synchronous code: + * a busy loop holds the event loop and the abort timer can't fire until + * the loop yields. This means a node containing `while (Date.now() < end) {}` + * will run to completion past `timeoutMs` and resolve normally. + * + * Why this is acceptable in Phase 2: + * - All Phase 2 default nodes are async and call `ctx.services.*`, + * each of which awaits an LLM round-trip — they yield to the event + * loop, so the timer fires on time. + * - Default node code is *trusted code we wrote*, not agent-supplied + * untrusted JS. The threat model assumes default nodes don't + * deliberately busy-loop. + * + * Phase 3 fix: swap the body to `vm.runInContext({timeout: timeoutMs})`, + * which interrupts execution mid-instruction. That swap is also when + * untrusted agent-supplied JS first runs inside, which is exactly when + * sync-code timeout enforcement starts mattering. See + * `slot-sandbox-builder.test.ts` "documents Phase 2 sync-code timeout + * escape" for a regression that pins the current behavior. + */ + +import type {NodeSlot} from '../types.js' + +import {slotContracts} from '../slots/contracts.js' +import {NodeTimeoutError, ToolAccessViolation} from './errors.js' + +export {NodeTimeoutError, ToolAccessViolation} from './errors.js' + +export interface SlotSandboxRunArgs { + readonly signal: AbortSignal + readonly tools: Record +} + +export interface SlotSandbox { + runInSlot(fn: (args: SlotSandboxRunArgs) => Promise): Promise + readonly slot: NodeSlot +} + +export interface SlotSandboxOptions { + /** Externally-provided abort signal (e.g., `NodeContext.signal`). */ + readonly parentSignal?: AbortSignal + /** Override `slotContracts[slot].timeoutMs` (tests + bench). */ + readonly timeoutMsOverride?: number +} + +export function buildSlotSandbox( + slot: NodeSlot, + tools: Record, + options: SlotSandboxOptions = {}, +): SlotSandbox { + const contract = slotContracts[slot] + // Allowlist entries are written as `tools.` (the dotted form an + // agent-authored slot would type). The proxy traverses the bare `tools` + // object, so strip the leading `tools.` prefix here once. + const allowlist = new Set( + contract.toolAllowlist.map((entry) => (entry.startsWith('tools.') ? entry.slice(6) : entry)), + ) + const timeoutMs = options.timeoutMsOverride ?? contract.timeoutMs + + const allowedTools = buildAllowedToolsProxy(slot, tools, allowlist) + + return { + async runInSlot(fn: (args: SlotSandboxRunArgs) => Promise): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + + // Link parent signal: when parent aborts, our controller also aborts. + const parent = options.parentSignal + const onParentAbort = (): void => { + controller.abort() + } + + if (parent) { + if (parent.aborted) { + controller.abort() + } else { + parent.addEventListener('abort', onParentAbort, {once: true}) + } + } + + try { + const fnPromise = fn({signal: controller.signal, tools: allowedTools}) + const timeoutPromise = new Promise((_, reject) => { + controller.signal.addEventListener( + 'abort', + () => reject(new NodeTimeoutError(slot, timeoutMs)), + {once: true}, + ) + }) + + return await Promise.race([fnPromise, timeoutPromise]) + } finally { + clearTimeout(timer) + parent?.removeEventListener('abort', onParentAbort) + } + }, + slot, + } +} + +function buildAllowedToolsProxy( + slot: NodeSlot, + tools: Record, + allowlist: ReadonlySet, +): Record { + return new Proxy(tools, { + get(target, prop, receiver) { + if (typeof prop === 'symbol') { + return Reflect.get(target, prop, receiver) + } + + const value = Reflect.get(target, prop, receiver) + return wrapWithGuard(slot, allowlist, prop, value) + }, + }) +} + +function wrapWithGuard( + slot: NodeSlot, + allowlist: ReadonlySet, + pathSoFar: string, + value: unknown, +): unknown { + if (typeof value === 'function') { + if (!allowlist.has(pathSoFar)) { + // Returning a thrower (rather than throwing on get) keeps the + // Proxy's `get` trap free of side effects and lets call sites + // see the violation at invocation time. Surface the violation + // with the dotted `tools.` form (matching the allowlist syntax). + return () => { + throw new ToolAccessViolation(slot, `tools.${pathSoFar}`) + } + } + + return value + } + + if (value !== null && typeof value === 'object') { + return new Proxy(value as Record, { + get(target, prop, receiver) { + if (typeof prop === 'symbol') { + return Reflect.get(target, prop, receiver) + } + + const child = Reflect.get(target, prop, receiver) + return wrapWithGuard(slot, allowlist, `${pathSoFar}.${prop}`, child) + }, + }) + } + + return value +} diff --git a/src/agent/core/curation/flow/slots/contracts.ts b/src/agent/core/curation/flow/slots/contracts.ts new file mode 100644 index 000000000..f6cc79919 --- /dev/null +++ b/src/agent/core/curation/flow/slots/contracts.ts @@ -0,0 +1,80 @@ +/** + * Slot contracts registry — single source of truth for I/O shapes, + * tool allowlists, and per-slot timeouts. + * + * Phase 1: descriptive only. Phase 2 wires enforcement at the sandbox + * boundary. Phase 3+ adds promotion of agent-supplied node code into + * the harness store. + */ + +import type {NodeSlot} from '../types.js' +import type {SlotContract} from './types.js' + +import { + chunkInputSchema, + chunkOutputSchema, + conflictInputSchema, + conflictOutputSchema, + dedupInputSchema, + dedupOutputSchema, + extractInputSchema, + extractOutputSchema, + groupInputSchema, + groupOutputSchema, + reconInputSchema, + reconOutputSchema, + writeInputSchema, + writeOutputSchema, +} from './schemas.js' + +export const slotContracts: Record = { + chunk: { + inputSchema: chunkInputSchema, + outputSchema: chunkOutputSchema, + slot: 'chunk', + timeoutMs: 5000, + toolAllowlist: [], + }, + conflict: { + inputSchema: conflictInputSchema, + outputSchema: conflictOutputSchema, + slot: 'conflict', + timeoutMs: 30_000, + toolAllowlist: ['tools.curation.conflict'], + }, + dedup: { + inputSchema: dedupInputSchema, + outputSchema: dedupOutputSchema, + slot: 'dedup', + timeoutMs: 5000, + toolAllowlist: [], + }, + extract: { + inputSchema: extractInputSchema, + outputSchema: extractOutputSchema, + slot: 'extract', + timeoutMs: 60_000, + toolAllowlist: ['tools.curation.mapExtract'], + }, + group: { + inputSchema: groupInputSchema, + outputSchema: groupOutputSchema, + slot: 'group', + timeoutMs: 5000, + toolAllowlist: [], + }, + recon: { + inputSchema: reconInputSchema, + outputSchema: reconOutputSchema, + slot: 'recon', + timeoutMs: 10_000, + toolAllowlist: ['tools.curation.recon'], + }, + write: { + inputSchema: writeInputSchema, + outputSchema: writeOutputSchema, + slot: 'write', + timeoutMs: 30_000, + toolAllowlist: ['tools.curate'], + }, +} diff --git a/src/agent/core/curation/flow/slots/schemas.ts b/src/agent/core/curation/flow/slots/schemas.ts new file mode 100644 index 000000000..adbc32b49 --- /dev/null +++ b/src/agent/core/curation/flow/slots/schemas.ts @@ -0,0 +1,176 @@ +/** + * Zod schemas for the 7 default slots' I/O. + * + * These mirror runtime shapes produced by the actual curation helpers and + * tools (NOT a curated subset). Schemas serve as the future Phase 2 + * enforcement boundary, so they must accept what the default impls + * actually produce — otherwise the runner/sandbox will reject valid + * outputs or silently drop required fields. + * + * Reference shapes: + * recon() → src/agent/infra/sandbox/curation-helpers.ts (ReconResult) + * chunk() → src/agent/infra/sandbox/curation-helpers.ts (ChunkResult) + * mapExtract() → src/agent/infra/sandbox/tools-sdk.ts:184 (returns facts/succeeded/failed/total) + * groupBySubject → src/agent/infra/sandbox/curation-helpers.ts (Record) + * dedup() → src/agent/infra/sandbox/curation-helpers.ts (CurationFact[]) + * tools.curate() → src/agent/infra/tools/implementations/curate-tool.ts (CurateOutput: {applied, summary}) + * + * `CurationFact` (helpers) intentionally omits the `value` field that + * `CurateFact` (interfaces/i-curate-service.ts) carries — runtime extraction + * only produces category/statement/subject. + */ + +import {z} from 'zod' + +const curationFactSchema = z.object({ + category: z + .enum(['convention', 'environment', 'other', 'personal', 'preference', 'project', 'team']) + .optional(), + statement: z.string(), + subject: z.string().optional(), +}) + +const conflictDecisionSchema = z.object({ + action: z.enum(['add', 'update', 'merge', 'skip']), + existingId: z.string().optional(), + fact: curationFactSchema, + reason: z.string().optional(), +}) + +/** + * Mirrors `OperationResult` produced by `executeCurate`. + * See src/agent/infra/tools/implementations/curate-tool.ts:403. + * + * Required at runtime: confidence, impact, needsReview, path, reason, status, type. + * Status is only ever 'success' | 'failed' — NOT 'applied' | 'pending' | 'skipped'. + * Optional: additionalFilePaths, filePath, message, previousSummary, summary. + */ +const appliedOpSchema = z.object({ + additionalFilePaths: z.array(z.string()).optional(), + confidence: z.enum(['high', 'low']), + filePath: z.string().optional(), + impact: z.enum(['high', 'low']), + message: z.string().optional(), + needsReview: z.boolean(), + path: z.string(), + previousSummary: z.string().optional(), + reason: z.string(), + status: z.enum(['failed', 'success']), + summary: z.string().optional(), + type: z.enum(['ADD', 'DELETE', 'MERGE', 'UPDATE', 'UPSERT']), +}) + +const curateSummarySchema = z.object({ + added: z.number().int().nonnegative(), + deleted: z.number().int().nonnegative(), + failed: z.number().int().nonnegative(), + merged: z.number().int().nonnegative(), + updated: z.number().int().nonnegative(), +}) + +// --------------------------------------------------------------------------- +// recon — mirrors `ReconResult` from curation-helpers.ts +// --------------------------------------------------------------------------- + +export const reconInputSchema = z.object({ + context: z.string(), + history: z.record(z.string(), z.unknown()), + meta: z.record(z.string(), z.unknown()), +}) + +export const reconOutputSchema = z.object({ + headPreview: z.string(), + history: z.object({ + domains: z.record(z.string(), z.array(z.string())), + totalProcessed: z.number().int().nonnegative(), + }), + meta: z.object({ + charCount: z.number().int().nonnegative(), + lineCount: z.number().int().nonnegative(), + messageCount: z.number().int().nonnegative(), + }), + suggestedChunkCount: z.number().int().nonnegative(), + suggestedMode: z.enum(['single-pass', 'chunked']), + tailPreview: z.string(), +}) + +// --------------------------------------------------------------------------- +// chunk — input is recon's output. The original `context` is pulled from +// `ctx.initialInput.context` rather than threaded via the edge (DAG runners +// pass single-predecessor output verbatim; threading context separately +// would force every downstream slot to repeat fields it doesn't use). +// --------------------------------------------------------------------------- + +export const chunkInputSchema = reconOutputSchema + +export const chunkOutputSchema = z.object({ + boundaries: z.array(z.object({end: z.number().int(), start: z.number().int()})), + chunks: z.array(z.string()), + totalChunks: z.number().int().nonnegative(), +}) + +// --------------------------------------------------------------------------- +// extract — input is chunk's output. Node maps `input.chunks` through the +// extract service; `taskId` comes from `ctx.taskId`. Phase 1 was sequential; +// Phase 2 (Task 2.4) parallelizes via `pMap` INSIDE the node at +// `ctx.extractConcurrency ?? 4` — the DAG still has a single extract-node +// instance so the runner stays slot-agnostic. +// --------------------------------------------------------------------------- + +export const extractInputSchema = chunkOutputSchema + +export const extractOutputSchema = z.object({ + facts: z.array(curationFactSchema), + failed: z.number().int().nonnegative(), + succeeded: z.number().int().nonnegative(), + total: z.number().int().nonnegative(), +}) + +// --------------------------------------------------------------------------- +// group — input is extract's output (uses `facts`, ignores counts). +// Wraps `groupBySubject` from curation-helpers.ts in `{grouped: ...}` for +// uniform DAG-edge output shape. +// --------------------------------------------------------------------------- + +export const groupInputSchema = extractOutputSchema + +export const groupOutputSchema = z.object({ + grouped: z.record(z.string(), z.array(curationFactSchema)), +}) + +// --------------------------------------------------------------------------- +// dedup — input is group's output. Output uses `{deduped: ...}` so the +// downstream conflict slot can clearly distinguish "post-dedup" facts. +// --------------------------------------------------------------------------- + +export const dedupInputSchema = groupOutputSchema + +export const dedupOutputSchema = z.object({ + deduped: z.array(curationFactSchema), +}) + +// --------------------------------------------------------------------------- +// conflict — input is dedup's output. The existing-memory comparison set +// is pulled from `ctx.initialInput.existing` (or empty if absent), not from +// the edge. Phase 1 is single-shot detection; self-consistency vote lands +// in a later phase per the original curate-flow plan. +// --------------------------------------------------------------------------- + +export const conflictInputSchema = dedupOutputSchema + +export const conflictOutputSchema = z.object({ + decisions: z.array(conflictDecisionSchema), +}) + +// --------------------------------------------------------------------------- +// write — mirrors `executeCurate` return shape from curate-tool.ts +// --------------------------------------------------------------------------- + +export const writeInputSchema = z.object({ + decisions: z.array(conflictDecisionSchema), +}) + +export const writeOutputSchema = z.object({ + applied: z.array(appliedOpSchema), + summary: curateSummarySchema, +}) diff --git a/src/agent/core/curation/flow/slots/types.ts b/src/agent/core/curation/flow/slots/types.ts new file mode 100644 index 000000000..c1df96361 --- /dev/null +++ b/src/agent/core/curation/flow/slots/types.ts @@ -0,0 +1,30 @@ +/** + * Slot contract — the typed interface a curate-flow node must satisfy. + * + * Phase 1 ships descriptive contracts only: schemas document the I/O shape + * but are NOT yet enforced at slot boundaries (the runner just calls + * `node.execute(input)` directly). Phase 2 introduces the per-slot vm + * sandbox that validates input/output against the schemas, enforces the + * tool allowlist, and applies the timeout. + * + * The `defaultImpl` field is intentionally absent in Phase 1 — the + * `slotContracts` registry exposes contracts only. Default implementations + * live alongside as `nodes/{slot}-node.ts` (added in Task 1.6) and are + * wired together by `dag-builder.ts` (added in Task 1.7). + */ + +import type {z} from 'zod' + +import type {NodeSlot} from '../types.js' + +export interface SlotContract { + readonly inputSchema: z.ZodType + readonly outputSchema: z.ZodType + readonly slot: NodeSlot + readonly timeoutMs: number + /** + * Per-slot tool allowlist. Empty = pure-JS slot (no `tools.*` access). + * Phase 2 enforces this at the sandbox boundary. + */ + readonly toolAllowlist: ReadonlyArray +} diff --git a/src/agent/core/curation/flow/types.ts b/src/agent/core/curation/flow/types.ts new file mode 100644 index 000000000..42b528fa1 --- /dev/null +++ b/src/agent/core/curation/flow/types.ts @@ -0,0 +1,34 @@ +/** + * Curate-flow type primitives. + * + * Phase 1 scaffold — see plan/agent-driven-graph/PHASE-1-IMPLEMENTATION.md. + * Slot contracts (input/output schemas, tool allowlist, timeouts) live in + * `./slots/`. The runner and node interfaces live in `./runner-types.ts`. + */ + +export type NodeSlot = + | 'chunk' + | 'conflict' + | 'dedup' + | 'extract' + | 'group' + | 'recon' + | 'write' + +/** + * Canonical execution order of the default curate DAG. + * + * Used by `dag-builder` to wire the default linear topology and by tests + * to assert ordering. Future phases (positional insertion) will continue + * to respect this order for the base slots; insertions land at named + * extension points between adjacent base slots. + */ +export const NODE_SLOT_ORDER: ReadonlyArray = [ + 'recon', + 'chunk', + 'extract', + 'group', + 'dedup', + 'conflict', + 'write', +] as const diff --git a/src/agent/core/domain/knowledge/conflict-detector.ts b/src/agent/core/domain/knowledge/conflict-detector.ts index b9823cf37..c5586ef0f 100644 --- a/src/agent/core/domain/knowledge/conflict-detector.ts +++ b/src/agent/core/domain/knowledge/conflict-detector.ts @@ -1,14 +1,24 @@ -import type {ContextData, Narrative, RawConcept} from '../../../../server/core/domain/knowledge/markdown-writer.js' +import type {ContextData, Fact, Narrative, RawConcept} from '../../../../server/core/domain/knowledge/markdown-writer.js' import {normalize} from './utils.js' /** * Summary of detected structural loss when comparing existing vs proposed content. + * + * R-1 hotfix (PHASE-2-UAT.md §5.2): added `lostFacts`, `lostKeywords`, + * `lostTags`. Pre-fix the detector ignored these three arrays, so + * Phase 2's per-fact UPDATE pattern silently dropped existing facts + * (Scenario 4 fact-loss bug). Phase 1's monolithic agent loop hid + * this by writing richer ContextData per UPDATE. */ export type StructuralLoss = { hasLoss: boolean /** Number of rawConcept array items (changes, files) that would be lost */ lostArrayItems: number + /** Number of facts (deduped by statement) that would be lost */ + lostFacts: number + /** Number of keywords that would be lost */ + lostKeywords: number /** Number of narrative fields that would be lost */ lostNarrativeFields: number /** Number of rawConcept scalar fields that would be lost */ @@ -17,6 +27,8 @@ export type StructuralLoss = { lostRelations: number /** Number of snippets that would be lost */ lostSnippets: number + /** Number of tags that would be lost */ + lostTags: number } /** @@ -27,6 +39,17 @@ function countLostItems(existing: string[], proposed: string[]): number { return existing.filter((item) => !proposedSet.has(normalize(item))).length } +/** + * Find facts in `existing` whose `statement` (case-insensitive) is not + * present in `proposed`. Statement is the unique identity for a Fact — + * matches the dedup key used in markdown-writer.ts and + * conflict-resolver.ts `mergeFactsByStatement`. + */ +function countLostFacts(existing: Fact[], proposed: Fact[]): number { + const proposedSet = new Set(proposed.map((f) => normalize(f.statement))) + return existing.filter((f) => !proposedSet.has(normalize(f.statement))).length +} + /** * Count narrative fields that exist in `existing` but are absent in `proposed`. */ @@ -78,17 +101,33 @@ export function detectStructuralLoss(existing: ContextData, proposed: ContextDat existing.rawConcept, proposed.rawConcept, ) + // R-1 hotfix: facts/keywords/tags must be detected so the resolver can + // merge them back. Without this, executeUpdate silently overwrites + // existing facts (PHASE-2-UAT.md Scenario 4). + const lostFacts = countLostFacts(existing.facts ?? [], proposed.facts ?? []) + const lostKeywords = countLostItems(existing.keywords ?? [], proposed.keywords ?? []) + const lostTags = countLostItems(existing.tags ?? [], proposed.tags ?? []) const hasLoss = - lostSnippets > 0 || lostRelations > 0 || lostNarrativeFields > 0 || lostRawConceptFields > 0 || lostArrayItems > 0 + lostSnippets > 0 || + lostRelations > 0 || + lostNarrativeFields > 0 || + lostRawConceptFields > 0 || + lostArrayItems > 0 || + lostFacts > 0 || + lostKeywords > 0 || + lostTags > 0 return { hasLoss, lostArrayItems, + lostFacts, + lostKeywords, lostNarrativeFields, lostRawConceptFields, lostRelations, lostSnippets, + lostTags, } } diff --git a/src/agent/core/domain/knowledge/conflict-resolver.ts b/src/agent/core/domain/knowledge/conflict-resolver.ts index dca9b4a4b..5106002c5 100644 --- a/src/agent/core/domain/knowledge/conflict-resolver.ts +++ b/src/agent/core/domain/knowledge/conflict-resolver.ts @@ -1,4 +1,4 @@ -import type {ContextData, Narrative, RawConcept} from '../../../../server/core/domain/knowledge/markdown-writer.js' +import type {ContextData, Fact, Narrative, RawConcept} from '../../../../server/core/domain/knowledge/markdown-writer.js' import type {StructuralLoss} from './conflict-detector.js' import {normalize} from './utils.js' @@ -30,6 +30,41 @@ function mergeArraysWithDedup(existing: string[], proposed: string[]): string[] return result } +/** + * Union-merge facts deduped by `statement` (case-insensitive). + * + * Existing first preserves richer `category`/`subject`/`value` fields when + * the same statement appears in both lists. Phase 2's services-adapter + * sends one fact per UPDATE; existing-wins preserves established metadata + * if the LLM re-extracted the same fact with less detail. + * + * R-1 hotfix (PHASE-2-UAT.md §5.3) — see `mergeArraysWithDedup` for the + * string-array equivalent and `conflict-detector.ts countLostFacts` for + * the matching detection helper. + */ +function mergeFactsByStatement(existing: Fact[], proposed: Fact[]): Fact[] { + const seen = new Set() + const result: Fact[] = [] + + for (const f of existing) { + const key = normalize(f.statement) + if (!seen.has(key)) { + seen.add(key) + result.push(f) + } + } + + for (const f of proposed) { + const key = normalize(f.statement) + if (!seen.has(key)) { + seen.add(key) + result.push(f) + } + } + + return result +} + /** * Merge narrative fields. * Use proposed if provided, otherwise preserve existing (prevents data loss). @@ -106,11 +141,18 @@ function mergeRawConcept(existing?: RawConcept, proposed?: RawConcept): RawConce * Auto-resolve structural loss by merging existing content into proposed content. * * Resolution strategy: - * - Arrays (snippets, relations, changes, files): Union merge with deduplication - * - Scalars (narrative fields, rawConcept scalars): Proposed wins; preserve existing if proposed empty + * - Arrays (facts, keywords, tags, snippets, relations, changes, files): + * Union merge with deduplication (existing first, new appended). + * - Scalars (narrative fields, rawConcept scalars): Proposed wins; + * preserve existing if proposed empty. * * Only runs when `loss.hasLoss` is true. When no loss is detected, returns proposed as-is. * + * R-1 hotfix (PHASE-2-UAT.md §5.3): facts/keywords/tags added to the + * merge set. Pre-fix, executeUpdate silently overwrote existing facts + * because Phase 2's services-adapter sends one fact per UPDATE op + * (Scenario 4 fact-loss bug). + * * @param existing - Parsed content from the existing file (before update) * @param proposed - Proposed new content from the curate operation * @param loss - Structural loss summary from detectStructuralLoss @@ -125,9 +167,12 @@ export function resolveStructuralLoss( return { ...proposed, + facts: mergeFactsByStatement(existing.facts ?? [], proposed.facts ?? []), + keywords: mergeArraysWithDedup(existing.keywords ?? [], proposed.keywords ?? []), narrative: mergeNarrative(existing.narrative, proposed.narrative), rawConcept: mergeRawConcept(existing.rawConcept, proposed.rawConcept), relations: mergeArraysWithDedup(existing.relations ?? [], proposed.relations ?? []), snippets: mergeArraysWithDedup(existing.snippets ?? [], proposed.snippets ?? []), + tags: mergeArraysWithDedup(existing.tags ?? [], proposed.tags ?? []), } } diff --git a/src/agent/infra/curation/flow/dag-builder.ts b/src/agent/infra/curation/flow/dag-builder.ts new file mode 100644 index 000000000..4f7767ec7 --- /dev/null +++ b/src/agent/infra/curation/flow/dag-builder.ts @@ -0,0 +1,54 @@ +/** + * DAG builder for the default Phase 1 curate-flow topology. + * + * Linear chain: recon → chunk → extract → group → dedup → conflict → write. + * Every node is identified by its slot name (e.g., id === 'recon'). Phase 8 + * (positional insertion) will allow user-defined slots between adjacent + * base slots; Phase 1 ships only this canonical chain. + */ + +import type {CurationDAG, CurationNode} from '../../../core/curation/flow/runner.js' + +import {NODE_SLOT_ORDER} from '../../../core/curation/flow/types.js' +import {createChunkNode} from './nodes/chunk-node.js' +import {createConflictNode} from './nodes/conflict-node.js' +import {createDedupNode} from './nodes/dedup-node.js' +import {createExtractNode} from './nodes/extract-node.js' +import {createGroupNode} from './nodes/group-node.js' +import {createReconNode} from './nodes/recon-node.js' +import {createWriteNode} from './nodes/write-node.js' + +export interface CurationDAGConfig { + /** + * Bounded concurrency for sibling-node execution at the same topological + * level. Phase 1 default is 1 (sequential); Phase 2 lifts this for + * parallel ExtractNode fan-out. + */ + readonly maxConcurrency?: number +} + +export function buildCurationDAG(config?: CurationDAGConfig): CurationDAG { + const nodes: Record> = { + chunk: createChunkNode() as CurationNode, + conflict: createConflictNode() as CurationNode, + dedup: createDedupNode() as CurationNode, + extract: createExtractNode() as CurationNode, + group: createGroupNode() as CurationNode, + recon: createReconNode() as CurationNode, + write: createWriteNode() as CurationNode, + } + + // Build the linear chain by walking NODE_SLOT_ORDER pairs. + const edges: Array<{from: string; to: string}> = [] + for (let i = 0; i < NODE_SLOT_ORDER.length - 1; i++) { + edges.push({from: NODE_SLOT_ORDER[i], to: NODE_SLOT_ORDER[i + 1]}) + } + + return { + edges, + entryNodeIds: [NODE_SLOT_ORDER[0]], + exitNodeIds: [NODE_SLOT_ORDER.at(-1) as string], + maxConcurrency: config?.maxConcurrency ?? 1, + nodes, + } +} diff --git a/src/agent/infra/curation/flow/existing-memory-loader.ts b/src/agent/infra/curation/flow/existing-memory-loader.ts new file mode 100644 index 000000000..a148525b9 --- /dev/null +++ b/src/agent/infra/curation/flow/existing-memory-loader.ts @@ -0,0 +1,79 @@ +/** + * Loader for existing memory passed to the conflict-node. + * + * Phase 1: queries the context tree via SearchKnowledgeService for each + * unique subject in the new facts. For each matched document, records the + * subject as "already known" along with the matched file path (used as + * `existingId` so conflict-node can emit `update` decisions). + * + * Phase 2 may parse facts out of matched markdown files for richer + * conflict checks; Phase 1 only signals subject existence. + */ + +import type {ISearchKnowledgeService} from '../../sandbox/tools-sdk.js' + +export interface ExistingMemoryEntry { + /** Path of the existing context-tree file holding this subject. */ + existingId?: string + /** + * A statement-shaped string. For Phase 1 this is just the file path + * since we don't parse fact content out of markdown yet. + */ + statement: string + subject?: string +} + +/** + * Look up subjects in the context tree and return discovered entries. + * + * Returns an empty array if no search service is provided or all searches + * fail (fail-open: a missing existing-memory check should not block curation). + */ +export async function loadExistingMemory( + searchService: ISearchKnowledgeService | undefined, + subjects: ReadonlyArray, + options: {limitPerSubject?: number} = {}, +): Promise { + if (!searchService || subjects.length === 0) { + return [] + } + + const limit = options.limitPerSubject ?? 3 + const seen = new Set() + const entries: ExistingMemoryEntry[] = [] + + for (const subject of subjects) { + if (seen.has(subject)) continue + seen.add(subject) + try { + // eslint-disable-next-line no-await-in-loop + const result = await searchService.search(subject, {limit}) + for (const match of result.results ?? []) { + const existingId = match.path + entries.push({ + existingId, + statement: existingId ?? subject, + subject, + }) + } + } catch { + // Fail-open: skip this subject if search throws. + } + } + + return entries +} + +/** + * Collect unique subjects from a fact list (helper for callers). + */ +export function uniqueSubjects( + facts: ReadonlyArray<{subject?: string}>, +): string[] { + const subjects = new Set() + for (const fact of facts) { + if (fact.subject) subjects.add(fact.subject) + } + + return [...subjects] +} diff --git a/src/agent/infra/curation/flow/metadata-derivers.ts b/src/agent/infra/curation/flow/metadata-derivers.ts new file mode 100644 index 000000000..72a1cd5ab --- /dev/null +++ b/src/agent/infra/curation/flow/metadata-derivers.ts @@ -0,0 +1,175 @@ +/** + * Phase 2.5 R-2 — deterministic metadata derivers (PHASE-2.5-PLAN.md §3.3). + * + * Derives `tags`, `keywords`, and `related` for curate operations from + * the fact + batch context already in hand — no extra LLM call. + * + * Phase 3 UAT flagged that 86/86 leaf files had empty `tags`/`keywords`/ + * `related`, blocking the `brv query` cross-link layer. This module + * unblocks that with cheap heuristics; Phase 4's enrichment slot can + * overlay LLM-derived metadata later without breaking Phase 2.5 data. + * + * Lives in its own module (not scope-private inside `services-adapter.ts`) + * so it can be unit-tested directly without test-only exports — the + * helpers are pure functions over plain inputs with no infra deps. + */ + +import {toSnakeCase} from '../../../../server/utils/file-helpers.js' + +export interface DerivableFact { + category?: string + statement: string + subject?: string +} + +export interface DerivableDecision { + readonly action: string + readonly fact: DerivableFact +} + +/** + * Tokens excluded from `keywords`. Curated short-list — adding more + * stopwords is fine; bloat (1000+ entries with duplicates) is not. + */ +const STOP_WORDS = new Set([ + 'a', 'after', 'all', 'also', 'an', 'and', 'any', 'are', 'as', 'at', + 'be', 'been', 'before', 'being', 'but', 'by', 'did', 'do', 'does', 'each', + 'for', 'from', 'has', 'have', 'he', 'how', 'in', 'is', + 'it', 'its', 'just', 'no', 'not', 'of', 'on', 'only', + 'or', 'so', 'such', 'than', 'that', 'the', 'their', 'then', 'these', + 'this', 'those', 'to', 'too', 'was', 'were', 'what', 'when', + 'where', 'which', 'who', 'whom', 'whose', 'why', 'will', 'with', +]) + +/** + * Tag set for a fact: lowercase {category, subject} dedup'd. + * Used by the BM25 ranker as a high-signal facet during retrieval. + */ +export function deriveTags(fact: DerivableFact): string[] { + const tags: string[] = [] + if (fact.category) tags.push(fact.category.toLowerCase()) + if (fact.subject) tags.push(fact.subject.toLowerCase()) + return [...new Set(tags)] +} + +/** + * Keyword bag: subject (first) + meaningful tokens from the statement, + * stop-word-filtered and capped at 8. + */ +export function deriveKeywords(fact: DerivableFact): string[] { + const out: string[] = [] + if (fact.subject) out.push(fact.subject.toLowerCase()) + const words = fact.statement + .toLowerCase() + .replaceAll(/[^a-z0-9_\s]/g, ' ') + .split(/\s+/) + .filter((w) => w.length > 2 && !STOP_WORDS.has(w)) + for (const w of words) { + if (!out.includes(w)) out.push(w) + } + + return out.slice(0, 8) +} + +/** + * Related sibling paths in the same batch: cross-link decisions sharing + * the same `category` but with distinct subjects. Emits a 3-segment path + * `//` matching R-4's file layout + * (`//.md`). + * + * Slug parity: every segment runs through `toSnakeCase` — the same helper + * `executeAdd`/`executeUpdate` use to slug filenames. Without this, + * subjects with hyphens or punctuation (`rate-limit`, `same=site`) would + * produce relation paths that don't match the actual on-disk file. + * + * Cross-tree relations (BM25 against existing files) are deferred to + * Phase 4 dream consolidation — would need a SearchService dependency. + */ +export function deriveRelated( + current: DerivableDecision, + allDecisions: ReadonlyArray, +): string[] { + const related = new Set() + for (const other of allDecisions) { + if (other === current) continue + if ( + other.fact.category && + other.fact.category === current.fact.category && + other.fact.subject && + other.fact.subject !== current.fact.subject + ) { + const cat = toSnakeCase(other.fact.category) + const sub = toSnakeCase(other.fact.subject) + related.add(`${cat}/${sub}/${sub}`) + } + } + + return [...related] +} + +/** + * Resolved decision: a `DerivableDecision` paired with the on-disk + * `(path, title)` it routes to. The two-pass services-adapter.write + * computes this map BEFORE building operations, so `deriveRelatedFromResolved` + * has the information it needs to filter out cross-links that would point + * to non-existent files. + */ +export interface ResolvedDecision { + readonly decision: DerivableDecision + readonly path: string + readonly title: string +} + +/** + * NEW-1 (PHASE-2.6-PLAN.md §3.2) — like `deriveRelated` but operates over + * RESOLVED target paths, so: + * - Same-category decisions whose target file is the current decision's + * own file (R-4 UPSERT collision OR cross-batch UPDATE merge) are + * filtered out — they're not "siblings", they ARE the same file. + * - The emitted path matches what executeCurate will actually write + * (uses `toSnakeCase` per segment per slug-parity contract from + * Phase 2.5 §3.3 P1). + * + * Cross-tree relations (BM25 against existing files outside this batch) + * are still deferred to Phase 4 dream consolidation. + * + * Phase 4 UAT exposed the bug: when 3 decisions for distinct subjects + * (jwt_token_ttl, jwt_storage, samesite) all UPDATE-merged into one + * existing file, the original `deriveRelated` emitted phantom paths to + * `//.md` that were never materialized. + */ +export function deriveRelatedFromResolved( + current: ResolvedDecision, + allResolved: ReadonlyArray, +): string[] { + const currentTargetKey = `${slugifyPath(current.path)}/${toSnakeCase(current.title)}` + const related = new Set() + for (const other of allResolved) { + if (other === current) continue + + const otherTargetKey = `${slugifyPath(other.path)}/${toSnakeCase(other.title)}` + if (otherTargetKey === currentTargetKey) continue // same file — not related, IS me + + // Same-category cross-link logic (preserved from deriveRelated). + if ( + other.decision.fact.category && + other.decision.fact.category === current.decision.fact.category && + other.decision.fact.subject && + other.decision.fact.subject !== current.decision.fact.subject + ) { + related.add(otherTargetKey) + } + } + + return [...related] +} + +/** + * Slug each `/`-segment with `toSnakeCase` so a path like + * `project/rate-limit` matches the on-disk `project/rate_limit/`. + * `toSnakeCase` itself collapses `[^\w]+` → `_`, which would mangle + * the `/` separators if applied to the whole path. + */ +function slugifyPath(path: string): string { + return path.split('/').map((seg) => toSnakeCase(seg)).join('/') +} diff --git a/src/agent/infra/curation/flow/nodes/chunk-node.ts b/src/agent/infra/curation/flow/nodes/chunk-node.ts new file mode 100644 index 000000000..a9e368be6 --- /dev/null +++ b/src/agent/infra/curation/flow/nodes/chunk-node.ts @@ -0,0 +1,52 @@ +/** + * Chunk node — pure JS, no LLM. + * + * Wraps `chunk()` from src/agent/infra/sandbox/curation-helpers.ts. + * Phase 1: takes recon's full output as edge input (uses `suggestedChunkCount` + * from it) and pulls the original text from `ctx.initialInput.context`. + * + * The runner threads slot-specific data via edges; shared run state + * (original context) lives in `ctx.initialInput`. We narrow that + * `unknown`-typed payload via Zod `safeParse` (no `as` casts). + */ + +import {z} from 'zod' + +import type {CurationNode, NodeContext} from '../../../../core/curation/flow/runner.js' +import type {chunkInputSchema, chunkOutputSchema} from '../../../../core/curation/flow/slots/schemas.js' + +import {chunk as chunkHelper} from '../../../sandbox/curation-helpers.js' + +export type ChunkInput = z.infer +export type ChunkOutput = z.infer + +const initialInputContextSchema = z.object({context: z.string()}).partial() + +function readContextFromCtx(ctx: NodeContext): string { + const parsed = initialInputContextSchema.safeParse(ctx.initialInput) + return parsed.success && typeof parsed.data.context === 'string' ? parsed.data.context : '' +} + +export function createChunkNode(id = 'chunk'): CurationNode { + return { + async execute(input: ChunkInput, ctx: NodeContext): Promise { + const context = readContextFromCtx(ctx) + + if (!context || context.length === 0) { + return {boundaries: [], chunks: [], totalChunks: 0} + } + + const target = input.suggestedChunkCount > 0 ? input.suggestedChunkCount : 1 + const size = target === 1 ? context.length : Math.ceil(context.length / target) + + const result = chunkHelper(context, {size}) + return { + boundaries: result.boundaries, + chunks: result.chunks, + totalChunks: result.totalChunks, + } + }, + id, + slot: 'chunk', + } +} diff --git a/src/agent/infra/curation/flow/nodes/conflict-node.ts b/src/agent/infra/curation/flow/nodes/conflict-node.ts new file mode 100644 index 000000000..439a06621 --- /dev/null +++ b/src/agent/infra/curation/flow/nodes/conflict-node.ts @@ -0,0 +1,46 @@ +/** + * Conflict node — service-bound (LLM via `services.detectConflicts`). + * + * Receives dedup's output (`{deduped: CurationFact[]}`). The service is + * responsible for sourcing the existing-memory comparison set itself + * (e.g., via SearchKnowledgeService); the node does not pass it. + * + * Phase 1: single-shot detection. Fail-open: if the service throws, every + * input fact becomes an `add` decision so the run keeps moving forward. + */ + +import type {z} from 'zod' + +import type {CurationNode, NodeContext} from '../../../../core/curation/flow/runner.js' +import type {conflictInputSchema, conflictOutputSchema} from '../../../../core/curation/flow/slots/schemas.js' + +export type ConflictInput = z.infer +export type ConflictOutput = z.infer + +export function createConflictNode(id = 'conflict'): CurationNode { + return { + async execute(input: ConflictInput, ctx: NodeContext): Promise { + // Empty input — short-circuit (no LLM call needed). + if (input.deduped.length === 0) { + return {decisions: []} + } + + if (!ctx.services?.detectConflicts) { + throw new Error( + 'conflict node requires ctx.services.detectConflicts — no conflict-detection service provided', + ) + } + + try { + return await ctx.services.detectConflicts(input.deduped) + } catch { + // Fail-open: every input fact becomes an add decision. + return { + decisions: input.deduped.map((fact) => ({action: 'add' as const, fact})), + } + } + }, + id, + slot: 'conflict', + } +} diff --git a/src/agent/infra/curation/flow/nodes/dedup-node.ts b/src/agent/infra/curation/flow/nodes/dedup-node.ts new file mode 100644 index 000000000..c0b7d7339 --- /dev/null +++ b/src/agent/infra/curation/flow/nodes/dedup-node.ts @@ -0,0 +1,30 @@ +/** + * Dedup node — pure JS, no LLM. + * + * Wraps `dedup()` from src/agent/infra/sandbox/curation-helpers.ts. + * Phase 1: flattens grouped facts and applies Jaccard similarity dedup + * (threshold = 0.85). The tier-S reranker for tie-break is a Phase-2+ + * concern — see plan/curate-flow/IMPLEMENTATION.md. + */ + +import type {z} from 'zod' + +import type {CurationNode, NodeContext} from '../../../../core/curation/flow/runner.js' +import type {dedupInputSchema, dedupOutputSchema} from '../../../../core/curation/flow/slots/schemas.js' + +import {dedup as dedupHelper} from '../../../sandbox/curation-helpers.js' + +export type DedupInput = z.infer +export type DedupOutput = z.infer + +export function createDedupNode(id = 'dedup'): CurationNode { + return { + async execute(input: DedupInput, _ctx: NodeContext): Promise { + const flat = Object.values(input.grouped).flat() + const deduped = dedupHelper(flat) + return {deduped} + }, + id, + slot: 'dedup', + } +} diff --git a/src/agent/infra/curation/flow/nodes/extract-node.ts b/src/agent/infra/curation/flow/nodes/extract-node.ts new file mode 100644 index 000000000..9e9231a7a --- /dev/null +++ b/src/agent/infra/curation/flow/nodes/extract-node.ts @@ -0,0 +1,67 @@ +/** + * Extract node — service-bound (LLM via `services.extract`). + * + * Phase 2 (Task 2.4) parallelizes per-chunk extraction via `pMap`. The + * default DAG keeps ONE extract-node instance (the runner doesn't fan + * out N nodes); concurrency happens INSIDE this node so the runner + * remains agnostic of slot-specific tuning. + * + * Concurrency: `ctx.extractConcurrency ?? 4`. Real-LLM benchmarks (UAT + * scenario 3) showed sequential ~6 min on 37 KB / ~10 chunks; the goal + * is ~90–120s under concurrency 4. The bench harness in + * `test/benchmarks/curate-flow.bench.ts` tracks this across commits. + * + * Aggregation: facts concatenated in pMap input order; counts summed. + * Per-chunk failures bubble up as a thrown error from the underlying + * service, which `pMap` propagates as a rejection — this surfaces as a + * `NodeTimeoutError` if the slot's outer timeout fires, otherwise as + * the original error which the runner records into `result.failures`. + */ + +import type {z} from 'zod' + +import pMap from 'p-map' + +import type {CurationNode, NodeContext} from '../../../../core/curation/flow/runner.js' +import type {extractInputSchema, extractOutputSchema} from '../../../../core/curation/flow/slots/schemas.js' + +export type ExtractInput = z.infer +export type ExtractOutput = z.infer + +const DEFAULT_EXTRACT_CONCURRENCY = 4 + +export function createExtractNode(id = 'extract'): CurationNode { + return { + async execute(input: ExtractInput, ctx: NodeContext): Promise { + if (input.chunks.length === 0) { + return {facts: [], failed: 0, succeeded: 0, total: 0} + } + + const extract = ctx.services?.extract + if (!extract) { + throw new Error( + 'extract node requires ctx.services.extract — no extraction service provided', + ) + } + + const concurrency = Math.max(1, ctx.extractConcurrency ?? DEFAULT_EXTRACT_CONCURRENCY) + const partials = await pMap( + input.chunks, + (chunk) => extract(chunk, ctx.taskId), + {concurrency}, + ) + + const aggregate: ExtractOutput = {facts: [], failed: 0, succeeded: 0, total: 0} + for (const partial of partials) { + aggregate.facts.push(...partial.facts) + aggregate.failed += partial.failed + aggregate.succeeded += partial.succeeded + aggregate.total += partial.total + } + + return aggregate + }, + id, + slot: 'extract', + } +} diff --git a/src/agent/infra/curation/flow/nodes/group-node.ts b/src/agent/infra/curation/flow/nodes/group-node.ts new file mode 100644 index 000000000..12cbdebc7 --- /dev/null +++ b/src/agent/infra/curation/flow/nodes/group-node.ts @@ -0,0 +1,28 @@ +/** + * Group node — pure JS, no LLM. + * + * Wraps `groupBySubject()` from src/agent/infra/sandbox/curation-helpers.ts. + * Receives extract's output (uses `facts`, ignores extraction counts). + * Buckets facts by subject (falling back to category, then 'uncategorized'). + */ + +import type {z} from 'zod' + +import type {CurationNode, NodeContext} from '../../../../core/curation/flow/runner.js' +import type {groupInputSchema, groupOutputSchema} from '../../../../core/curation/flow/slots/schemas.js' + +import {groupBySubject} from '../../../sandbox/curation-helpers.js' + +export type GroupInput = z.infer +export type GroupOutput = z.infer + +export function createGroupNode(id = 'group'): CurationNode { + return { + async execute(input: GroupInput, _ctx: NodeContext): Promise { + const grouped = groupBySubject(input.facts) + return {grouped} + }, + id, + slot: 'group', + } +} diff --git a/src/agent/infra/curation/flow/nodes/recon-node.ts b/src/agent/infra/curation/flow/nodes/recon-node.ts new file mode 100644 index 000000000..6332b6bc5 --- /dev/null +++ b/src/agent/infra/curation/flow/nodes/recon-node.ts @@ -0,0 +1,37 @@ +/** + * Recon node — pure JS in Phase 1. + * + * Wraps `recon()` from src/agent/infra/sandbox/curation-helpers.ts. + * The helper is fully deterministic (character-count thresholds + history + * summarization). Phase 1 ships this verbatim. The plan allows future + * LLM refinement of `suggestedMode` / `suggestedChunkCount` on edge + * cases — that's a Phase 2+ enhancement, not blocking Phase 1. + */ + +import type {z} from 'zod' + +import type {CurationNode, NodeContext} from '../../../../core/curation/flow/runner.js' +import type {reconInputSchema, reconOutputSchema} from '../../../../core/curation/flow/slots/schemas.js' + +import {recon as reconHelper} from '../../../sandbox/curation-helpers.js' + +export type ReconInput = z.infer +export type ReconOutput = z.infer + +export function createReconNode(id = 'recon'): CurationNode { + return { + async execute(input: ReconInput, _ctx: NodeContext): Promise { + const result = reconHelper(input.context, input.meta, input.history) + return { + headPreview: result.headPreview, + history: result.history, + meta: result.meta, + suggestedChunkCount: result.suggestedChunkCount, + suggestedMode: result.suggestedMode, + tailPreview: result.tailPreview, + } + }, + id, + slot: 'recon', + } +} diff --git a/src/agent/infra/curation/flow/nodes/write-node.ts b/src/agent/infra/curation/flow/nodes/write-node.ts new file mode 100644 index 000000000..28d12f73d --- /dev/null +++ b/src/agent/infra/curation/flow/nodes/write-node.ts @@ -0,0 +1,42 @@ +/** + * Write node — service-bound (delegates to `services.write` which wraps + * the existing curate-tool `executeCurate`). + * + * Phase 1: pure adapter. The curate-tool itself is NOT modified — + * write-node just shapes its `applied[]` + `summary` output back into + * the slot's output contract. + */ + +import type {z} from 'zod' + +import type {CurationNode, NodeContext} from '../../../../core/curation/flow/runner.js' +import type {writeInputSchema, writeOutputSchema} from '../../../../core/curation/flow/slots/schemas.js' + +export type WriteInput = z.infer +export type WriteOutput = z.infer + +const EMPTY_SUMMARY = { + added: 0, + deleted: 0, + failed: 0, + merged: 0, + updated: 0, +} as const + +export function createWriteNode(id = 'write'): CurationNode { + return { + async execute(input: WriteInput, ctx: NodeContext): Promise { + if (input.decisions.length === 0) { + return {applied: [], summary: {...EMPTY_SUMMARY}} + } + + if (!ctx.services?.write) { + throw new Error('write node requires ctx.services.write — no write service provided') + } + + return ctx.services.write(input.decisions) + }, + id, + slot: 'write', + } +} diff --git a/src/agent/infra/curation/flow/services-adapter.ts b/src/agent/infra/curation/flow/services-adapter.ts new file mode 100644 index 000000000..fd07a495a --- /dev/null +++ b/src/agent/infra/curation/flow/services-adapter.ts @@ -0,0 +1,379 @@ +/** + * Live services adapter — wires the abstract `NodeServices` to ByteRover's + * real infrastructure (LLM via `agent.generate`, write via `executeCurate`). + * + * Used by `curate-executor.ts` to back the curate-flow DAG. Tests inject + * stub services directly and bypass this module. + * + * Phase 1 design notes: + * - extract: one LLM call per chunk via `agent.generate` (NO agent loop) + * - detectConflicts: per-fact subject lookup against the context tree via + * the injected `lookupSubject` function. Subjects already present + * produce 'update' decisions; others produce 'add'. Phase 2 may add + * LLM-driven conflict reasoning. + * - write: builds a CurateInput from decisions and calls executeCurate + * directly (same path the curate-tool uses today). + */ + +import type {NodeServices} from '../../../core/curation/flow/runner.js' +import type {ICipherAgent} from '../../../core/interfaces/i-cipher-agent.js' +import type {ExistingMemoryEntry} from './existing-memory-loader.js' + +import {executeCurate} from '../../tools/implementations/curate-tool.js' +import {deriveKeywords, deriveRelatedFromResolved, deriveTags, type ResolvedDecision} from './metadata-derivers.js' + +/** + * Phase 2.5 R-3 (PHASE-2.5-PLAN.md §3.4) — describes where the curate + * input came from. Threaded into buildReason so the per-leaf `Reason` + * field carries source provenance for audit-trail. + */ +export interface SourceProvenance { + /** Human-readable source name (e.g., "cli-text", "package.json", "stdin"). */ + readonly name: string + /** Source type — file, text (cli paste), stdin. */ + readonly type: 'file' | 'stdin' | 'text' +} + +interface BuildLiveServicesDeps { + readonly agent: ICipherAgent + readonly basePath?: string + /** + * Curate-log identifier (cur-) generated by `CurateLogHandler.onTaskCreate`, + * forwarded by task-router → agent-process → curate-executor → here. + * When present, used as the correlation id in `Reason` (preferred over `taskId`). + * Optional — undefined for direct test invocations that bypass the router. + */ + readonly logId?: string + /** + * Look up existing memory entries for a given subject (e.g., via + * SearchKnowledgeService). Returns empty array if nothing matches. + * Failures should be swallowed by the caller — conflict detection is + * fail-open on missing data (treat unknown as 'add'). + */ + readonly lookupSubject: (subject: string) => Promise + /** R-3: source provenance threaded into Reason envelope. */ + readonly provenance?: SourceProvenance + /** R-3: runner taskId, used as fallback correlation id when logId is undefined. */ + readonly taskId?: string +} + +const EXTRACTION_PROMPT_PREFIX = `Extract concrete factual statements from the text below. Output ONLY a JSON array of objects matching this schema: + [ + {"subject": "string (required)", "statement": "string (required)", "category": "convention | environment | other | personal | preference | project | team (optional)"} + ] +Return an empty array \`[]\` if no clear facts are present. Do NOT wrap the array in any prose, code fence, or commentary. + +TEXT: +` + +interface ExtractedFact { + category?: 'convention' | 'environment' | 'other' | 'personal' | 'preference' | 'project' | 'team' + statement: string + subject?: string +} + +/** + * Parse a context-tree relative path from the existing-memory loader back + * into the `(path, title)` shape executeUpdate expects. + * + * Input examples (the loader sets `existingId = SearchKnowledgeResult.path`, + * which is relative to the context tree root): + * "project/auth/jwt_tokens.md" → {path: "project/auth", title: "jwt_tokens"} + * "project/auth/oauth/refresh.md" → {path: "project/auth/oauth", title: "refresh"} + * "/project/auth/jwt_tokens.md" → same as above (leading slash stripped) + * "project/auth" → undefined (only 2 segments — no title) + * "single-segment" → undefined + * + * Returns undefined when the input cannot be safely interpreted; the caller + * then falls back to ADD instead of issuing a guaranteed-failure UPDATE. + */ +function parseExistingIdForUpdate(existingId: string): undefined | {path: string; title: string} { + const cleaned = existingId.replaceAll(/^\/+|\/+$/g, '').replace(/\.md$/, '') + const parts = cleaned.split('/').filter(Boolean) + // Need at least domain/topic/title (3) and at most domain/topic/subtopic/title (4). + if (parts.length < 3 || parts.length > 4) { + return undefined + } + + const title = parts.at(-1) ?? '' + const path = parts.slice(0, -1).join('/') + if (!title || !path) return undefined + return {path, title} +} + +/** + * Phase A coarsening (PR578-TOKEN-REGRESSION-FIX-PLAN.md §3.1) — derive a + * topic from a subject's first underscore-token. Replaces the old per-subject + * topic derivation that scattered conceptually-related facts into distinct + * folders (e.g. `auth_login_endpoint`, `auth_token_storage` → 2 folders). + * + * "auth_login_endpoint" → "auth" + * "rate_limit_default" → "rate" + * "redis" → "redis" (single-token: unchanged) + * "_foo" → "foo" (filter drops empty leading segment) + * "" → undefined (caller falls back to 'misc') + * undefined → undefined (caller falls back to 'misc') + * + * Why first-token: the LLM extractor consistently uses snake_case subjects + * where the first segment is the conceptual category. First-token clustering + * preserves that grouping deterministically, no LLM call required. Subsequent + * same-prefix curates land in the same folder by construction (same input → + * same path). + */ +function derivePrefixTopic(subject: string | undefined): string | undefined { + if (!subject) return undefined + return subject.split('_').find(Boolean) +} + +function parseFactsFromLlmResponse(content: string): ExtractedFact[] { + // Strip code fences if the LLM wrapped the JSON despite instructions. + const cleaned = content + .replace(/^```(?:json)?\n/, '') + .replace(/\n```$/, '') + .trim() + try { + const parsed = JSON.parse(cleaned) as unknown + if (!Array.isArray(parsed)) return [] + const out: ExtractedFact[] = [] + for (const item of parsed) { + if (typeof item !== 'object' || item === null) continue + const i = item as {category?: unknown; statement?: unknown; subject?: unknown} + if (typeof i.statement !== 'string' || i.statement.length === 0) continue + out.push({ + category: + typeof i.category === 'string' ? (i.category as ExtractedFact['category']) : undefined, + statement: i.statement, + subject: typeof i.subject === 'string' ? i.subject : undefined, + }) + } + + return out + } catch { + return [] + } +} + +/** + * Phase 2.5 R-3 (PHASE-2.5-PLAN.md §3.4) — build the per-leaf `Reason` + * envelope satisfying the Phase 3 UAT-target shape: + * + * Curated from on via :""; + * subject "" [] inferred from "". + * Decision: ← appended ONLY when present (UPDATE case) + * + * **Critical (review P2)**: the envelope is ALWAYS emitted. UPDATE + * decisions previously kept their `detectConflicts` placeholder and + * bypassed provenance entirely, defeating audit-trail. Now `d.reason` + * is appended INSIDE the envelope as `Decision: `, never replaces it. + * + * Correlation id prefers `cur-` (router-generated, what the UAT + * cited) over the runner's UUID `taskId`. The taskId fallback exists + * for direct test invocations that bypass the router. + * + * Statement preview (~80 chars) is the closest available proxy for the + * UAT's "input quote". Phase 4 upgrades this to actual chunk text via + * a per-decision `chunkIndex` (requires bumping the conflict-decision + * schema; deferred). + * + * Exported so it can be unit-tested in isolation; not test-only. + */ +export function buildReason( + decision: {readonly action: string; readonly fact: {category?: string; statement: string; subject?: string}; readonly reason?: string}, + taskId: string | undefined, + logId: string | undefined, + provenance: SourceProvenance, +): string { + const correlationId = logId ?? taskId ?? '' + const ts = new Date().toISOString() + const subjectPart = decision.fact.subject ? `"${decision.fact.subject}"` : '' + const categoryPart = decision.fact.category ? `[${decision.fact.category}]` : '[uncategorized]' + const inputQuote = decision.fact.statement + .slice(0, 80) + .replaceAll(/\s+/g, ' ') + .trim() + + const envelope = `Curated from ${correlationId} on ${ts} via ${provenance.type}:"${provenance.name}"; subject ${subjectPart} ${categoryPart} inferred from "${inputQuote}".` + + return decision.reason ? `${envelope} Decision: ${decision.reason}` : envelope +} + +export function buildLiveServices(deps: BuildLiveServicesDeps): NodeServices { + // R-3: provenance defaults to a no-op shape when caller doesn't supply it + // (e.g., direct test invocations). buildReason still emits a valid envelope. + const provenance: SourceProvenance = deps.provenance ?? {name: 'unknown', type: 'text'} + return { + async detectConflicts(facts) { + // Per-fact subject lookup. Cache within the call to avoid repeat + // searches for facts sharing a subject. + const lookupCache = new Map() + const decisions = await Promise.all( + facts.map(async (fact) => { + if (!fact.subject) { + return {action: 'add' as const, fact} + } + + let existingEntries = lookupCache.get(fact.subject) + if (!existingEntries) { + try { + existingEntries = await deps.lookupSubject(fact.subject) + } catch { + existingEntries = [] + } + + lookupCache.set(fact.subject, existingEntries) + } + + if (existingEntries.length === 0) { + return {action: 'add' as const, fact} + } + + const matched = existingEntries[0] + return { + action: 'update' as const, + existingId: matched.existingId, + fact, + reason: `subject "${fact.subject}" already present at ${matched.existingId ?? 'unknown path'}`, + } + }), + ) + return {decisions} + }, + + async extract(chunk, taskId) { + const prompt = `${EXTRACTION_PROMPT_PREFIX}${chunk}` + const response = await deps.agent.generate(prompt, {taskId}) + const facts = parseFactsFromLlmResponse(response.content) + return { + facts, + failed: facts.length === 0 ? 1 : 0, + succeeded: facts.length === 0 ? 0 : 1, + total: 1, + } + }, + + async write(decisions) { + // executeCurate's parsePath requires `path` to be `domain/topic` or + // `domain/topic/subtopic` (2-3 segments) — see curate-tool.ts:689. + // A single segment like "auth" is rejected with "Invalid path format". + // + // UPDATE path: honor `existingId` (the path of the matched existing + // file, supplied by detectConflicts via the existing-memory loader). + // executeUpdate looks up `${path}/${snake_case(title)}.md`, so we + // must split existingId back into path + title rather than deriving + // a fresh title (which would point to a file that doesn't exist). + // + // ADD/UPSERT path (Phase A coarsening, PR578 fix plan §3.1): + // - domain = fact.category (or 'extracted' for uncategorized facts) + // - topic = derivePrefixTopic(fact.subject) (first underscore-token) + // or 'misc' when subject is missing + // - title = topic (so multiple subjects sharing a prefix collide + // on the same target file at `${domain}/${topic}/${topic}.md`) + // + // Two-pass shape (NEW-1, PHASE-2.6-PLAN §3.2): + // Pass 1 resolves each decision's (path, title, type). + // Pass 2 (Phase B batching, PR578 §3.1): groups resolved[] by + // `${path}/${title}` and emits one operation per bucket + // with merged facts/keywords/tags/relations/reasons. + // deriveRelatedFromResolved is unioned across the bucket so + // cross-links never reference non-materialized files. + // + // R-4 / NEW-2 UPSERT consolidation is unchanged in spirit but operates + // on a coarser key. Same-prefix subjects (e.g. 'auth_login_endpoint', + // 'auth_token_storage') now share path+title and batch in Pass 2. + // Cross-prefix subjects stay in distinct folders. + + // Pass 1: resolve target path/title/type for every decision. + const resolved: Array = decisions.map((d) => { + const updateTarget = d.action === 'update' && d.existingId + ? parseExistingIdForUpdate(d.existingId) + : undefined + + if (updateTarget) { + return {decision: d, path: updateTarget.path, title: updateTarget.title, type: 'UPDATE'} + } + + const domain = d.fact.category ?? 'extracted' + const topic = derivePrefixTopic(d.fact.subject) ?? 'misc' + return {decision: d, path: `${domain}/${topic}`, title: topic, type: 'UPSERT'} + }) + + // Pass 2 (Phase B): aggregate decisions by (path, title) → one operation + // per target file with merged `facts`. Phase A's coarsening produces + // the collisions; Phase B consumes them. + const targetKey = (r: typeof resolved[number]): string => `${r.path}/${r.title}` + + const byTarget = new Map>() + for (const r of resolved) { + const key = targetKey(r) + const bucket = byTarget.get(key) ?? [] + bucket.push(r) + byTarget.set(key, bucket) + } + + const operations = [...byTarget.values()].map((rs) => { + // Type resolution: UPDATE wins over UPSERT. A bucket can mix UPDATE + // (existingId hit, R-1 merge contract) with UPSERT (new path, fresh + // write). UPDATE must take precedence so executeUpdate's structural- + // loss merge runs. + const type: 'ADD' | 'UPDATE' | 'UPSERT' = rs.some((r) => r.type === 'UPDATE') ? 'UPDATE' : rs[0].type + const primary = rs[0] + + const facts = rs.map((r) => ({ + category: r.decision.fact.category, + statement: r.decision.fact.statement, + subject: r.decision.fact.subject, + })) + + // R-2 (PHASE-2.5-PLAN §3.3): deterministic metadata derivation. No + // LLM call. Union keywords/tags across the bucket; cap keywords at 8 + // (existing schema limit, see metadata-derivers.ts). + const keywords = [...new Set(rs.flatMap((r) => deriveKeywords(r.decision.fact)))].slice(0, 8) + const tags = [...new Set(rs.flatMap((r) => deriveTags(r.decision.fact)))] + + // NEW-1: union relations across all bucket members so cross-link + // coverage isn't lost. deriveRelatedFromResolved already filters to + // materialized files, so cross-batch UPDATE merges remain correct. + const relations = [...new Set(rs.flatMap((r) => deriveRelatedFromResolved(r, resolved)))] + + // R-3 (PHASE-2.5-PLAN §3.4): join per-fact reason envelopes with + // newlines so each decision's audit-trail context survives. `reason` + // is written to the markdown body's `## Reason` section + // (markdown-writer.ts:550-552), NOT to YAML frontmatter — multi-line + // strings are safe. + const reason = rs + .map((r) => buildReason(r.decision, deps.taskId, deps.logId, provenance)) + .join('\n') + + // Summary is a machine-facing preview; truncation mid-statement after + // join is intentional. Reviewers read `## Reason` for full context. + const summary = facts + .map((f) => f.statement) + .join('; ') + .slice(0, 200) + + // Phase-6+ enhancement (deferred): if rs.length exceeds ~20, consider + // splitting back to per-subject buckets to bound executeUpdate's + // structural-loss work. Empirically buckets are <10 facts; not gating + // the initial fix. + + return { + confidence: 'high' as const, + content: {facts, keywords, relations, tags}, + impact: 'low' as const, + path: primary.path, + reason, + summary, + title: primary.title, + type, + } + }) + + const result = await executeCurate({basePath: deps.basePath, operations}) + + return { + applied: result.applied, + summary: result.summary, + } + }, + } +} diff --git a/src/agent/infra/tools/implementations/curate-tool.ts b/src/agent/infra/tools/implementations/curate-tool.ts index 73f17e7c4..8a400bb50 100644 --- a/src/agent/infra/tools/implementations/curate-tool.ts +++ b/src/agent/infra/tools/implementations/curate-tool.ts @@ -1006,20 +1006,31 @@ async function executeUpdate( let resolvedContextData: ContextData = proposedContextData let elevatedImpact = impact + let mergeHappened = false if (existingParsed) { const loss = detectStructuralLoss(existingParsed, proposedContextData) const structuralImpact = deriveImpactFromLoss(loss) elevatedImpact = maxImpact(impact, structuralImpact) resolvedContextData = resolveStructuralLoss(existingParsed, proposedContextData, loss) + mergeHappened = loss.hasLoss } const reviewMeta = deriveReviewMetadata('UPDATE', confidence, elevatedImpact) + // R-6 (PHASE-2.5-PLAN.md §3.2): when facts are merged in via R-1's + // resolveStructuralLoss, the operation's `summary` only reflects the + // new (single) fact and the frontmatter goes stale relative to + // `## Facts`. Regenerate summary as `; `-joined statements (capped 200). + const effectiveSummary = + mergeHappened && (resolvedContextData.facts?.length ?? 0) > 1 + ? resolvedContextData.facts!.map((f) => f.statement).join('; ').slice(0, 200) + : summary + const contextContent = MarkdownWriter.generateContext({ ...resolvedContextData, reason, - summary, + summary: effectiveSummary, timestamps, }) await backupBeforeWrite(contextPath, basePath, reviewDisabled) diff --git a/src/oclif/commands/gather.ts b/src/oclif/commands/gather.ts new file mode 100644 index 000000000..c6cf3a044 --- /dev/null +++ b/src/oclif/commands/gather.ts @@ -0,0 +1,274 @@ +/* eslint-disable camelcase -- DESIGN §6.2 specifies snake_case for the gather payload */ +import type {ITransportClient, TaskAck} from '@campfirein/brv-transport-client' + +import {Args, Command, Flags} from '@oclif/core' +import {randomUUID} from 'node:crypto' + +import type {GatherResult} from '../../server/core/interfaces/executor/i-gather-executor.js' + +import {TaskEvents} from '../../shared/transport/events/index.js' +import {encodeGatherContent} from '../../shared/transport/gather-content.js' +import { + type DaemonClientOptions, + formatConnectionError, + hasLeakedHandles, + withDaemonRetry, +} from '../lib/daemon-client.js' +import {writeJsonResponse} from '../lib/json-response.js' +import {waitForTaskCompletion} from '../lib/task-client.js' + +export default class Gather extends Command { + public static args = { + query: Args.string({ + description: 'Question to gather context for (no LLM synthesis — bundle is returned for inspection)', + required: true, + }), + } + public static description = `Assemble an LLM-free context bundle from the context tree + +Returns the prefetched markdown bundle, search metadata, token estimate, and +follow-up hints — the same payload that the brv_gather MCP tool returns to +external agents. Useful for: + • Debugging "what context would the agent see for this query?" + • Pipelining: brv gather "..." --format json | external-llm + • Inspecting bundle size before paying tokens for synthesis + +Use "brv search" for ranked BM25 results without bundle assembly. +Use "brv query" when you want the synthesized answer.` + public static examples = [ + '# Gather context for an agent-style question', + '<%= config.bin %> <%= command.id %> "how does authentication work"', + '', + '# Restrict scope and cap result count', + '<%= config.bin %> <%= command.id %> "JWT tokens" --scope auth/ --limit 5', + '', + '# Cap token budget for large knowledge bases', + '<%= config.bin %> <%= command.id %> "auth" --token-budget 8000', + '', + '# JSON output (pipeline-friendly)', + '<%= config.bin %> <%= command.id %> "auth" --format json | jq .total_tokens_estimated', + ] + public static flags = { + format: Flags.string({ + default: 'text', + description: 'Output format (text or json)', + options: ['text', 'json'], + }), + limit: Flags.integer({ + default: 10, + description: 'Maximum number of BM25 results to include (1-50)', + max: 50, + min: 1, + }), + scope: Flags.string({ + description: 'Path prefix to scope results (e.g. "auth/" for auth domain only)', + }), + 'token-budget': Flags.integer({ + description: 'Soft cap on bundle tokens (default 4000). Truncates excess sections.', + max: 64_000, + min: 100, + }), + } + // Allow unknown flags for forward-compatibility (e.g., new daemon flags + // passed through by wrapper scripts without requiring a CLI upgrade). + public static strict = false + + protected getDaemonClientOptions(): DaemonClientOptions { + return {} + } + + public async run(): Promise { + const {args, flags} = await this.parse(Gather) + const format: 'json' | 'text' = flags.format === 'json' ? 'json' : 'text' + + if (!this.validateInput(args.query, format)) return + + try { + await withDaemonRetry( + async (client, projectRoot, worktreeRoot) => { + await this.submitTask({ + client, + format, + limit: flags.limit, + projectRoot, + query: args.query, + scope: flags.scope, + tokenBudget: flags['token-budget'], + worktreeRoot, + }) + }, + { + ...this.getDaemonClientOptions(), + onRetry: + format === 'text' + ? (attempt, maxRetries) => + this.log(`\nConnection lost. Restarting daemon... (attempt ${attempt}/${maxRetries})`) + : undefined, + }, + ) + } catch (error) { + this.reportError(error, format) + } + } + + private formatTextOutput(gatherResult: GatherResult): string[] { + const lines: string[] = [] + const meta = gatherResult.search_metadata + + lines.push( + '', + `Search metadata: ${meta.result_count} result(s), top score ${meta.top_score.toFixed(2)}, total found ${meta.total_found}`, + `Bundle tokens (estimated): ${gatherResult.total_tokens_estimated}`, + ) + + if (gatherResult.prefetched_context) { + lines.push('', '=== Prefetched context ===', '', gatherResult.prefetched_context) + } else { + lines.push('', '(No high-confidence passages above the score threshold.)') + } + + if (gatherResult.manifest_context) { + lines.push('', '=== Manifest context ===', '', gatherResult.manifest_context) + } + + if (gatherResult.follow_up_hints && gatherResult.follow_up_hints.length > 0) { + lines.push('', 'Follow-up hints:') + for (const hint of gatherResult.follow_up_hints) { + lines.push(` - ${hint}`) + } + } + + lines.push('') + return lines + } + + private reportError(error: unknown, format: 'json' | 'text'): void { + const errorMessage = error instanceof Error ? error.message : 'Gather failed' + + if (format === 'json') { + writeJsonResponse({command: 'gather', data: {error: errorMessage, status: 'error'}, success: false}) + } else { + this.log(formatConnectionError(error)) + } + + if (hasLeakedHandles(error)) { + // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit + process.exit(1) + } + } + + private async submitTask(props: { + client: ITransportClient + format: 'json' | 'text' + limit?: number + projectRoot?: string + query: string + scope?: string + tokenBudget?: number + worktreeRoot?: string + }): Promise { + const {client, format, projectRoot, query, worktreeRoot} = props + const taskId = randomUUID() + + const contentPayload = encodeGatherContent({ + ...(props.limit === undefined ? {} : {limit: props.limit}), + query, + ...(props.scope === undefined ? {} : {scope: props.scope}), + ...(props.tokenBudget === undefined ? {} : {tokenBudget: props.tokenBudget}), + }) + + const taskPayload = { + clientCwd: process.cwd(), + content: contentPayload, + ...(projectRoot ? {projectPath: projectRoot} : {}), + taskId, + type: 'gather' as const, + ...(worktreeRoot ? {worktreeRoot} : {}), + } + + const completionPromise = waitForTaskCompletion( + { + client, + command: 'gather', + format, + onCompleted: ({result}) => { + if (!result) { + if (format === 'json') { + writeJsonResponse({ + command: 'gather', + data: {prefetched_context: '', status: 'completed'}, + success: true, + }) + } else { + this.log('\nNo results.\n') + } + + return + } + + try { + const gatherResult = JSON.parse(result) as GatherResult + + if (format === 'json') { + writeJsonResponse({ + command: 'gather', + data: {...gatherResult, status: 'completed'}, + success: true, + }) + } else { + for (const line of this.formatTextOutput(gatherResult)) { + this.log(line) + } + } + } catch { + if (format === 'json') { + writeJsonResponse({ + command: 'gather', + data: {error: 'Invalid gather result format', raw: result, status: 'error'}, + success: false, + }) + } else { + this.log(`\n${result}\n`) + } + } + }, + onError: ({error}) => { + if (format === 'json') { + writeJsonResponse({ + command: 'gather', + data: {event: 'error', message: error.message, status: 'error'}, + success: false, + }) + } else { + this.log(`\nError: ${error.message}\n`) + } + }, + taskId, + }, + (msg) => this.log(msg), + ) + + await client.requestWithAck(TaskEvents.CREATE, taskPayload) + await completionPromise + } + + private validateInput(query: string, format: 'json' | 'text'): boolean { + if (query.trim()) return true + + if (format === 'json') { + writeJsonResponse({ + command: 'gather', + data: {message: 'Gather query is required.', status: 'error'}, + success: false, + }) + } else { + this.log('Gather query is required.') + this.log('Usage: brv gather "your question here"') + } + + // PHASE-5-UAT.md UAT-14: empty input must exit non-zero so CI scripts + // can detect failure (`brv gather "" || echo failed`). + process.exitCode = 1 + return false + } +} diff --git a/src/oclif/commands/record-answer.ts b/src/oclif/commands/record-answer.ts new file mode 100644 index 000000000..8956f150e --- /dev/null +++ b/src/oclif/commands/record-answer.ts @@ -0,0 +1,239 @@ +import type {ITransportClient, TaskAck} from '@campfirein/brv-transport-client' + +import {Args, Command, Flags} from '@oclif/core' +import {randomUUID} from 'node:crypto' + +import type {RecordAnswerResult} from '../../server/core/interfaces/executor/i-record-answer-executor.js' + +import {TaskEvents} from '../../shared/transport/events/index.js' +import {encodeRecordAnswerContent} from '../../shared/transport/record-answer-content.js' +import { + type DaemonClientOptions, + formatConnectionError, + hasLeakedHandles, + withDaemonRetry, +} from '../lib/daemon-client.js' +import {writeJsonResponse} from '../lib/json-response.js' +import {waitForTaskCompletion} from '../lib/task-client.js' + +/* eslint-disable perfectionist/sort-objects -- positional CLI args MUST preserve declaration order; oclif uses property iteration order to map argv positions to arg names */ +export default class RecordAnswer extends Command { + public static args = { + query: Args.string({ + description: 'The query that the answer responds to (must match the prior brv search/gather query)', + required: true, + }), + answer: Args.string({ + description: 'The agent-synthesized answer to cache', + required: true, + }), + } + /* eslint-enable perfectionist/sort-objects */ + public static description = `Cache an agent-synthesized answer for future tier-0/1 hits + +Closes the cache loop for the LLM-free pipeline: + brv search → brv gather → (your LLM synthesizes) → brv record-answer + +After recording, future equivalent queries to "brv search" (or "brv query") +will hit tier 0 (exact) or tier 1 (fuzzy) cache and skip the synthesis cost. + +Optional — skipping it means future queries will need to re-synthesize, but +correctness is preserved (cache is purely a perf optimization). Cache TTL +defaults to 60 seconds.` + public static examples = [ + '# Record an answer after running brv gather + your own LLM', + '<%= config.bin %> <%= command.id %> "how does auth work" "Auth uses JWTs..." --fingerprint ', + '', + '# Pipelining: gather → llm → record', + 'brv gather "auth" --format json | jq -r .prefetched_context | external-llm > /tmp/ans', + '<%= config.bin %> <%= command.id %> "auth" "$(cat /tmp/ans)" --fingerprint ', + '', + '# JSON output (CI/automation)', + '<%= config.bin %> <%= command.id %> "q" "a" --fingerprint fp --format json', + ] + public static flags = { + fingerprint: Flags.string({ + description: 'Cache key fingerprint from a prior brv search/gather call (required)', + required: true, + }), + format: Flags.string({ + default: 'text', + description: 'Output format (text or json)', + options: ['text', 'json'], + }), + } + // Allow unknown flags for forward-compatibility (e.g., new daemon flags + // passed through by wrapper scripts without requiring a CLI upgrade). + public static strict = false + + protected getDaemonClientOptions(): DaemonClientOptions { + return {} + } + + public async run(): Promise { + const {args, flags} = await this.parse(RecordAnswer) + const format: 'json' | 'text' = flags.format === 'json' ? 'json' : 'text' + + if (!this.validateInput(args.query, args.answer, flags.fingerprint, format)) return + + try { + await withDaemonRetry( + async (client, projectRoot, worktreeRoot) => { + await this.submitTask({ + answer: args.answer, + client, + fingerprint: flags.fingerprint, + format, + projectRoot, + query: args.query, + worktreeRoot, + }) + }, + { + ...this.getDaemonClientOptions(), + onRetry: + format === 'text' + ? (attempt, maxRetries) => + this.log(`\nConnection lost. Restarting daemon... (attempt ${attempt}/${maxRetries})`) + : undefined, + }, + ) + } catch (error) { + this.reportError(error, format) + } + } + + private formatTextOutput(recordResult: RecordAnswerResult): string[] { + if (recordResult.recorded) { + return ['', `Answer recorded for fingerprint ${recordResult.fingerprint}.`, ''] + } + + return [ + '', + `Answer NOT recorded (fingerprint ${recordResult.fingerprint}).`, + 'Daemon may not have caching enabled, or the cache.set call failed.', + '', + ] + } + + private reportError(error: unknown, format: 'json' | 'text'): void { + const errorMessage = error instanceof Error ? error.message : 'Record-answer failed' + + if (format === 'json') { + writeJsonResponse({command: 'record-answer', data: {error: errorMessage, status: 'error'}, success: false}) + } else { + this.log(formatConnectionError(error)) + } + + if (hasLeakedHandles(error)) { + // eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit + process.exit(1) + } + } + + private async submitTask(props: { + answer: string + client: ITransportClient + fingerprint: string + format: 'json' | 'text' + projectRoot?: string + query: string + worktreeRoot?: string + }): Promise { + const {answer, client, fingerprint, format, projectRoot, query, worktreeRoot} = props + const taskId = randomUUID() + + const contentPayload = encodeRecordAnswerContent({answer, fingerprint, query}) + + const taskPayload = { + clientCwd: process.cwd(), + content: contentPayload, + ...(projectRoot ? {projectPath: projectRoot} : {}), + taskId, + type: 'record-answer' as const, + ...(worktreeRoot ? {worktreeRoot} : {}), + } + + const completionPromise = waitForTaskCompletion( + { + client, + command: 'record-answer', + format, + onCompleted: ({result}) => { + if (!result) { + if (format === 'json') { + writeJsonResponse({ + command: 'record-answer', + data: {fingerprint, recorded: false, status: 'completed'}, + success: true, + }) + } else { + this.log('\nNo response from daemon.\n') + } + + return + } + + try { + const recordResult = JSON.parse(result) as RecordAnswerResult + + if (format === 'json') { + writeJsonResponse({ + command: 'record-answer', + data: {...recordResult, status: 'completed'}, + success: true, + }) + } else { + for (const line of this.formatTextOutput(recordResult)) { + this.log(line) + } + } + } catch { + if (format === 'json') { + writeJsonResponse({ + command: 'record-answer', + data: {error: 'Invalid record-answer result format', raw: result, status: 'error'}, + success: false, + }) + } else { + this.log(`\n${result}\n`) + } + } + }, + onError: ({error}) => { + if (format === 'json') { + writeJsonResponse({ + command: 'record-answer', + data: {event: 'error', message: error.message, status: 'error'}, + success: false, + }) + } else { + this.log(`\nError: ${error.message}\n`) + } + }, + taskId, + }, + (msg) => this.log(msg), + ) + + await client.requestWithAck(TaskEvents.CREATE, taskPayload) + await completionPromise + } + + private validateInput(query: string, answer: string, fingerprint: string, format: 'json' | 'text'): boolean { + if (query.trim() && answer.trim() && fingerprint.trim()) return true + + const message = 'query, answer, and --fingerprint are all required and cannot be empty.' + if (format === 'json') { + writeJsonResponse({command: 'record-answer', data: {message, status: 'error'}, success: false}) + } else { + this.log(message) + this.log('Usage: brv record-answer "" "" --fingerprint ') + } + + // PHASE-5-UAT.md UAT-14: empty input must exit non-zero so CI scripts + // can detect failure. + process.exitCode = 1 + return false + } +} diff --git a/src/oclif/commands/search.ts b/src/oclif/commands/search.ts index 389fb1938..cac2f8376 100644 --- a/src/oclif/commands/search.ts +++ b/src/oclif/commands/search.ts @@ -217,6 +217,9 @@ Use "brv query" when you need a synthesized answer.` this.log('Usage: brv search "your query here"') } + // PHASE-5-UAT.md UAT-14 (Codex Pass 8 finding): empty input must exit + // non-zero so CI scripts can detect failure. + process.exitCode = 1 return false } } diff --git a/src/server/core/domain/transport/schemas.ts b/src/server/core/domain/transport/schemas.ts index d2c0d1765..90ec81884 100644 --- a/src/server/core/domain/transport/schemas.ts +++ b/src/server/core/domain/transport/schemas.ts @@ -392,6 +392,14 @@ export const TaskExecuteSchema = z.object({ folderPath: z.string().optional(), /** Force flag for dream tasks (skip time/activity/queue gates) */ force: z.boolean().optional(), + /** + * Curate-log identifier (cur-) assigned by `CurateLogHandler.onTaskCreate` + * via `runCreateHooks` in the task-router. Threaded through to the executor so + * services-adapter.write can include it in the per-leaf `Reason` field for + * audit-trail provenance (Phase 2.5 R-3). Optional and backward-compatible: + * non-curate tasks (query/dream/etc.) leave it undefined. + */ + logId: z.string().optional(), /** Project path this task belongs to (for multi-project routing) */ projectPath: z.string().optional(), /** @@ -405,7 +413,7 @@ export const TaskExecuteSchema = z.object({ /** Dream trigger source — how this dream was initiated */ trigger: z.enum(['agent-idle', 'cli', 'manual']).optional(), /** Task type */ - type: z.enum(['curate', 'curate-folder', 'dream', 'query', 'search']), + type: z.enum(['curate', 'curate-folder', 'dream', 'gather', 'mcp-search', 'query', 'record-answer', 'search']), /** Workspace root for scoped query/curate */ worktreeRoot: z.string().optional(), }) @@ -526,7 +534,7 @@ export const TaskCreatedSchema = z.object({ /** Unique task identifier */ taskId: z.string(), /** Task type */ - type: z.enum(['curate', 'curate-folder', 'query', 'search']), + type: z.enum(['curate', 'curate-folder', 'gather', 'mcp-search', 'query', 'record-answer', 'search']), }) /** @@ -670,7 +678,7 @@ export type TaskQueryResultEvent = z.infer // Request/Response Schemas (for client → server commands) // ============================================================================ -export const TaskTypeSchema = z.enum(['curate', 'curate-folder', 'dream', 'query', 'search']) +export const TaskTypeSchema = z.enum(['curate', 'curate-folder', 'dream', 'gather', 'mcp-search', 'query', 'record-answer', 'search']) /** * Request to create a new task diff --git a/src/server/core/interfaces/executor/i-curate-executor.ts b/src/server/core/interfaces/executor/i-curate-executor.ts index c96a2031a..2fde22a26 100644 --- a/src/server/core/interfaces/executor/i-curate-executor.ts +++ b/src/server/core/interfaces/executor/i-curate-executor.ts @@ -11,6 +11,14 @@ export interface CurateExecuteOptions { content: string /** Optional file paths for --files flag */ files?: string[] + /** + * Curate-log identifier (cur-) assigned by `CurateLogHandler.onTaskCreate`, + * forwarded by the task-router via `TaskExecuteSchema.logId`. The executor passes + * it to services-adapter so each curated leaf's `Reason` field can carry the + * source curate-log id for audit-trail provenance (Phase 2.5 R-3). + * Optional — undefined for direct-test invocations that bypass the router. + */ + logId?: string /** Canonical project root where .brv/ lives (for post-processing: snapshot, summary, manifest) */ projectRoot?: string /** Task ID for event routing (required for concurrent task isolation) */ diff --git a/src/server/core/interfaces/executor/i-gather-executor.ts b/src/server/core/interfaces/executor/i-gather-executor.ts new file mode 100644 index 000000000..bcef0f454 --- /dev/null +++ b/src/server/core/interfaces/executor/i-gather-executor.ts @@ -0,0 +1,50 @@ +/** + * IGatherExecutor (Phase 5 Task 5.3) — assembles a context bundle for an + * external agent (or human user) to synthesize from. NEVER invokes the LLM. + * + * Backs both the `brv_gather` MCP tool and the `brv gather` CLI command. + * Output shape mirrors DESIGN §6.2 — snake_case fields are intentional and + * match the MCP wire format the agent reads. Interface property names don't + * trigger ESLint's camelcase rule; consumer files that USE these fields need + * the per-file disable directive. + */ + +export interface GatherExecuteOptions { + /** Result cap, default 10, max 50. */ + limit?: number + query: string + /** Optional path-prefix scope filter. */ + scope?: string + /** Soft cap on total tokens in the bundle (default 4000 per DESIGN §6.2). */ + tokenBudget?: number +} + +export interface GatherSearchMetadata { + result_count: number + top_score: number + total_found: number +} + +export interface GatherResult { + /** Optional rule-based hints when results are sparse or low-confidence. */ + follow_up_hints?: string[] + /** + * Manifest-derived structural snippets (broad context). + * + * **Reserved field — DEFERRED to a follow-up phase** (PHASE-5-CODE-REVIEW.md F8). + * The current `GatherExecutor` does not populate this field; agents should + * treat its presence as optional and synthesize from `prefetched_context` + * alone for now. Wiring `FileContextTreeManifestService.resolveForInjection` + * into GatherExecutor requires a baseDirectory dep + IFileSystem dep and + * adds I/O latency — deferred until a real consumer demonstrates the need. + */ + manifest_context?: string + /** Markdown-formatted bundle of high-confidence excerpts. Empty string if none. */ + prefetched_context: string + search_metadata: GatherSearchMetadata + total_tokens_estimated: number +} + +export interface IGatherExecutor { + execute(options: GatherExecuteOptions): Promise +} diff --git a/src/server/core/interfaces/executor/i-record-answer-executor.ts b/src/server/core/interfaces/executor/i-record-answer-executor.ts new file mode 100644 index 000000000..6f2f6d2b7 --- /dev/null +++ b/src/server/core/interfaces/executor/i-record-answer-executor.ts @@ -0,0 +1,30 @@ +/** + * IRecordAnswerExecutor (Phase 5 Task 5.4) — closes the cache loop for + * agent-synthesized answers. The agent calls this AFTER synthesizing from + * a `brv_gather` bundle, so future equivalent queries can hit tier 0/1 + * via `brv_search` / `brv_query`. + * + * Without this hook, agent-synthesized answers are discarded and the + * cache only sees tier-2 direct BM25 hits. + */ + +export interface RecordAnswerOptions { + answer: string + /** + * Required cache key match — the fingerprint the agent received from + * its prior `brv_search` or `brv_gather` call. Without it the cache + * write would be invisible to future fingerprint-aware reads. + */ + fingerprint: string + query: string +} + +export interface RecordAnswerResult { + fingerprint: string + /** false when no cache is configured on the daemon (graceful no-op). */ + recorded: boolean +} + +export interface IRecordAnswerExecutor { + execute(options: RecordAnswerOptions): Promise +} diff --git a/src/server/infra/daemon/agent-process.ts b/src/server/infra/daemon/agent-process.ts index 41cd7e9e6..eb8b7f76b 100644 --- a/src/server/infra/daemon/agent-process.ts +++ b/src/server/infra/daemon/agent-process.ts @@ -38,6 +38,8 @@ import {FileKeyStorage} from '../../../agent/infra/storage/file-key-storage.js' import {runWithReviewDisabled} from '../../../agent/infra/tools/implementations/curate-tool-task-context.js' import {createSearchKnowledgeService} from '../../../agent/infra/tools/implementations/search-knowledge-service.js' import {AuthEvents} from '../../../shared/transport/events/auth-events.js' +import {decodeGatherContent} from '../../../shared/transport/gather-content.js' +import {decodeRecordAnswerContent} from '../../../shared/transport/record-answer-content.js' import {decodeSearchContent} from '../../../shared/transport/search-content.js' import {getCurrentConfig} from '../../config/environment.js' import {BRV_DIR, DEFAULT_LLM_MODEL, PROJECT} from '../../constants.js' @@ -51,6 +53,7 @@ import { } from '../../core/domain/transport/schemas.js' import {FileContextTreeArchiveService} from '../context-tree/file-context-tree-archive-service.js' import {RuntimeSignalStore} from '../context-tree/runtime-signal-store.js' +import {toBrvSearchResult} from '../dispatcher/query-dispatcher.js' import {DreamLockService} from '../dream/dream-lock-service.js' import {DreamLogStore} from '../dream/dream-log-store.js' import {DreamStateService} from '../dream/dream-state-service.js' @@ -58,7 +61,9 @@ import {DreamTrigger} from '../dream/dream-trigger.js' import {CurateExecutor} from '../executor/curate-executor.js' import {DreamExecutor} from '../executor/dream-executor.js' import {FolderPackExecutor} from '../executor/folder-pack-executor.js' +import {GatherExecutor} from '../executor/gather-executor.js' import {QueryExecutor} from '../executor/query-executor.js' +import {RecordAnswerExecutor} from '../executor/record-answer-executor.js' import {SearchExecutor} from '../executor/search-executor.js' import {FileCurateLogStore} from '../storage/file-curate-log-store.js' import {FileReviewBackupStore} from '../storage/file-review-backup-store.js' @@ -98,6 +103,15 @@ const projectPath = projectPathEnv const agentLog = createAgentLogger(process.env.BRV_SESSION_LOG, `[agent-process:${projectPath}]`) +/** + * Task types that bypass provider validation (no LLM dependency). + * - `'search'`: CLI BM25 retrieval + * - `'mcp-search'`: Phase 5 MCP tier 0/1/2 dispatch + * - `'gather'`: Phase 5 context bundle assembly + * - `'record-answer'`: Phase 5 cache write + */ +const LLM_FREE_TASK_TYPES = new Set(['gather', 'mcp-search', 'record-answer', 'search']) + /** * Holds detached post-curate work so `task:completed` can fire as soon as * the agent body finishes. Drained on shutdown to avoid truncated writes. @@ -402,7 +416,7 @@ async function start(): Promise { }) // 7. Create executors and listen for task:execute from pool - const curateExecutor = new CurateExecutor() + const curateExecutor = new CurateExecutor({searchService}) const folderPackService = new FolderPackService(fileSystemService) await folderPackService.initialize() const folderPackExecutor = new FolderPackExecutor(folderPackService) @@ -413,6 +427,14 @@ async function start(): Promise { searchService, }) const searchExecutor = new SearchExecutor(searchService) + // Phase 5 Task 5.3: shared GatherExecutor for both `brv_gather` MCP and + // `brv gather` CLI. Pure data — no LLM, no agent dep. + const gatherExecutor = new GatherExecutor({searchService}) + // Phase 5 Task 5.4: shares the cache instance with QueryExecutor's + // dispatcher so agent-recorded answers flow back to tier 0/1 reads. + const recordAnswerExecutor = new RecordAnswerExecutor({ + ...(queryExecutor.dispatcher.cache ? {cache: queryExecutor.dispatcher.cache} : {}), + }) transport.on(TransportTaskEventNames.EXECUTE, (task) => { agentLog(`task:execute received taskId=${task.taskId} type=${task.type} activeTaskCount=${activeTaskCount + 1}`) @@ -421,7 +443,9 @@ async function start(): Promise { task, curateExecutor, folderPackExecutor, + gatherExecutor, queryExecutor, + recordAnswerExecutor, searchExecutor, searchService, configResult.storagePath, @@ -441,18 +465,26 @@ async function executeTask( task: TaskExecute, curateExecutor: CurateExecutor, folderPackExecutor: FolderPackExecutor, + gatherExecutor: GatherExecutor, queryExecutor: QueryExecutor, + recordAnswerExecutor: RecordAnswerExecutor, searchExecutor: SearchExecutor, searchKnowledgeService: ISearchKnowledgeService, storagePath: string, runtimeSignalStore: IRuntimeSignalStore, ): Promise { - const {clientCwd, clientId, content, files, folderPath, force, reviewDisabled, taskId, trigger, type, worktreeRoot} = task + const { + clientCwd, clientId, content, files, folderPath, force, + logId: routerLogId, + reviewDisabled, + taskId, trigger, type, worktreeRoot, + } = task if (!transport || !agent) return - // Search tasks are pure BM25 retrieval — no LLM, no provider needed. - // Skip provider validation so search works even without a configured provider. - if (type !== 'search') { + // LLM-free task types skip provider validation. PHASE-5-CODE-REVIEW.md F3: + // brv_search/brv_gather/brv_record_answer must work without a configured + // provider per the Phase 5 invariant — they're pure BM25 / pure cache writes. + if (!LLM_FREE_TASK_TYPES.has(type)) { const freshProviderConfig = await transport.requestWithAck( TransportStateEventNames.GET_PROVIDER_CONFIG, ) @@ -556,6 +588,9 @@ async function executeTask( clientCwd, content, files, + // R-3 (PHASE-2.5-PLAN.md §3.4): forward the cur- from the + // task-router so services-adapter can use it in `Reason`. + logId: routerLogId, projectRoot: projectPath, taskId, worktreeRoot, @@ -625,6 +660,47 @@ async function executeTask( break } + case 'gather': { + // Phase 5 Task 5.3 (PHASE-5-PLAN.md): assemble a context bundle for + // the agent (or human user) to synthesize from. NEVER invokes the LLM. + const gatherOptions = decodeGatherContent(content) + const gatherResult = await gatherExecutor.execute({ + ...(gatherOptions.limit === undefined ? {} : {limit: gatherOptions.limit}), + query: gatherOptions.query, + ...(gatherOptions.scope === undefined ? {} : {scope: gatherOptions.scope}), + ...(gatherOptions.tokenBudget === undefined ? {} : {tokenBudget: gatherOptions.tokenBudget}), + }) + result = JSON.stringify(gatherResult) + + break + } + + case 'mcp-search': { + // Phase 5 Task 5.2 (PHASE-5-PLAN.md): tier 0/1/2 path for the + // brv-search MCP tool. Routes through QueryExecutor's shared + // QueryDispatcher (same cache + searchService instances as + // legacy brv_query) — never invokes the LLM. + // + // PHASE-5-CODE-REVIEW.md F2: compute fingerprint via QueryExecutor + // so tier 0/1 hits work. Without it, the search → gather → + // record-answer → search loop can never reach tier-0 cache. + // + // PHASE-5-CODE-REVIEW.md F4: map the internal DispatchResult to + // the public BrvSearchResult DTO (snake_case per DESIGN §6.1). + // Internal `searchResult` MUST NOT leak across the MCP boundary. + const searchOptions = decodeSearchContent(content) + const fingerprint = await queryExecutor.resolveFingerprint(worktreeRoot) + const dispatchResult = await queryExecutor.dispatcher.dispatch({ + ...(fingerprint === undefined ? {} : {fingerprint}), + ...(searchOptions.limit === undefined ? {} : {limit: searchOptions.limit}), + query: searchOptions.query, + ...(searchOptions.scope === undefined ? {} : {scope: searchOptions.scope}), + }) + result = JSON.stringify(toBrvSearchResult(dispatchResult)) + + break + } + case 'query': { const queryResult = await queryExecutor.executeWithAgent(agent, {query: content, taskId, worktreeRoot}) result = queryResult.response @@ -646,6 +722,17 @@ async function executeTask( break } + case 'record-answer': { + // Phase 5 Task 5.4 (PHASE-5-PLAN.md): write agent-synthesized + // answer back into the shared cache. Closes the loop so future + // equivalent queries hit tier 0/1 via brv_search / brv_query. + const recordOptions = decodeRecordAnswerContent(content) + const recordResult = await recordAnswerExecutor.execute(recordOptions) + result = JSON.stringify(recordResult) + + break + } + case 'search': { const searchOptions = decodeSearchContent(content) const searchResult = await searchExecutor.execute(searchOptions) diff --git a/src/server/infra/dispatcher/query-dispatcher.ts b/src/server/infra/dispatcher/query-dispatcher.ts new file mode 100644 index 000000000..e81574175 --- /dev/null +++ b/src/server/infra/dispatcher/query-dispatcher.ts @@ -0,0 +1,331 @@ +/** + * QueryDispatcher (Phase 5 Task 5.1) — deterministic tier 0/1/2 path. + * + * Owns the LLM-free portion of query handling so both `brv_query` (legacy + * CLI / MCP) and `brv_search` (new MCP) can share the same code without + * forking. Discriminated-union return; caller decides what to do with + * each tier outcome. + * + * Stateless by design — `computeContextTreeFingerprint` stays on the + * executor (per PHASE-5-VALIDATION.md §2.2 recommendation b). Caller + * passes the resolved fingerprint in. + * + * Cache invariant: this module writes to cache ONLY on `direct_passages`. + * The `formatNotFoundResponse` cache write (legacy executor behavior on + * tier-2 empty BM25) is a human-facing response, not the dispatcher's + * concern — kept in the executor. + */ + +import {join} from 'node:path' + +import type {IFileSystem} from '../../../agent/core/interfaces/i-file-system.js' +import type {ISearchKnowledgeService, SearchKnowledgeResult} from '../../../agent/infra/sandbox/tools-sdk.js' +import type {QueryResultCache} from '../executor/query-result-cache.js' + +import {BRV_DIR, CONTEXT_TREE_DIR} from '../../constants.js' +import {canRespondDirectly, type DirectSearchResult, formatDirectResponse} from '../executor/direct-search-responder.js' + +/** Public passage shape returned to consumers (matches DESIGN §6.1 brv_search contract). */ +export interface Passage { + excerpt: string + path: string + score: number +} + +export interface QueryDispatcherDeps { + cache?: QueryResultCache + fileSystem?: IFileSystem + searchService?: ISearchKnowledgeService +} + +export interface DispatchOptions { + fingerprint?: string + /** + * Default 10 — matches DESIGN §6.1 + `BrvSearchInputSchema` (max 50). + * Legacy `brv_query` path threads its own searchPromise with + * `SMART_ROUTING_MAX_DOCS = 5` from `QueryExecutor`, so this default + * only affects direct dispatcher consumers (`brv_search` MCP / CLI). + */ + limit?: number + query: string + scope?: string + /** + * Caller-supplied in-flight search promise. When present, dispatcher + * awaits it instead of firing its own search — preserves the legacy + * executor's parallel-search-with-fingerprint optimization. + */ + searchPromise?: Promise +} + +export type DispatchResult = + | { + cachedAnswer: string + fingerprint?: string + status: 'cached_answer' + tier: 0 | 1 + timingMs: number + totalFound: 0 + } + | { + directAnswer: string + fingerprint?: string + passages: Passage[] + searchResult: SearchKnowledgeResult + status: 'direct_passages' + tier: 2 + timingMs: number + totalFound: number + } + | { + fingerprint?: string + passages: [] + searchResult?: SearchKnowledgeResult + status: 'no_results' + tier: 2 + timingMs: number + totalFound: number + } + | { + fingerprint?: string + passages: Passage[] + searchResult: SearchKnowledgeResult + status: 'needs_synthesis' + tier: 2 + timingMs: number + totalFound: number + } + +/* eslint-disable camelcase -- DESIGN §6.1 specifies snake_case for the brv_search public contract */ + +/** + * Public `brv_search` MCP/CLI contract per DESIGN §6.1. This is the wire shape + * external agents read — distinct from the internal `DispatchResult` discriminated + * union which carries camelCase fields and the raw `searchResult` for tier 3/4 + * fall-through inside `QueryExecutor`. + * + * Per PHASE-5-CODE-REVIEW.md F4: the daemon must emit this DTO, NOT the internal + * shape. Internal `searchResult` MUST NOT leak across the MCP boundary. + */ +export interface BrvSearchResult { + cached_answer?: string + fingerprint?: string + passages?: Passage[] + status: 'cached_answer' | 'direct_passages' | 'needs_synthesis' | 'no_results' + tier: 0 | 1 | 2 + timing_ms: number + total_found: number +} + +/** + * Map the internal `DispatchResult` to the public `BrvSearchResult` DTO. + * Drops `searchResult` (internal), `directAnswer` (legacy formatter output — + * not in DESIGN §6.1; agent reads `passages` for the direct case), and + * renames camelCase fields to snake_case. + */ +export function toBrvSearchResult(r: DispatchResult): BrvSearchResult { + const base = { + ...(r.fingerprint === undefined ? {} : {fingerprint: r.fingerprint}), + status: r.status, + tier: r.tier, + timing_ms: r.timingMs, + total_found: r.totalFound, + } + + if (r.status === 'cached_answer') { + return {...base, cached_answer: r.cachedAnswer} + } + + if (r.status === 'no_results') { + return {...base, passages: []} + } + + // 'direct_passages' or 'needs_synthesis' — both expose passages + return {...base, passages: r.passages} +} + +/* eslint-enable camelcase */ + +/** + * BM25 result cap when caller doesn't pass `limit`. Matches DESIGN §6.1 + * (default 10, max 50) and the `BrvSearchInputSchema` JSDoc. + * + * The legacy `brv_query` path threads its own searchPromise (via + * QueryExecutor with `SMART_ROUTING_MAX_DOCS = 5`) so this constant only + * applies to direct dispatcher consumers — the `brv_search` MCP / CLI path. + */ +const DEFAULT_LIMIT = 10 + +/** + * Max docs read fully (excerpt → full content) inside `tryDirectResponse` + * for direct-answer formatting. Internal tuning knob — independent from + * the public BM25 default. Mirrors the legacy executor's behavior. + */ +const DIRECT_RESPONSE_MAX_DOCS = 5 + +/** Mirrors SMART_ROUTING_SCORE_THRESHOLD in the legacy executor. */ +const DIRECT_RESPONSE_SCORE_FLOOR = 0.7 + +export class QueryDispatcher { + /** + * Public so agent-process.ts can share the same cache instance with + * `RecordAnswerExecutor` (Phase 5 Task 5.4) — closes the cache loop + * for agent-synthesized answers without instantiating a parallel cache. + */ + public readonly cache?: QueryResultCache + private readonly fileSystem?: IFileSystem + private readonly searchService?: ISearchKnowledgeService + + constructor(deps: QueryDispatcherDeps) { + this.cache = deps.cache + this.fileSystem = deps.fileSystem + this.searchService = deps.searchService + } + + async dispatch(options: DispatchOptions): Promise { + const startTime = Date.now() + const {fingerprint, limit, query, scope, searchPromise} = options + + if (this.cache && fingerprint) { + const exact = this.cache.get(query, fingerprint) + if (exact) { + return { + cachedAnswer: exact, + fingerprint, + status: 'cached_answer', + tier: 0, + timingMs: Date.now() - startTime, + totalFound: 0, + } + } + + const fuzzy = this.cache.findSimilar(query, fingerprint) + if (fuzzy) { + return { + cachedAnswer: fuzzy, + fingerprint, + status: 'cached_answer', + tier: 1, + timingMs: Date.now() - startTime, + totalFound: 0, + } + } + } + + const searchResult = await this.resolveSearchResult({limit, query, scope, searchPromise}) + + if (!searchResult || searchResult.results.length === 0) { + return { + fingerprint, + passages: [], + status: 'no_results', + tier: 2, + timingMs: Date.now() - startTime, + totalFound: searchResult?.totalFound ?? 0, + ...(searchResult ? {searchResult} : {}), + } + } + + const passages: Passage[] = searchResult.results.map((r) => ({ + excerpt: r.excerpt, + path: r.path, + score: r.score, + })) + + if (this.fileSystem) { + const directAnswer = await this.tryDirectResponse(query, searchResult) + if (directAnswer) { + if (this.cache && fingerprint) { + this.cache.set(query, directAnswer, fingerprint) + } + + return { + directAnswer, + fingerprint, + passages, + searchResult, + status: 'direct_passages', + tier: 2, + timingMs: Date.now() - startTime, + totalFound: searchResult.totalFound, + } + } + } + + return { + fingerprint, + passages, + searchResult, + status: 'needs_synthesis', + tier: 2, + timingMs: Date.now() - startTime, + totalFound: searchResult.totalFound, + } + } + + private async resolveSearchResult(opts: { + limit?: number + query: string + scope?: string + searchPromise?: Promise + }): Promise { + if (opts.searchPromise) { + try { + return await opts.searchPromise + } catch { + return undefined + } + } + + if (!this.searchService) return undefined + + try { + return await this.searchService.search(opts.query, { + limit: opts.limit ?? DEFAULT_LIMIT, + ...(opts.scope ? {scope: opts.scope} : {}), + }) + } catch { + return undefined + } + } + + private async tryDirectResponse( + query: string, + searchResult: SearchKnowledgeResult, + ): Promise { + if (!this.fileSystem) return undefined + const {fileSystem} = this + + try { + if (searchResult.totalFound === 0) return undefined + + const fullResults: DirectSearchResult[] = await Promise.all( + searchResult.results + .filter((r) => r.score >= DIRECT_RESPONSE_SCORE_FLOOR) + .slice(0, DIRECT_RESPONSE_MAX_DOCS) + .map(async (result) => { + let content = result.excerpt + try { + const ctBase = result.originContextTreeRoot ?? join(BRV_DIR, CONTEXT_TREE_DIR) + const ctPath = join(ctBase, result.path) + const {content: fullContent} = await fileSystem.readFile(ctPath) + content = fullContent + } catch { + // fall back to excerpt + } + + const displayPath = + result.origin === 'shared' && result.originAlias + ? `[${result.originAlias}]:${result.path}` + : result.path + + return {content, path: displayPath, score: result.score, title: result.title} + }), + ) + + if (!canRespondDirectly(fullResults)) return undefined + return formatDirectResponse(query, fullResults) + } catch { + return undefined + } + } +} diff --git a/src/server/infra/executor/curate-executor.ts b/src/server/infra/executor/curate-executor.ts index dcba97530..83be48090 100644 --- a/src/server/infra/executor/curate-executor.ts +++ b/src/server/infra/executor/curate-executor.ts @@ -1,10 +1,18 @@ import path from 'node:path' import type {ICipherAgent} from '../../../agent/core/interfaces/i-cipher-agent.js' +import type {ISearchKnowledgeService} from '../../../agent/infra/sandbox/tools-sdk.js' import type {CurationStatus} from '../../core/domain/entities/curation-status.js' import type {CurateExecuteOptions, ICurateExecutor} from '../../core/interfaces/executor/i-curate-executor.js' -import {recon as reconHelper} from '../../../agent/infra/sandbox/curation-helpers.js' +import { + type CurationRunResult, + type NodeContext, + TopologicalCurationRunner, +} from '../../../agent/core/curation/flow/runner.js' +import {buildCurationDAG} from '../../../agent/infra/curation/flow/dag-builder.js' +import {loadExistingMemory} from '../../../agent/infra/curation/flow/existing-memory-loader.js' +import {buildLiveServices} from '../../../agent/infra/curation/flow/services-adapter.js' import {BRV_DIR} from '../../constants.js' import {FileValidationError} from '../../core/domain/errors/task-error.js' import { @@ -22,16 +30,49 @@ import {PreCompactionService} from './pre-compaction/pre-compaction-service.js' type BackgroundDrainAgent = ICipherAgent & {drainBackgroundWork?: () => Promise} /** - * CurateExecutor - Executes curate tasks with an injected CipherAgent. + * Build a human-readable source name for the R-3 provenance envelope. + * Handles the 0/1/few/many file cases cleanly: + * - 0 files → 'cli-text' + * - 1 file → that file's path + * - 2-3 files → comma-joined + * - 4+ files → first 3 + '+N more' summary + * + * Per PHASE-2.5-PLAN.md §3.4 — the executor's input is `options.files: string[]` + * (not a scalar `filePath`), so a single name needs to summarize multi-file batches. + */ +function deriveProvenanceName(files?: string[]): string { + if (!files || files.length === 0) return 'cli-text' + if (files.length === 1) return files[0] + if (files.length <= 3) return files.join(',') + return `${files.slice(0, 3).join(',')}+${files.length - 3} more` +} + +export interface CurateExecutorDeps { + /** Override file content reader (primarily for testing). */ + readonly fileContentReader?: FileContentReader + /** + * Search service used by the curate-flow conflict-node to detect existing + * subjects in the context tree. When omitted, conflict detection always + * emits 'add' decisions (acceptable Phase 1 fallback in tests). + */ + readonly searchService?: ISearchKnowledgeService +} + +/** + * CurateExecutor — executes curate tasks via the typed-slot DAG runner. * - * This is NOT a UseCase (which orchestrates business logic). - * It's an Executor that wraps agent.execute() with curate-specific options. + * Phase 1 cutover (plan/agent-driven-graph/PHASE-1-IMPLEMENTATION.md): the + * monolithic 50-iteration agent loop is replaced by a deterministic DAG of + * 7 nodes (recon → chunk → extract → group → dedup → conflict → write). + * Service-bound nodes (extract, conflict, write) delegate via NodeServices + * built by `services-adapter.ts`. * * Architecture: - * - AgentProcess injects the long-lived CipherAgent - * - Event streaming is handled by agent-process (subscribes to agentEventBus) + * - AgentProcess injects the long-lived CipherAgent + SearchKnowledgeService + * - Event streaming flows through agent-process (subscribes to agentEventBus) * - Transport handles task lifecycle (task:started, task:completed, task:error) - * - Executor focuses solely on curate execution + * - Post-processing (snapshot diff, summary propagation, dream state) is + * preserved verbatim from the pre-cutover path. */ export class CurateExecutor implements ICurateExecutor { /** Maximum content length per file in characters */ @@ -46,9 +87,11 @@ export class CurateExecutor implements ICurateExecutor { public lastStatus?: CurationStatus private readonly fileContentReader: FileContentReader private readonly preCompactionService = new PreCompactionService() + private readonly searchService?: ISearchKnowledgeService - constructor(fileContentReader?: FileContentReader) { - this.fileContentReader = fileContentReader ?? createFileContentReader() + constructor(deps?: CurateExecutorDeps) { + this.fileContentReader = deps?.fileContentReader ?? createFileContentReader() + this.searchService = deps?.searchService } /** Synchronous wrapper — runs Phases 1-3 and awaits Phase 4 inline. */ @@ -69,107 +112,113 @@ export class CurateExecutor implements ICurateExecutor { agent: ICipherAgent, options: CurateExecuteOptions, ): Promise<{finalize: () => Promise; response: string}> { - const {clientCwd, content, files, projectRoot, taskId} = options + const {clientCwd, content, files, logId, projectRoot, taskId} = options // --- Phase 1: Preprocessing (no sessions created yet — safe to throw) --- const fileReferenceInstructions = await this.processFileReferences(files ?? [], clientCwd) const fullContext = fileReferenceInstructions ? `${content}\n${fileReferenceInstructions}` : content - // --- Phase 2: Pre-compaction (fail-open, manages its own session lifecycle) --- - const compactionResult = await this.preCompactionService.compact(agent, fullContext, taskId) - const effectiveContext = compactionResult.context - - // --- Phase 3: Curation (session created AFTER preprocessing + compaction) --- - // Capture pre-curation state for snapshot diff (summary propagation) - // Post-processing (snapshot, summary, manifest) operates on projectRoot where .brv/ lives. - // worktreeRoot is a linked subdir — .brv/ does not exist there in linked setups. + // --- Phase 2: Pre-compaction + task-session creation (parallel hoist). + // These are independent: compact() produces effective context, createTaskSession() + // produces the session ID. Running them concurrently hides ~5–10s of latency on + // large contexts. If compaction throws while the session is being created (or + // already created), we delete the orphan session before re-throwing to prevent + // leaks. Phase 3's snapshot capture also parallelizes here. const baseDir = projectRoot ?? clientCwd ?? process.cwd() const snapshotService = new FileContextTreeSnapshotService({baseDirectory: baseDir}) + const compactionPromise = this.preCompactionService.compact(agent, fullContext, taskId) + const sessionPromise = agent.createTaskSession(taskId, 'curate', {mapRootEligible: true, userFacing: true}) + // eslint-disable-next-line unicorn/no-useless-undefined -- explicit `undefined` keeps the awaited type narrow (Map<...> | undefined) instead of `void | Map<...>` + const snapshotPromise = snapshotService.getCurrentState(baseDir).catch(() => undefined) + + let compactionResult + let taskSessionId let preState: Map | undefined try { - preState = await snapshotService.getCurrentState(baseDir) - } catch { - // Fail-open: if snapshot fails, skip summary propagation + ;[compactionResult, taskSessionId, preState] = await Promise.all([ + compactionPromise, + sessionPromise, + snapshotPromise, + ]) + } catch (error) { + // Orphan-session guard: if compaction throws but session creation succeeded, + // delete the session before re-throwing so we don't leak it. + // eslint-disable-next-line unicorn/no-useless-undefined -- explicit `undefined` for type clarity + const settledSession = await sessionPromise.catch(() => undefined) + if (settledSession !== undefined) { + // eslint-disable-next-line unicorn/no-useless-undefined -- explicit `undefined` for type clarity + await agent.deleteTaskSession(settledSession).catch(() => undefined) + } + + throw error } - const taskSessionId = await agent.createTaskSession(taskId, 'curate', {mapRootEligible: true, userFacing: true}) + const effectiveContext = compactionResult.context let response: string try { - // Task-scoped variable names for RLM pattern. - // Replace hyphens with underscores: UUIDs have hyphens which are invalid in JS identifiers, - // so the LLM would naturally use underscores when writing code-exec calls — causing a - // ReferenceError if the variable was stored under the hyphen version. - const taskIdSafe = taskId.replaceAll('-', '_') - const ctxVar = `__curate_ctx_${taskIdSafe}` - const histVar = `__curate_hist_${taskIdSafe}` - const metaVar = `__curate_meta_${taskIdSafe}` - - // Compute context metadata (RLM pattern — LM sees metadata, not raw content) - const contextLines = effectiveContext.split('\n') + // --- Phase 3: Curation via the typed-slot DAG runner --- + // No more 50-iteration agent loop. The DAG is deterministic; each + // service-bound slot makes at most one LLM call (extract per chunk, + // conflict per fact-set). See plan/agent-driven-graph/DESIGN.md. + // + // R-3 (PHASE-2.5-PLAN.md §3.4): derive provenance from `options.files` + // (not options.filePath — the schema uses an array per i-curate-executor.ts:13) + // and thread `logId` + `taskId` through buildLiveServices so each curated + // leaf's `Reason` field carries cur- + source provenance + statement preview. + const services = buildLiveServices({ + agent, + basePath: path.join(baseDir, BRV_DIR, 'context-tree'), + logId, + lookupSubject: async (subject) => { + if (!this.searchService) return [] + return loadExistingMemory(this.searchService, [subject], {limitPerSubject: 3}) + }, + provenance: { + name: deriveProvenanceName(files), + type: files && files.length > 0 ? 'file' : 'text', + }, + taskId, + }) + const metadata = { charCount: effectiveContext.length, - lineCount: contextLines.length, + lineCount: effectiveContext.split('\n').length, messageCount: (effectiveContext.match(/\n\n\[(USER|ASSISTANT)\]:/g) || []).length, ...(compactionResult.preCompacted && { originalCharCount: compactionResult.originalCharCount, preCompacted: true, preCompactionTier: compactionResult.preCompactionTier, }), - preview: effectiveContext.slice(0, 500), - type: 'string', } - // Pre-pipeline the recon step (deterministic helper) so the agent loop - // doesn't spend its first iteration calling tools.curation.recon. The - // result is injected as a sandbox variable for code-exec access AND - // its key findings are surfaced inline in the prompt so the agent's - // first iteration can proceed directly to extraction. recon is pure - // JS — no LLM judgment is needed for whether to call it; the answer - // is always "yes, first thing." Surfacing it as an agent-tool meant - // paying a full LLM iteration just to invoke a deterministic helper. - const initialHistory = {entries: [], totalProcessed: 0} - // The `metadata` arg is currently unused by `recon` — the helper - // recomputes char/line/message counts from `effectiveContext` - // directly. Passed through here to match the helper's existing - // signature; do NOT assume changing `metadata` will alter - // `reconResult`. - const reconResult = reconHelper(effectiveContext, metadata, initialHistory) - const reconVar = `__recon_result_${taskIdSafe}` - - // Inject context, metadata, empty history, taskId, and pre-computed - // recon result into the TASK session's sandbox. - const taskIdVar = `__taskId_${taskIdSafe}` - agent.setSandboxVariableOnSession(taskSessionId, ctxVar, effectiveContext) - agent.setSandboxVariableOnSession(taskSessionId, histVar, initialHistory) - agent.setSandboxVariableOnSession(taskSessionId, metaVar, metadata) - agent.setSandboxVariableOnSession(taskSessionId, taskIdVar, taskId) - agent.setSandboxVariableOnSession(taskSessionId, reconVar, reconResult) - - // Prompt with curation helpers guidance (tools.curation.* replaces manual infrastructure code) - const prompt = [ - `Curate using RLM approach.`, - `Context variable: ${ctxVar} (${metadata.charCount} chars, ${metadata.lineCount} lines, ${metadata.messageCount} messages)`, - `History variable: ${histVar}`, - `Metadata variable: ${metaVar}`, - `Task ID variable: ${taskIdVar} (pass as bare variable, not a string)`, - `Recon already computed in ${reconVar}: suggestedMode=${reconResult.suggestedMode}, suggestedChunkCount=${reconResult.suggestedChunkCount}, charCount=${reconResult.meta.charCount}, lineCount=${reconResult.meta.lineCount}, messageCount=${reconResult.meta.messageCount}.`, - `IMPORTANT: Do NOT print raw context. Do NOT call tools.curation.recon — it has been pre-computed. Proceed directly to extraction.`, - `For chunked extraction use tools.curation.mapExtract(). Pass taskId: ${taskIdVar} (bare variable).`, - `IMPORTANT: Any code_exec call containing mapExtract MUST use timeout: 300000 on the code_exec tool call itself (not inside mapExtract options).`, - `Use tools.curation.groupBySubject() and tools.curation.dedup() to organize extractions.`, - `Verify via result.applied[].filePath — do NOT call readFile for verification.`, - ].join('\n') - - // Execute on the task session (isolated sandbox + history) - // Task lifecycle is managed by Transport (task:started, task:completed, task:error) - response = await agent.executeOnSession(taskSessionId, prompt, { - executionContext: {clearHistory: true, commandType: 'curate', maxIterations: 50}, + // initialInput carries the original context for chunk-node and the + // recon inputs for recon-node. The DAG's recon-node provides the + // deterministic recon analysis natively (PR #601's pre-pipelined + // reconHelper for the agent-loop path is unneeded here). + // + // R-5 (PHASE-2.6-PLAN.md §3.1): rolled back from 8 → 4 per Phase 2.5 + // §3.5 go/no-go gate. Phase 4 UAT showed c=8 regressed Scenario 3 from + // 150s (Phase 3 baseline) → 169s, most likely from gpt-5.4-mini + // rate-limit retries. The 30s gap to ≤120s spec moves to a Phase 6 + // perf spike with profiler-driven measurements (don't guess-and-rerun). + const ctx: NodeContext = { + extractConcurrency: 4, + initialInput: { + context: effectiveContext, + history: {entries: [], totalProcessed: 0}, + meta: metadata, + }, + services, taskId, - }) + } - // Parse curation status from agent response for status tracking - this.lastStatus = this.parseCurationStatus(taskId, response) + const dag = buildCurationDAG() + const runner = new TopologicalCurationRunner() + const runResult = await runner.run(dag, ctx) + + this.lastStatus = this.adaptToStatus(taskId, runResult) + response = this.formatResponseString(runResult) } catch (error) { // Clean up before propagating — error path returns no finalize. await agent.deleteTaskSession(taskSessionId) @@ -190,6 +239,29 @@ export class CurateExecutor implements ICurateExecutor { return {finalize, response} } + /** + * Build a CurationStatus from the runner result. Pulls counts from the + * write-node's output; reports 'partial' if any slot failure occurred. + */ + private adaptToStatus(taskId: string, result: CurationRunResult): CurationStatus { + const defaultSummary = {added: 0, deleted: 0, failed: 0, merged: 0, updated: 0} + const writeOutput = result.outputs.get('write') as + | undefined + | {applied: ReadonlyArray; summary: typeof defaultSummary} + + const summary = writeOutput?.summary ?? defaultSummary + const status: CurationStatus['status'] = + result.failures.length > 0 ? 'partial' : summary.failed > 0 ? 'partial' : 'success' + + return { + completedAt: new Date().toISOString(), + status, + summary, + taskId, + verification: {checked: 0, confirmed: 0, missing: []}, + } + } + /** * Format file contents for inclusion in the prompt. */ @@ -241,6 +313,27 @@ export class CurateExecutor implements ICurateExecutor { return instructions.join('\n') } + /** + * Format the runner result as a transport-compatible response string. + * Wraps the write summary in the JSON-status block shape that downstream + * consumers (TUI, MCP) parse via regex. + */ + private formatResponseString(result: CurationRunResult): string { + const defaultSummary = {added: 0, deleted: 0, failed: 0, merged: 0, updated: 0} + const writeOutput = result.outputs.get('write') as + | undefined + | {applied: ReadonlyArray; summary: typeof defaultSummary} + + const summary = writeOutput?.summary ?? defaultSummary + const failures = result.failures.length > 0 ? `\nFailures: ${JSON.stringify(result.failures)}` : '' + + return `Curate completed via typed-slot DAG.${failures}\n\n\`\`\`json\n${JSON.stringify( + {summary, verification: {checked: 0, confirmed: 0, missing: []}}, + null, + 2, + )}\n\`\`\`` + } + /** * Get human-readable label for file type. */ @@ -277,42 +370,6 @@ export class CurateExecutor implements ICurateExecutor { } } - /** - * Parse curation status from the agent response. - * Extracts JSON status block if present, otherwise infers from response text. - */ - private parseCurationStatus(taskId: string, response: string): CurationStatus { - const defaultSummary = { added: 0, deleted: 0, failed: 0, merged: 0, updated: 0 } - const defaultVerification = { checked: 0, confirmed: 0, missing: [] as string[] } - - // Try to extract JSON status block from response (agent instructed to include it) - try { - const jsonMatch = /```json\n([\S\s]*?)\n```/.exec(response) - if (jsonMatch) { - const parsed = JSON.parse(jsonMatch[1]) - - return { - completedAt: new Date().toISOString(), - status: parsed.summary?.failed > 0 ? 'partial' : 'success', - summary: parsed.summary ?? defaultSummary, - taskId, - verification: parsed.verification ?? defaultVerification, - } - } - } catch { - // Ignore parse errors — fall through to heuristic - } - - // Fallback: infer from response text - return { - completedAt: new Date().toISOString(), - status: response.includes('failed') ? 'failed' : 'success', - summary: defaultSummary, - taskId, - verification: defaultVerification, - } - } - /** * Process file paths from --files flag. * Now reads file contents directly using FileContentReader. diff --git a/src/server/infra/executor/gather-executor.ts b/src/server/infra/executor/gather-executor.ts new file mode 100644 index 000000000..38ba34d5d --- /dev/null +++ b/src/server/infra/executor/gather-executor.ts @@ -0,0 +1,148 @@ +/* eslint-disable camelcase -- DESIGN §6.2 specifies snake_case for MCP-facing fields */ + +/** + * GatherExecutor (Phase 5 Task 5.3) — daemon-side handler for `brv_gather` + * MCP tool and `brv gather` CLI command. + * + * Pure data assembly: + * 1. Run BM25 via the shared `ISearchKnowledgeService`. + * 2. Build prefetched context bundle (shared `buildPrefetchedContext` helper). + * 3. Estimate total tokens. + * 4. Compute rule-based `follow_up_hints` (sparse results / low confidence). + * + * Critical invariant (DESIGN §4.2): NEVER invokes the LLM. The agent (or + * human user) synthesizes the answer from the returned bundle. If you find + * yourself adding `agent.executeOnSession` here, stop. + * + * Manifest-context assembly (broad structural snippets via + * `FileContextTreeManifestService`) is deferred — gather-executor stays + * pure data + searchService for now. Future enhancement: inject a + * `manifestService` dep when needed. + */ + +import type {ISearchKnowledgeService, SearchKnowledgeResult} from '../../../agent/infra/sandbox/tools-sdk.js' +import type {GatherExecuteOptions, GatherResult, IGatherExecutor} from '../../core/interfaces/executor/i-gather-executor.js' + +import {estimateTokens} from '../../../shared/utils/escalation-utils.js' +import {buildPrefetchedContext} from './prefetch-context-builder.js' + +const DEFAULT_LIMIT = 10 +const MAX_LIMIT = 50 +const DEFAULT_TOKEN_BUDGET = 4000 + +/** Below this top-score, surface a low-confidence hint. */ +const LOW_CONFIDENCE_TOP_SCORE = 0.5 + +/** At-or-below this many results, surface a sparse-results hint. */ +const SPARSE_RESULTS_THRESHOLD = 2 + +export interface GatherExecutorDeps { + searchService: ISearchKnowledgeService +} + +export class GatherExecutor implements IGatherExecutor { + private readonly searchService: ISearchKnowledgeService + + constructor(deps: GatherExecutorDeps) { + this.searchService = deps.searchService + } + + async execute(options: GatherExecuteOptions): Promise { + const query = options.query.trim() + if (!query) { + return emptyResult() + } + + const limit = clampLimit(options.limit) + const scope = options.scope?.trim() || undefined + + let searchResult: SearchKnowledgeResult + try { + searchResult = await this.searchService.search(query, { + limit, + ...(scope ? {scope} : {}), + }) + } catch { + return emptyResult() + } + + const prefetchedContext = buildPrefetchedContext(searchResult) ?? '' + const tokenBudget = options.tokenBudget ?? DEFAULT_TOKEN_BUDGET + const truncatedPrefetched = applyTokenBudget(prefetchedContext, tokenBudget) + const totalTokens = estimateTokens(truncatedPrefetched) + + const followUpHints = computeFollowUpHints(searchResult) + const topScore = searchResult.results[0]?.score ?? 0 + + return { + ...(followUpHints.length > 0 ? {follow_up_hints: followUpHints} : {}), + prefetched_context: truncatedPrefetched, + search_metadata: { + result_count: searchResult.results.length, + top_score: topScore, + total_found: searchResult.totalFound, + }, + total_tokens_estimated: totalTokens, + } + } +} + +function emptyResult(): GatherResult { + return { + prefetched_context: '', + search_metadata: {result_count: 0, top_score: 0, total_found: 0}, + total_tokens_estimated: 0, + } +} + +function clampLimit(limit?: number): number { + if (limit === undefined) return DEFAULT_LIMIT + return Math.min(MAX_LIMIT, Math.max(1, Math.trunc(limit))) +} + +/** + * If the bundle exceeds the token budget, truncate to the last full section + * (sections are joined by `\n\n---\n\n` per `buildPrefetchedContext`). + */ +function applyTokenBudget(bundle: string, tokenBudget: number): string { + if (!bundle) return bundle + const tokens = estimateTokens(bundle) + if (tokens <= tokenBudget) return bundle + + const sections = bundle.split('\n\n---\n\n') + const kept: string[] = [] + let accumulated = 0 + for (const section of sections) { + const sectionTokens = estimateTokens(section) + if (accumulated + sectionTokens > tokenBudget) break + kept.push(section) + accumulated += sectionTokens + } + + // Always include at least the first section so callers receive some context + // even when token_budget is smaller than a single section. + if (kept.length === 0 && sections.length > 0) { + return sections[0] + } + + return kept.join('\n\n---\n\n') +} + +function computeFollowUpHints(searchResult: SearchKnowledgeResult): string[] { + const hints: string[] = [] + const topScore = searchResult.results[0]?.score ?? 0 + + if (searchResult.results.length <= SPARSE_RESULTS_THRESHOLD && searchResult.totalFound <= SPARSE_RESULTS_THRESHOLD) { + hints.push( + `few results (${searchResult.results.length}) — try a broader query or expand scope to surface adjacent topics`, + ) + } + + if (searchResult.results.length > 0 && topScore < LOW_CONFIDENCE_TOP_SCORE) { + hints.push( + `top score ${topScore.toFixed(2)} indicates low confidence — consider rephrasing or adding context-specific terms`, + ) + } + + return hints +} diff --git a/src/server/infra/executor/prefetch-context-builder.ts b/src/server/infra/executor/prefetch-context-builder.ts new file mode 100644 index 000000000..9e3bbafd8 --- /dev/null +++ b/src/server/infra/executor/prefetch-context-builder.ts @@ -0,0 +1,37 @@ +/** + * Shared helper for assembling a pre-fetched context bundle from BM25 search + * results. Used by: + * - QueryExecutor (legacy `brv_query` tier 3 path) for LLM prompt injection + * - GatherExecutor (Phase 5 `brv_gather` MCP + `brv gather` CLI) for the + * MCP-side bundle returned to external agents + * + * Pure function — no I/O. Excerpts are taken from the search result as-is + * (full document reads are out of scope here; deferred to direct-search-responder + * for the legacy direct-answer path). + */ + +import type {SearchKnowledgeResult} from '../../../agent/infra/sandbox/tools-sdk.js' + +/** Minimum normalized score for a result to be included in the prefetched bundle. */ +export const PREFETCH_SCORE_THRESHOLD = 0.7 + +/** + * Build a markdown-formatted context bundle from high-confidence search hits. + * Returns undefined when no results clear the score threshold. + */ +export function buildPrefetchedContext(searchResult: SearchKnowledgeResult): string | undefined { + if (searchResult.totalFound === 0) return undefined + + const highConfidenceResults = searchResult.results.filter((r) => r.score >= PREFETCH_SCORE_THRESHOLD) + + if (highConfidenceResults.length === 0) return undefined + + const sections = highConfidenceResults.map((r) => { + const source = + r.origin === 'shared' && r.originAlias ? `[${r.originAlias}]:${r.path}` : `.brv/context-tree/${r.path}` + + return `### ${r.title}\n**Source**: ${source}\n\n${r.excerpt}` + }) + + return sections.join('\n\n---\n\n') +} diff --git a/src/server/infra/executor/query-executor.ts b/src/server/infra/executor/query-executor.ts index 7a30cb37a..beed8eaf9 100644 --- a/src/server/infra/executor/query-executor.ts +++ b/src/server/infra/executor/query-executor.ts @@ -21,12 +21,9 @@ import { import {loadSources} from '../../core/domain/source/source-schema.js' import {isDerivedArtifact} from '../context-tree/derived-artifact.js' import {FileContextTreeManifestService} from '../context-tree/file-context-tree-manifest-service.js' -import { - canRespondDirectly, - type DirectSearchResult, - formatDirectResponse, - formatNotFoundResponse, -} from './direct-search-responder.js' +import {QueryDispatcher} from '../dispatcher/query-dispatcher.js' +import {formatNotFoundResponse} from './direct-search-responder.js' +import {buildPrefetchedContext} from './prefetch-context-builder.js' import {QueryResultCache} from './query-result-cache.js' /** Attribution footer appended to all query responses */ @@ -37,9 +34,6 @@ function buildMatchedDocs(sr: SearchKnowledgeResult | undefined): QueryLogMatche return (sr?.results ?? []).map((r) => ({path: r.path, score: r.score, title: r.title})) } -/** Minimum normalized score to consider a result high-confidence for pre-fetching */ -const SMART_ROUTING_SCORE_THRESHOLD = 0.7 - /** Maximum number of documents to pre-fetch and inject into the prompt */ const SMART_ROUTING_MAX_DOCS = 5 @@ -79,6 +73,20 @@ export interface QueryExecutorDeps { */ export class QueryExecutor implements IQueryExecutor { private static readonly FINGERPRINT_CACHE_TTL_MS = 30_000 + /** + * Tier 0/1/2 path. Stateless — owns the cache + BM25 + direct-response + * logic shared with `brv_search` MCP. Constructed in the executor so the + * legacy executor and any future MCP-side wiring share the same instance + * (same cache, same searchService) and avoid forking. Per + * PHASE-5-VALIDATION.md §2.2 (recommendation b), fingerprint computation + * stays on the executor and is passed in. + * + * Public so agent-process.ts can route the `'mcp-search'` task type + * directly through this dispatcher (PHASE-5-PLAN.md Task 5.2 §3.3 + * Option B — no parallel `SearchExecutor` to fork; keeps the cache + * instance shared with legacy `brv_query`). + */ + public readonly dispatcher: QueryDispatcher private readonly baseDirectory?: string private readonly cache?: QueryResultCache private cachedFingerprint?: {expiresAt: number; sourceValidityHash: string; value: string; worktreeRoot?: string} @@ -92,6 +100,12 @@ export class QueryExecutor implements IQueryExecutor { if (deps?.enableCache) { this.cache = new QueryResultCache() } + + this.dispatcher = new QueryDispatcher({ + ...(this.cache ? {cache: this.cache} : {}), + ...(this.fileSystem ? {fileSystem: this.fileSystem} : {}), + ...(this.searchService ? {searchService: this.searchService} : {}), + }) } public async executeWithAgent(agent: ICipherAgent, options: QueryExecuteOptions): Promise { @@ -104,90 +118,87 @@ export class QueryExecutor implements IQueryExecutor { // Prevent unhandled rejection if we return early (cache hit) while search is still pending searchPromise?.catch(() => {}) - // === Tier 0: Exact cache hit (0ms) === - let fingerprint: string | undefined - if (this.cache && this.fileSystem) { - fingerprint = await this.computeContextTreeFingerprint(worktreeRoot) - const cached = this.cache.get(query, fingerprint) - if (cached) { - return { - matchedDocs: [], - response: cached + ATTRIBUTION_FOOTER, - tier: TIER_EXACT_CACHE, - timing: {durationMs: Date.now() - startTime}, - } - } - } + // === Tiers 0/1/2 via QueryDispatcher (PHASE-5-PLAN.md Task 5.1) === + // Dispatcher owns cache + BM25 + direct-response. Executor handles + // entity-supplementation (legacy-only behavior) by wrapping the search + // promise before passing it in — preserves the parallel-search-with- + // fingerprint optimization without forking dispatcher logic. + const fingerprint = + this.cache && this.fileSystem ? await this.computeContextTreeFingerprint(worktreeRoot) : undefined + + const supplementedPromise: Promise | undefined = + searchPromise === undefined + ? undefined + : (async () => { + try { + let sr = await searchPromise + if (this.searchService && sr && sr.totalFound < 3) { + sr = await this.supplementEntitySearches(query, sr, workspaceScope) + } - // === Tier 1: Fuzzy cache match (~50ms) === - if (this.cache && fingerprint) { - const fuzzyHit = this.cache.findSimilar(query, fingerprint) - if (fuzzyHit) { - return { - matchedDocs: [], - response: fuzzyHit + ATTRIBUTION_FOOTER, - tier: TIER_FUZZY_CACHE, - timing: {durationMs: Date.now() - startTime}, - } - } - } + return sr + } catch { + + } + })() - // Await search result (already started in parallel with fingerprint computation) - let searchResult: SearchKnowledgeResult | undefined - try { - searchResult = await searchPromise - } catch { - // Search failed, proceed without pre-fetched context + const dispatchResult = await this.dispatcher.dispatch({ + ...(fingerprint === undefined ? {} : {fingerprint}), + query, + ...(workspaceScope === undefined ? {} : {scope: workspaceScope}), + ...(supplementedPromise === undefined ? {} : {searchPromise: supplementedPromise}), + }) + + if (dispatchResult.status === 'cached_answer') { + return { + matchedDocs: [], + response: dispatchResult.cachedAnswer + ATTRIBUTION_FOOTER, + tier: dispatchResult.tier === 0 ? TIER_EXACT_CACHE : TIER_FUZZY_CACHE, + timing: {durationMs: Date.now() - startTime}, + } } - // Supplementary entity-based searches for better multi-session recall - if (this.searchService && searchResult && searchResult.totalFound < 3) { - searchResult = await this.supplementEntitySearches(query, searchResult, workspaceScope) + if (dispatchResult.status === 'direct_passages') { + return { + matchedDocs: buildMatchedDocs(dispatchResult.searchResult), + response: dispatchResult.directAnswer + ATTRIBUTION_FOOTER, + searchMetadata: { + cacheFingerprint: fingerprint, + resultCount: dispatchResult.searchResult.results.length, + topScore: dispatchResult.searchResult.results[0]?.score ?? 0, + totalFound: dispatchResult.searchResult.totalFound, + }, + tier: TIER_DIRECT_SEARCH, + timing: {durationMs: Date.now() - startTime}, + } } - // === OOD short-circuit: no results means topic not covered === - if (searchResult && searchResult.results.length === 0) { - const response = formatNotFoundResponse(query) + if (dispatchResult.status === 'no_results') { + // OOD short-circuit: legacy executor caches a human-readable not-found + // response so future identical queries hit Tier 0. Dispatcher leaves + // this to the executor since the cached string is human-facing. + const notFoundResponse = formatNotFoundResponse(query) if (this.cache && fingerprint) { - this.cache.set(query, response, fingerprint) + this.cache.set(query, notFoundResponse, fingerprint) } return { matchedDocs: [], - response: response + ATTRIBUTION_FOOTER, + response: notFoundResponse + ATTRIBUTION_FOOTER, searchMetadata: {resultCount: 0, topScore: 0, totalFound: 0}, tier: TIER_DIRECT_SEARCH, timing: {durationMs: Date.now() - startTime}, } } - // === Tier 2: Direct search response (~100-200ms) === - if (searchResult && this.fileSystem) { - const directResult = await this.tryDirectSearchResponse(query, searchResult) - if (directResult) { - if (this.cache && fingerprint) { - this.cache.set(query, directResult, fingerprint) - } - - return { - matchedDocs: buildMatchedDocs(searchResult), - response: directResult + ATTRIBUTION_FOOTER, - searchMetadata: { - cacheFingerprint: fingerprint, - resultCount: searchResult.results.length, - topScore: searchResult.results[0]?.score ?? 0, - totalFound: searchResult.totalFound, - }, - tier: TIER_DIRECT_SEARCH, - timing: {durationMs: Date.now() - startTime}, - } - } - } + // status === 'needs_synthesis' — fall through to tier 3/4 with the + // dispatcher's already-resolved (and supplemented) search result. + const {searchResult} = dispatchResult // === Tier 3/4: LLM call with RLM pattern (variable-based search results) === let prefetchedContext: string | undefined if (searchResult && this.fileSystem) { - prefetchedContext = this.buildPrefetchedContext(searchResult) + prefetchedContext = buildPrefetchedContext(searchResult) } // Lazy manifest rebuild: provides broad structural awareness for LLM @@ -284,25 +295,19 @@ export class QueryExecutor implements IQueryExecutor { } /** - * Build pre-fetched context string from search results for LLM prompt injection. - * Synchronous — uses already-fetched search results (no additional I/O for excerpts). - * Full document reads happen only for high-confidence results. + * Public accessor for the context-tree fingerprint used by tier 0/1 cache + * lookups. Phase 5 Task 5.2 (PHASE-5-CODE-REVIEW.md F2) — agent-process's + * `'mcp-search'` case calls this so `brv_search` can hit cached answers + * and return the fingerprint to the agent for the round-trip + * search → gather → record-answer → search loop. + * + * Returns undefined when caching is disabled (no cache OR no fileSystem). + * Reuses the same TTL-bounded `cachedFingerprint` slot the legacy path uses, + * so MCP and CLI consumers share one cache miss not two. */ - private buildPrefetchedContext(searchResult: SearchKnowledgeResult): string | undefined { - if (searchResult.totalFound === 0) return undefined - - const highConfidenceResults = searchResult.results.filter((r) => r.score >= SMART_ROUTING_SCORE_THRESHOLD) - - if (highConfidenceResults.length === 0) return undefined - - const sections = highConfidenceResults.map((r) => { - const source = - r.origin === 'shared' && r.originAlias ? `[${r.originAlias}]:${r.path}` : `.brv/context-tree/${r.path}` - - return `### ${r.title}\n**Source**: ${source}\n\n${r.excerpt}` - }) - - return sections.join('\n\n---\n\n') + public async resolveFingerprint(worktreeRoot?: string): Promise { + if (!this.cache || !this.fileSystem) return undefined + return this.computeContextTreeFingerprint(worktreeRoot) } /** @@ -613,50 +618,4 @@ ${responseFormat}` } } - /** - * Attempt to produce a direct response from search results without LLM. - * Returns formatted response if high-confidence dominant match found, undefined otherwise. - * - * Uses higher thresholds than smart routing (score >= 8, 2x dominance) - * to ensure only clearly answerable queries bypass the LLM. - */ - private async tryDirectSearchResponse( - query: string, - searchResult: SearchKnowledgeResult, - ): Promise { - try { - if (searchResult.totalFound === 0) return undefined - - // Build full results with content - const fullResults: DirectSearchResult[] = await Promise.all( - searchResult.results - .filter((r) => r.score >= SMART_ROUTING_SCORE_THRESHOLD) - .slice(0, SMART_ROUTING_MAX_DOCS) - .map(async (result) => { - let content = result.excerpt - try { - // Use originContextTreeRoot for shared results, local context tree for local - const ctBase = result.originContextTreeRoot ?? join(BRV_DIR, CONTEXT_TREE_DIR) - const ctPath = join(ctBase, result.path) - const {content: fullContent} = await this.fileSystem!.readFile(ctPath) - content = fullContent - } catch { - // Use excerpt if full read fails - } - - // Include source attribution in path for shared results - const displayPath = - result.origin === 'shared' && result.originAlias ? `[${result.originAlias}]:${result.path}` : result.path - - return {content, path: displayPath, score: result.score, title: result.title} - }), - ) - - if (!canRespondDirectly(fullResults)) return undefined - - return formatDirectResponse(query, fullResults) - } catch { - return undefined - } - } } diff --git a/src/server/infra/executor/record-answer-executor.ts b/src/server/infra/executor/record-answer-executor.ts new file mode 100644 index 000000000..ffb6ecde4 --- /dev/null +++ b/src/server/infra/executor/record-answer-executor.ts @@ -0,0 +1,45 @@ +/** + * RecordAnswerExecutor (Phase 5 Task 5.4) — daemon-side handler for + * `brv_record_answer` MCP tool and `brv record-answer` CLI command. + * + * Closes the cache loop: agent calls this AFTER synthesizing from a + * `brv_gather` bundle, so future equivalent queries hit tier 0/1 via + * `brv_search` / `brv_query` without re-paying for synthesis. + * + * Failure modes are graceful — when no cache is configured (daemon + * started without it) or `cache.set` throws, returns `recorded: false` + * instead of erroring. Skill/hook agents shouldn't blow up just because + * cache is disabled or full. + */ + +import type { + IRecordAnswerExecutor, + RecordAnswerOptions, + RecordAnswerResult, +} from '../../core/interfaces/executor/i-record-answer-executor.js' +import type {QueryResultCache} from './query-result-cache.js' + +export interface RecordAnswerExecutorDeps { + cache?: QueryResultCache +} + +export class RecordAnswerExecutor implements IRecordAnswerExecutor { + private readonly cache?: QueryResultCache + + constructor(deps: RecordAnswerExecutorDeps) { + this.cache = deps.cache + } + + async execute(options: RecordAnswerOptions): Promise { + if (!this.cache) { + return {fingerprint: options.fingerprint, recorded: false} + } + + try { + this.cache.set(options.query, options.answer, options.fingerprint) + return {fingerprint: options.fingerprint, recorded: true} + } catch { + return {fingerprint: options.fingerprint, recorded: false} + } + } +} diff --git a/src/server/infra/mcp/mcp-server.ts b/src/server/infra/mcp/mcp-server.ts index dd7ac5370..8dce669af 100644 --- a/src/server/infra/mcp/mcp-server.ts +++ b/src/server/infra/mcp/mcp-server.ts @@ -14,7 +14,13 @@ import type {McpStartupProjectContext} from './tools/mcp-project-context.js' import {TransportClientEventNames} from '../../core/domain/transport/schemas.js' import {resolveLocalServerMainPath} from '../../utils/server-main-resolver.js' import {detectMcpMode, type McpMode} from './mcp-mode-detector.js' -import {registerBrvCurateTool, registerBrvQueryTool} from './tools/index.js' +import { + registerBrvCurateTool, + registerBrvGatherTool, + registerBrvQueryTool, + registerBrvRecordAnswerTool, + registerBrvSearchTool, +} from './tools/index.js' export interface McpServerConfig { /** CLI version for MCP server identification */ @@ -94,6 +100,24 @@ export class ByteRoverMcpServer { () => this.getWorkingDirectory(), getStartupProjectContext, ) + registerBrvSearchTool( + this.server, + () => this.client, + () => this.getWorkingDirectory(), + getStartupProjectContext, + ) + registerBrvGatherTool( + this.server, + () => this.client, + () => this.getWorkingDirectory(), + getStartupProjectContext, + ) + registerBrvRecordAnswerTool( + this.server, + () => this.client, + () => this.getWorkingDirectory(), + getStartupProjectContext, + ) } /** diff --git a/src/server/infra/mcp/tools/brv-gather-tool.ts b/src/server/infra/mcp/tools/brv-gather-tool.ts new file mode 100644 index 000000000..9422eaf2b --- /dev/null +++ b/src/server/infra/mcp/tools/brv-gather-tool.ts @@ -0,0 +1,110 @@ +/** + * brv-gather MCP tool (Phase 5 Task 5.3). + * + * Returns a context bundle for the agent to synthesize from. NEVER invokes + * the LLM — synthesis crosses the MCP boundary by design (DESIGN §4.2). + * + * The agent's typical flow: + * brv_search → status: 'needs_synthesis' → brv_gather → synthesize locally. + */ + +import type {ITransportClient} from '@campfirein/brv-transport-client' +import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js' + +import {waitForConnectedClient} from '@campfirein/brv-transport-client' +import {randomUUID} from 'node:crypto' +import {z} from 'zod' + +import {encodeGatherContent} from '../../../../shared/transport/gather-content.js' +import {TransportTaskEventNames} from '../../../core/domain/transport/schemas.js' +import {associateProjectWithRetry, type McpStartupProjectContext, resolveMcpTaskContext} from './mcp-project-context.js' +import {resolveClientCwd} from './resolve-client-cwd.js' +import {cwdField} from './shared-schema.js' +import {waitForTaskResult} from './task-result-waiter.js' + +/* eslint-disable camelcase -- DESIGN §6.2 specifies snake_case for MCP-facing fields */ +export const BrvGatherInputSchema = z.object({ + cwd: cwdField, + /** Result cap, default 10, max 50 (mirrors brv-search). */ + limit: z.number().int().min(1).max(50).optional(), + query: z.string().describe('Natural language question to gather context for'), + /** Optional path-prefix scope filter. */ + scope: z.string().optional(), + /** Soft cap on bundle tokens (default 4000 per DESIGN §6.2). */ + token_budget: z.number().int().min(100).max(64_000).optional(), +}) +/* eslint-enable camelcase */ + +export function registerBrvGatherTool( + server: McpServer, + getClient: () => ITransportClient | undefined, + getWorkingDirectory: () => string | undefined, + getStartupProjectContext: () => McpStartupProjectContext | undefined, +): void { + server.registerTool( + 'brv-gather', + { + description: + 'Assemble an LLM-free context bundle (BM25 passages + token estimate + follow-up hints) for the calling agent ' + + 'to synthesize from. Use after brv-search returns status: needs_synthesis.', + inputSchema: BrvGatherInputSchema, + title: 'ByteRover Gather', + }, + async (input: { + cwd?: string + limit?: number + query: string + scope?: string + token_budget?: number + }) => { + const cwdResult = resolveClientCwd(input.cwd, getWorkingDirectory) + if (!cwdResult.success) { + return {content: [{text: cwdResult.error, type: 'text' as const}], isError: true} + } + + const client = await waitForConnectedClient(getClient) + if (!client) { + return { + content: [ + { + text: 'Error: Not connected to the daemon. Connection timed out. Ensure "brv" is running.', + type: 'text' as const, + }, + ], + isError: true, + } + } + + try { + const taskContext = resolveMcpTaskContext(cwdResult.clientCwd, getStartupProjectContext()) + if (!getWorkingDirectory()) { + await associateProjectWithRetry(client, taskContext.projectRoot) + } + + const taskId = randomUUID() + const resultPromise = waitForTaskResult(client, taskId) + + await client.requestWithAck(TransportTaskEventNames.CREATE, { + clientCwd: cwdResult.clientCwd, + content: encodeGatherContent({ + ...(input.limit === undefined ? {} : {limit: input.limit}), + query: input.query, + ...(input.scope === undefined ? {} : {scope: input.scope}), + ...(input.token_budget === undefined ? {} : {tokenBudget: input.token_budget}), + }), + projectPath: taskContext.projectRoot, + taskId, + type: 'gather', + worktreeRoot: taskContext.worktreeRoot, + }) + + const result = await resultPromise + + return {content: [{text: result, type: 'text' as const}]} + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return {content: [{text: `Error: ${message}`, type: 'text' as const}], isError: true} + } + }, + ) +} diff --git a/src/server/infra/mcp/tools/brv-query-tool.ts b/src/server/infra/mcp/tools/brv-query-tool.ts index 02e612793..f896af192 100644 --- a/src/server/infra/mcp/tools/brv-query-tool.ts +++ b/src/server/infra/mcp/tools/brv-query-tool.ts @@ -6,6 +6,7 @@ import {randomUUID} from 'node:crypto' import {z} from 'zod' import {TransportTaskEventNames} from '../../../core/domain/transport/schemas.js' +import {recordLegacyQueryInvocation} from './deprecation-telemetry.js' import {associateProjectWithRetry, type McpStartupProjectContext, resolveMcpTaskContext} from './mcp-project-context.js' import {resolveClientCwd} from './resolve-client-cwd.js' import {cwdField} from './shared-schema.js' @@ -19,8 +20,15 @@ export const BrvQueryInputSchema = z.object({ /** * Registers the brv-query tool with the MCP server. * - * This tool allows coding agents to query the ByteRover context tree - * for patterns, decisions, implementation details, or any stored knowledge. + * **DEPRECATED (Phase 5 Task 5.5)** — agents should migrate to: + * `brv-search` (tier 0/1/2 — cached/BM25, no LLM) + * `brv-gather` (tier 3 prep — context bundle, no LLM; agent synthesizes) + * `brv-record-answer` (cache-write companion to close the loop) + * + * The legacy tool keeps working — old MCP clients are unaffected. Each + * invocation writes one JSONL line to `/telemetry/mcp-deprecation.jsonl` + * so we can decide when usage is low enough to remove this MCP path. + * The CLI `brv query` command stays — that's a separate consumer. */ export function registerBrvQueryTool( server: McpServer, @@ -31,11 +39,25 @@ export function registerBrvQueryTool( server.registerTool( 'brv-query', { - description: 'Query the ByteRover context tree for patterns, decisions, or implementation details.', + // MCP SDK's `annotations` only accepts standard fields (readOnlyHint, + // destructiveHint, etc.) — arbitrary metadata goes in `_meta`. Tool-aware + // clients can read `_meta.deprecated` / `_meta.replacedBy` to surface + // migration guidance; legacy clients see the `[deprecated]` prefix in + // the description and the title suffix. + _meta: { + deprecated: true, + replacedBy: ['brv-search', 'brv-gather', 'brv-record-answer'], + }, + description: + '[deprecated] Query the ByteRover context tree for patterns, decisions, or implementation details. ' + + 'Migrate to brv-search + brv-gather + brv-record-answer (LLM-free pipeline; agent synthesizes locally).', inputSchema: BrvQueryInputSchema, - title: 'ByteRover Query', + title: 'ByteRover Query (deprecated)', }, async ({cwd, query}: {cwd?: string; query: string}) => { + // Telemetry first — fire on every invocation so adoption metrics include + // failures (failed legacy calls still count as legacy usage). + recordLegacyQueryInvocation() // Resolve clientCwd: explicit cwd param > server working directory const cwdResult = resolveClientCwd(cwd, getWorkingDirectory) if (!cwdResult.success) { diff --git a/src/server/infra/mcp/tools/brv-record-answer-tool.ts b/src/server/infra/mcp/tools/brv-record-answer-tool.ts new file mode 100644 index 000000000..9b2ba0337 --- /dev/null +++ b/src/server/infra/mcp/tools/brv-record-answer-tool.ts @@ -0,0 +1,100 @@ +/** + * brv-record-answer MCP tool (Phase 5 Task 5.4). + * + * Closes the cache loop after agent-side synthesis. Agent calls this with + * the same query+fingerprint it received from a prior `brv_search` / + * `brv_gather` round-trip, plus the synthesized answer. Future equivalent + * queries hit tier 0/1 via `brv_search` / `brv_query`. + * + * Optional — agents that skip this lose cache benefit on equivalent + * future queries but stay correct (cache miss → full pipeline still works). + */ + +import type {ITransportClient} from '@campfirein/brv-transport-client' +import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js' + +import {waitForConnectedClient} from '@campfirein/brv-transport-client' +import {randomUUID} from 'node:crypto' +import {z} from 'zod' + +import {encodeRecordAnswerContent} from '../../../../shared/transport/record-answer-content.js' +import {TransportTaskEventNames} from '../../../core/domain/transport/schemas.js' +import {associateProjectWithRetry, type McpStartupProjectContext, resolveMcpTaskContext} from './mcp-project-context.js' +import {resolveClientCwd} from './resolve-client-cwd.js' +import {cwdField} from './shared-schema.js' +import {waitForTaskResult} from './task-result-waiter.js' + +export const BrvRecordAnswerInputSchema = z.object({ + answer: z.string().min(1).describe('Synthesized answer to cache for future tier-0/1 hits'), + cwd: cwdField, + fingerprint: z.string().min(1).describe('Cache key fingerprint from prior brv_search / brv_gather call'), + query: z.string().min(1).describe('The query the answer responds to'), +}) + +export function registerBrvRecordAnswerTool( + server: McpServer, + getClient: () => ITransportClient | undefined, + getWorkingDirectory: () => string | undefined, + getStartupProjectContext: () => McpStartupProjectContext | undefined, +): void { + server.registerTool( + 'brv-record-answer', + { + description: + 'Cache an agent-synthesized answer so future equivalent queries hit tier 0/1. ' + + 'Optional — skipping it loses cache benefit but stays correct. Use after synthesizing ' + + 'from a brv-gather bundle.', + inputSchema: BrvRecordAnswerInputSchema, + title: 'ByteRover Record Answer', + }, + async (input: {answer: string; cwd?: string; fingerprint: string; query: string}) => { + const cwdResult = resolveClientCwd(input.cwd, getWorkingDirectory) + if (!cwdResult.success) { + return {content: [{text: cwdResult.error, type: 'text' as const}], isError: true} + } + + const client = await waitForConnectedClient(getClient) + if (!client) { + return { + content: [ + { + text: 'Error: Not connected to the daemon. Connection timed out. Ensure "brv" is running.', + type: 'text' as const, + }, + ], + isError: true, + } + } + + try { + const taskContext = resolveMcpTaskContext(cwdResult.clientCwd, getStartupProjectContext()) + if (!getWorkingDirectory()) { + await associateProjectWithRetry(client, taskContext.projectRoot) + } + + const taskId = randomUUID() + const resultPromise = waitForTaskResult(client, taskId) + + await client.requestWithAck(TransportTaskEventNames.CREATE, { + clientCwd: cwdResult.clientCwd, + content: encodeRecordAnswerContent({ + answer: input.answer, + fingerprint: input.fingerprint, + query: input.query, + }), + projectPath: taskContext.projectRoot, + taskId, + type: 'record-answer', + worktreeRoot: taskContext.worktreeRoot, + }) + + const result = await resultPromise + + return {content: [{text: result, type: 'text' as const}]} + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return {content: [{text: `Error: ${message}`, type: 'text' as const}], isError: true} + } + }, + ) +} diff --git a/src/server/infra/mcp/tools/brv-search-tool.ts b/src/server/infra/mcp/tools/brv-search-tool.ts new file mode 100644 index 000000000..0e54ad33d --- /dev/null +++ b/src/server/infra/mcp/tools/brv-search-tool.ts @@ -0,0 +1,148 @@ +/** + * brv-search MCP tool (Phase 5 Task 5.2). + * + * Deterministic, LLM-free tier 0/1/2 query interface for external agents. + * The daemon-side handler (agent-process.ts case `'mcp-search'`) routes + * directly through `QueryDispatcher` (NOT a parallel SearchExecutor — + * that's reserved for the existing CLI `brv search` BM25 task type). + * + * Returns a JSON-encoded `DispatchResult` per DESIGN §6.1: + * {tier, status, passages?, cached_answer?, fingerprint?, total_found, timing_ms} + * + * Agents detect `status: 'needs_synthesis'` as the signal to escalate to + * `brv_gather` (Task 5.3). + */ + +import type {ITransportClient} from '@campfirein/brv-transport-client' +import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js' + +import {waitForConnectedClient} from '@campfirein/brv-transport-client' +import {randomUUID} from 'node:crypto' +import {z} from 'zod' + +import type {BrvSearchResult} from '../../dispatcher/query-dispatcher.js' + +import {encodeSearchContent} from '../../../../shared/transport/search-content.js' +import {TransportTaskEventNames} from '../../../core/domain/transport/schemas.js' +import {associateProjectWithRetry, type McpStartupProjectContext, resolveMcpTaskContext} from './mcp-project-context.js' +import {resolveClientCwd} from './resolve-client-cwd.js' +import {cwdField} from './shared-schema.js' +import {waitForTaskResult} from './task-result-waiter.js' + +export const BrvSearchInputSchema = z.object({ + cwd: cwdField, + /** Result cap, default 10, max 50 (DESIGN §6.1). */ + limit: z.number().int().min(1).max(50).optional(), + query: z.string().describe('Natural language question — tiers 0/1/2 only, no LLM'), + /** Optional path-prefix scope filter (e.g., "src/auth"). */ + scope: z.string().optional(), +}) + +/** + * Registers the brv-search tool with the MCP server. + * + * Unlike `brv-query` (legacy, marked deprecated in Task 5.5), this tool + * never invokes the LLM — it returns either a cached answer (tier 0/1), + * a direct BM25-derived answer + passages (tier 2 direct), or just + * passages with `status: 'needs_synthesis'` for the agent to feed into + * `brv_gather` and synthesize using its own model. + */ +export function registerBrvSearchTool( + server: McpServer, + getClient: () => ITransportClient | undefined, + getWorkingDirectory: () => string | undefined, + getStartupProjectContext: () => McpStartupProjectContext | undefined, +): void { + server.registerTool( + 'brv-search', + { + description: + 'LLM-free tier 0/1/2 search over the ByteRover context tree. Returns cached answer, ' + + 'direct BM25 answer + passages, or passages with needs_synthesis status. Cheap and deterministic. ' + + 'Escalate to brv-gather when status is needs_synthesis.', + inputSchema: BrvSearchInputSchema, + title: 'ByteRover Search', + }, + async (input: {cwd?: string; limit?: number; query: string; scope?: string}) => { + const cwdResult = resolveClientCwd(input.cwd, getWorkingDirectory) + if (!cwdResult.success) { + return { + content: [{text: cwdResult.error, type: 'text' as const}], + isError: true, + } + } + + const client = await waitForConnectedClient(getClient) + if (!client) { + return { + content: [ + { + text: 'Error: Not connected to the daemon. Connection timed out. Ensure "brv" is running.', + type: 'text' as const, + }, + ], + isError: true, + } + } + + try { + const taskContext = resolveMcpTaskContext(cwdResult.clientCwd, getStartupProjectContext()) + if (!getWorkingDirectory()) { + await associateProjectWithRetry(client, taskContext.projectRoot) + } + + const taskId = randomUUID() + const resultPromise = waitForTaskResult(client, taskId) + + await client.requestWithAck(TransportTaskEventNames.CREATE, { + clientCwd: cwdResult.clientCwd, + content: encodeSearchContent({ + ...(input.limit === undefined ? {} : {limit: input.limit}), + query: input.query, + ...(input.scope === undefined ? {} : {scope: input.scope}), + }), + projectPath: taskContext.projectRoot, + taskId, + type: 'mcp-search', + worktreeRoot: taskContext.worktreeRoot, + }) + + const result = await resultPromise + + // PHASE-5-CODE-REVIEW.md W2: return both text content (legacy MCP UIs) + // and structured `_meta` (tool-aware clients can read the typed payload + // without re-parsing the JSON text block). The daemon already emits the + // public BrvSearchResult DTO via toBrvSearchResult — see + // PHASE-5-CODE-REVIEW.md F4. If JSON parse fails, fall back to text-only + // so legacy callers still receive the raw daemon response. + let parsedMeta: BrvSearchResult | undefined + try { + const raw: unknown = JSON.parse(result) + // Light structural check — at minimum tier + status must be present + // (avoids `as` assertion per CLAUDE.md standards) + if ( + raw !== null && + typeof raw === 'object' && + 'tier' in raw && + 'status' in raw + ) { + parsedMeta = raw as BrvSearchResult + } + } catch { + // raw text fallback below + } + + return { + _meta: parsedMeta as unknown as Record | undefined, + content: [{text: result, type: 'text' as const}], + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + content: [{text: `Error: ${message}`, type: 'text' as const}], + isError: true, + } + } + }, + ) +} diff --git a/src/server/infra/mcp/tools/deprecation-telemetry.ts b/src/server/infra/mcp/tools/deprecation-telemetry.ts new file mode 100644 index 000000000..de23c604b --- /dev/null +++ b/src/server/infra/mcp/tools/deprecation-telemetry.ts @@ -0,0 +1,47 @@ +/** + * Deprecation telemetry (Phase 5 Task 5.5). + * + * One JSONL line per legacy `brv-query` MCP invocation, written to + * `/telemetry/mcp-deprecation.jsonl`. Used to track adoption of + * the new Phase 5 tools (`brv-search` + `brv-gather` + `brv-record-answer`) + * so we can decide when MCP-side `brv_query` usage has dropped low enough + * to remove the legacy tool entirely. + * + * Honors `BRV_DATA_DIR` per the standard data-dir convention. Best-effort — + * never throws; telemetry failures must not block the query handler. + * + * Lives in its own module (rather than inline in `brv-query-tool.ts`) so it + * can be unit-tested independently and so future deprecation flows (e.g., + * other legacy MCP tools) can reuse the same writer. + */ + +import {appendFileSync, mkdirSync} from 'node:fs' +import {join} from 'node:path' + +import {getGlobalDataDir} from '../../../utils/global-data-path.js' + +const TELEMETRY_DIR = 'telemetry' +const TELEMETRY_FILE = 'mcp-deprecation.jsonl' + +/** + * Record one invocation of the deprecated `brv-query` MCP tool. + * Called from the tool handler before transport routing, so failed legacy + * calls also count as legacy usage (adoption metrics aren't skewed by errors). + * + * Failures are swallowed: if the data dir is unwritable, telemetry is lost + * but the user's query is not blocked. + */ +export function recordLegacyQueryInvocation(): void { + try { + const dir = join(getGlobalDataDir(), TELEMETRY_DIR) + mkdirSync(dir, {recursive: true}) + const line = JSON.stringify({ + counter: 'mcp.query.legacy_invocations', + tool: 'brv-query', + ts: new Date().toISOString(), + }) + appendFileSync(join(dir, TELEMETRY_FILE), line + '\n', 'utf8') + } catch { + // Best-effort: never block the handler on telemetry failure. + } +} diff --git a/src/server/infra/mcp/tools/index.ts b/src/server/infra/mcp/tools/index.ts index cc218b25a..50e2ecb6a 100644 --- a/src/server/infra/mcp/tools/index.ts +++ b/src/server/infra/mcp/tools/index.ts @@ -1,5 +1,8 @@ export { registerBrvCurateTool } from './brv-curate-tool.js' +export { registerBrvGatherTool } from './brv-gather-tool.js' export { registerBrvQueryTool } from './brv-query-tool.js' +export { registerBrvRecordAnswerTool } from './brv-record-answer-tool.js' +export { registerBrvSearchTool } from './brv-search-tool.js' export { resolveClientCwd } from './resolve-client-cwd.js' export { CWD_DESCRIPTION, cwdField } from './shared-schema.js' export { waitForTaskResult } from './task-result-waiter.js' diff --git a/src/server/infra/process/task-router.ts b/src/server/infra/process/task-router.ts index 4903971e1..980547ba9 100644 --- a/src/server/infra/process/task-router.ts +++ b/src/server/infra/process/task-router.ts @@ -600,6 +600,11 @@ export class TaskRouter { ...(data.files?.length ? {files: data.files} : {}), ...(data.folderPath ? {folderPath: data.folderPath} : {}), ...(data.force === undefined ? {} : {force: data.force}), + // R-3 (PHASE-2.5-PLAN.md §3.4): forward the curate-log identifier + // (cur-) generated by CurateLogHandler.onTaskCreate so the + // executor can include it in the per-leaf `Reason` field. Only present + // for curate tasks — runCreateHooks returns undefined for other types. + ...(logId ? {logId} : {}), ...(projectPath ? {projectPath} : {}), ...(reviewDisabled === undefined ? {} : {reviewDisabled}), taskId, diff --git a/src/server/templates/sections/mcp-workflow.md b/src/server/templates/sections/mcp-workflow.md index 5314b8568..3c13c533a 100644 --- a/src/server/templates/sections/mcp-workflow.md +++ b/src/server/templates/sections/mcp-workflow.md @@ -4,10 +4,33 @@ You are a coding agent integrated with ByteRover via MCP (Model Context Protocol ## Core Rules -1. **Query First**: Automatically call the mcp tool `brv-query` when you need to query the context for the task and you do not have the context. -2. **Curate Later**: After finishing the task, call `brv-curate` to store back the knowledge if it is very important. +1. **Search first (LLM-free):** Call `brv-search` to check the cache + BM25 index. If `tier <= 2` returns `cached_answer` or `direct_passages`, use it directly — no synthesis needed. +2. **Gather when needed:** If `brv-search` returns `status: 'needs_synthesis'`, call `brv-gather` to assemble a context bundle (still no LLM cost), then synthesize the answer with your own model. +3. **Record back (optional):** After synthesizing from a `brv-gather` bundle, call `brv-record-answer` with the same `fingerprint` you received from `brv-search`/`brv-gather`. Future equivalent queries hit `tier 0/1` cache and skip synthesis. +4. **Curate later:** After completing the task, call `brv-curate` to store back the knowledge if it is durably important (cache TTL is 60s; `brv-curate` is for permanent context-tree storage). ## Tool Usage -- `brv-query`: Query the context tree. -- `brv-curate`: Store context to the context tree. +LLM-free path (preferred — cheap, deterministic): + +- `brv-search`: Tier 0/1/2 — cached answer, direct BM25 answer, or passages with `needs_synthesis` status. +- `brv-gather`: Assemble a context bundle (passages + token estimate + follow-up hints) for the agent to synthesize from. Never invokes the daemon LLM. +- `brv-record-answer`: Cache an agent-synthesized answer so future equivalent queries hit tier 0/1. + +Curation: + +- `brv-curate`: Store context to the context tree (durable; uses the configured LLM provider for categorization). + +Legacy: + +- `brv-query`: **Deprecated.** Migrate to the `brv-search` → `brv-gather` → (your LLM) → `brv-record-answer` pipeline. Existing calls keep working but every invocation is logged for adoption tracking. + +## Pipeline example + +```text +brv-search "how does auth work" → status: 'needs_synthesis', fingerprint: +brv-gather "how does auth work" → prefetched_context, follow_up_hints +(your model synthesizes from the bundle) +brv-record-answer "how does auth work" "Auth uses JWTs..." --fingerprint +brv-search "how does auth work" → tier: 0, status: 'cached_answer' (instant) +``` diff --git a/src/server/templates/skill/SKILL.md b/src/server/templates/skill/SKILL.md index bfe16992b..11b4a639f 100644 --- a/src/server/templates/skill/SKILL.md +++ b/src/server/templates/skill/SKILL.md @@ -14,6 +14,42 @@ Knowledge is stored in `.brv/context-tree/` as human-readable Markdown files. ## Workflow 1. **Before Thinking:** Run `brv query` to understand existing patterns. 2. **After Implementing:** Run `brv curate` to save new patterns/decisions. +3. **For agent synthesis (LLM-free pipeline):** `brv search` → if no high-confidence direct answer, `brv gather` → synthesize with your own model → `brv record-answer` to cache the result. Skips internal LLM cost while still warming the cache for future similar queries. + +## MCP Workflow + +ByteRover exposes 5 MCP tools. Use them as follows. + +### Querying knowledge + +1. **Always start with `brv-search`.** It covers cached + direct results without + any LLM cost. + + - If `tier ≤ 2`: response is final. Use `cached_answer` or `passages`. + - If `status: 'no_results'`: stop. The knowledge base does not contain it. + - If `status: 'needs_synthesis'`: escalate to `brv-gather`. + +2. **`brv-gather` returns a context bundle for you to synthesize.** It does not + itself produce an answer. Synthesize from `prefetched_context` + + `manifest_context` using your own model. + +3. **Iterate as needed.** Refined queries can re-enter at `brv-search`. There is no + separate "agentic" tool — your loop is tier 4. + +4. **(Optional) record your synthesis** via `brv-record-answer` to populate the + cache for future hits. Pass back the same `fingerprint` you received from the + prior `brv-search` call. + +### Curating knowledge + +Use `brv-curate` (MCP) or `brv curate` (CLI) — same backend. The agent code +contribution surface (`nodes`, `nodes_meta`, `mode` fields) lands in Phase 3 and +is not yet enabled. + +### Legacy: `brv-query` + +`brv-query` still works but is **deprecated**. Migrate to `brv-search` + +`brv-gather` + `brv-record-answer`. The CLI `brv query` command is unaffected. ## Commands @@ -109,7 +145,56 @@ brv curate view --format json Only proceed when `status: completed`. If `processing`, wait or tell the user. If `error`/`cancelled`, report and consider re-curate. `--detach` errors are silent — verification before trust is mandatory. -### 4. Review Pending Changes +### 4. Gather Context Bundle +**Overview:** Assemble an LLM-free context bundle from `.brv/context-tree/` — the prefetched markdown bundle, search metadata, token estimate, and follow-up hints. Same payload that the `brv-gather` MCP tool returns. The agent (or human) synthesizes the answer locally with their own model. + +**Use this skill when:** +- `brv search` returned no high-confidence direct answer and you want to synthesize one yourself without paying for the daemon's internal LLM +- Pipelining: `brv gather "..." --format json | ` for ad-hoc external-LLM queries +- Debugging: "what context would the agent see for this query?" + +**Do NOT use this skill when:** +- A simple `brv search` already returned a usable direct passage — no need for bundle assembly +- You want a daemon-synthesized answer — use `brv query` (legacy) instead + +```bash +brv gather "how does authentication work" +brv gather "JWT tokens" --scope auth/ --limit 5 +brv gather "auth" --token-budget 8000 +brv gather "auth" --format json | jq .total_tokens_estimated +``` + +**Flags:** `--limit N` (1-50, default 10), `--scope "domain/"` (path prefix filter), `--token-budget N` (soft cap on bundle tokens; default 4000; truncates excess sections), `--format json` (structured output for tooling). + +After synthesizing locally, optionally close the cache loop with `brv record-answer` (next section). + +### 5. Record Synthesized Answer +**Overview:** Cache an agent-synthesized answer so future equivalent queries hit tier 0/1 cache and skip the synthesis cost. Closes the LLM-free pipeline: + +```text +brv search → brv gather → (your LLM synthesizes) → brv record-answer +``` + +**Use this skill when:** +- You just ran `brv gather` + your own LLM synthesis and the answer is reusable +- You want future equivalent queries to short-circuit through the cache + +**Do NOT use this skill when:** +- The answer is one-shot or unlikely to be re-asked (cache slot is finite) +- You don't have the `fingerprint` from a prior `brv search` / `brv gather` call (it's required as the cache key) + +```bash +brv record-answer "how does auth work" "Auth uses JWTs..." --fingerprint +brv record-answer "q" "a" --fingerprint fp --format json +``` + +**Flags:** `--fingerprint ` (REQUIRED — cache key from prior brv search/gather call), `--format json` (structured output for CI/automation). + +**Cache TTL:** 60 seconds by default. Recorded answers expire after that window — so this is for amortizing cost across short bursts of similar queries, not long-term storage. For durable knowledge, use `brv curate` instead. + +**Failure mode:** if the daemon was started without the cache enabled, the call returns `recorded: false` (no error). It is safe to fire-and-forget. + +### 6. Review Pending Changes **Overview:** After a curate operation, some changes may require human review before being applied. Use `brv review` to list, approve, or reject pending operations. **Use this when:** @@ -172,7 +257,7 @@ brv review approve --format json brv review reject --format json ``` -### 5. LLM Provider Setup +### 7. LLM Provider Setup `brv query` and `brv curate` require a configured LLM provider. Connect the default ByteRover provider (no API key needed): ```bash @@ -186,7 +271,7 @@ brv providers list brv providers connect openai --api-key sk-xxx --model gpt-4.1 ``` -### 6. Project Locations +### 8. Project Locations **Overview:** List registered projects and their context tree paths. Returns project metadata including initialization status and active state. Use `-f json` for machine-readable output. **Use this when:** @@ -204,7 +289,7 @@ brv locations -f json JSON fields: `projectPath`, `contextTreePath`, `isCurrent`, `isActive`, `isInitialized`. -### 7. Version Control +### 9. Version Control **Overview:** `brv vc` provides git-based version control for your context tree. It uses standard git semantics — branching, committing, merging, history, and conflict resolution — all working locally with no authentication required. Remote sync with a team is optional. The legacy `brv push`, `brv pull`, and `brv space` commands are deprecated — use `brv vc push`, `brv vc pull`, and `brv vc clone`/`brv vc remote add` instead. **Use this when:** @@ -320,7 +405,7 @@ brv vc push -u origin main # push and set upstream tracking brv vc clone https://byterover.dev//.git ``` -### 8. Swarm Query +### 10. Swarm Query **Overview:** Search across all active memory providers simultaneously — ByteRover context tree, Obsidian vault, Local Markdown folders, GBrain, and Memory Wiki. Results are fused via Reciprocal Rank Fusion (RRF) and ranked by provider weight and relevance. No LLM call — pure algorithmic search. **Use this skill when:** @@ -399,7 +484,7 @@ brv swarm query "testing strategy" -n 5 **Flags:** `--explain` (show routing details), `--format json` (structured output), `-n ` (max results). -### 9. Swarm Curate +### 11. Swarm Curate **Overview:** Store knowledge in the best available external memory provider. ByteRover automatically classifies the content type and routes accordingly: entities (people, orgs) go to GBrain, notes (meeting notes, TODOs) go to Local Markdown, general content goes to the first writable provider. Falls back to ByteRover context tree if no external providers are available. **Use this skill when:** @@ -455,7 +540,7 @@ Output: **Flags:** `--provider ` (target specific provider), `--format json` (structured output). -### 10. Swarm Status +### 12. Swarm Status **Overview:** Check provider health and write targets before running swarm query or curate. Use this to verify which providers are available and operational. **Use this skill when:** @@ -483,7 +568,7 @@ Write Targets: Swarm is operational (5/5 providers configured). ``` -### 11. Query and Curate History +### 13. Query and Curate History **Overview:** Inspect past query and curate operations. Use `brv query-log view` to review query history, `brv curate view` to review curate history, and `brv query-log summary` to see aggregated recall metrics. Supports filtering by time, status, tier, and detailed per-operation output. **Use this skill when:** diff --git a/src/shared/transport/gather-content.ts b/src/shared/transport/gather-content.ts new file mode 100644 index 000000000..98c7498f5 --- /dev/null +++ b/src/shared/transport/gather-content.ts @@ -0,0 +1,45 @@ +/** + * Encode/decode helpers for `gather` task content payloads. + * + * The transport layer's TaskCreateRequest has a single `content: string` + * field. For gather tasks (Phase 5 Task 5.3), we pack + * {query, limit?, scope?, tokenBudget?} as JSON so the daemon's + * GatherExecutor can reconstruct the structured options. + * + * Lives in shared/ because both the CLI (encoder, brv gather command) + * and the daemon agent-process (decoder) depend on it. + */ + +export interface GatherContentPayload { + limit?: number + query: string + scope?: string + tokenBudget?: number +} + +export function encodeGatherContent(options: GatherContentPayload): string { + return JSON.stringify({ + limit: options.limit, + query: options.query, + scope: options.scope, + tokenBudget: options.tokenBudget, + }) +} + +/** + * Parse a JSON-encoded gather content payload back into options. + * Falls back to treating the entire string as a plain query if parsing fails. + */ +export function decodeGatherContent(content: string): GatherContentPayload { + try { + const parsed = JSON.parse(content) as Partial + return { + limit: typeof parsed.limit === 'number' ? parsed.limit : undefined, + query: typeof parsed.query === 'string' ? parsed.query : content, + scope: typeof parsed.scope === 'string' ? parsed.scope : undefined, + tokenBudget: typeof parsed.tokenBudget === 'number' ? parsed.tokenBudget : undefined, + } + } catch { + return {query: content} + } +} diff --git a/src/shared/transport/record-answer-content.ts b/src/shared/transport/record-answer-content.ts new file mode 100644 index 000000000..531fe3649 --- /dev/null +++ b/src/shared/transport/record-answer-content.ts @@ -0,0 +1,44 @@ +/** + * Encode/decode helpers for `record-answer` task content payloads. + * + * Phase 5 Task 5.4. Packs {query, answer, fingerprint} as JSON for the + * single-string transport content field. Both the CLI command + * (`brv record-answer`) and the MCP tool (`brv_record_answer`) encode; + * the daemon's RecordAnswerExecutor decodes. + */ + +export interface RecordAnswerContentPayload { + answer: string + fingerprint: string + query: string +} + +export function encodeRecordAnswerContent(options: RecordAnswerContentPayload): string { + return JSON.stringify({ + answer: options.answer, + fingerprint: options.fingerprint, + query: options.query, + }) +} + +/** + * Parse a JSON-encoded record-answer payload. Throws on malformed input + * (unlike search/gather which fall back to plain query) — fingerprint and + * answer are required, no sensible default. + */ +export function decodeRecordAnswerContent(content: string): RecordAnswerContentPayload { + const parsed = JSON.parse(content) as Partial + if (typeof parsed.query !== 'string' || !parsed.query) { + throw new TypeError('record-answer payload missing query') + } + + if (typeof parsed.answer !== 'string') { + throw new TypeError('record-answer payload missing answer') + } + + if (typeof parsed.fingerprint !== 'string' || !parsed.fingerprint) { + throw new TypeError('record-answer payload missing fingerprint') + } + + return {answer: parsed.answer, fingerprint: parsed.fingerprint, query: parsed.query} +} diff --git a/test/benchmarks/curate-flow.bench.ts b/test/benchmarks/curate-flow.bench.ts new file mode 100644 index 000000000..ca97f36a5 --- /dev/null +++ b/test/benchmarks/curate-flow.bench.ts @@ -0,0 +1,165 @@ +/** + * Phase 2 Task 2.5 — curate-flow bench harness. + * + * Opt-in: skipped unless `BENCH=1` is set. Not run by CI. + * + * Goal: track wall-clock + per-slot timings + LLM-call counts as the + * curate-flow DAG evolves (Phase 2 fan-out, Phase 3 agent-supplied + * code, Phase 4 harness promotion). Stub services use a fixed + * artificial per-chunk latency so the fan-out improvement is visible + * without a real LLM connection. + * + * Usage: + * + * BENCH=1 npx mocha test/benchmarks/curate-flow.bench.ts + * + * Writes a JSON results file under + * test/benchmarks/results/curate-flow-.json + * + * The Phase 1 baseline (sequential extract) is committed at + * test/benchmarks/results/curate-flow-phase1-baseline.json so future + * runs can compare against it without re-running history. + */ + +import {expect} from 'chai' +import {readFileSync, writeFileSync} from 'node:fs' +import {join} from 'node:path' + +import {MetricsCollector} from '../../src/agent/core/curation/flow/metrics.js' +import { + type NodeContext, + type NodeServices, + TopologicalCurationRunner, +} from '../../src/agent/core/curation/flow/runner.js' +import {buildCurationDAG} from '../../src/agent/infra/curation/flow/dag-builder.js' + +const FIXTURES_DIR = join(process.cwd(), 'test', 'fixtures', 'curation') +const RESULTS_DIR = join(process.cwd(), 'test', 'benchmarks', 'results') + +const PER_CHUNK_LATENCY_MS = 50 // synthetic LLM round-trip + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +function makeStubServices(): NodeServices & {readonly llmCallCount: () => number} { + let calls = 0 + return { + async detectConflicts(facts) { + calls++ + return {decisions: facts.map((fact) => ({action: 'add' as const, fact}))} + }, + async extract(chunk, _taskId) { + calls++ + await delay(PER_CHUNK_LATENCY_MS) + const facts: Array<{statement: string; subject: string}> = [] + if (chunk.includes('JWT')) facts.push({statement: 'Auth uses JWT in httpOnly cookies', subject: 'auth'}) + if (chunk.includes('PostgreSQL')) facts.push({statement: 'Database is PostgreSQL 15', subject: 'database'}) + if (chunk.includes('rate limit') || chunk.includes('rate-limit')) + facts.push({statement: 'Rate limit is 100/min per IP', subject: 'rate-limit'}) + return {facts, failed: 0, succeeded: facts.length, total: 1} + }, + llmCallCount: () => calls, + write: async (decisions) => ({ + applied: decisions.map((d) => ({ + confidence: 'high' as const, + impact: 'low' as const, + needsReview: false, + path: `${d.fact.subject ?? 'misc'}/${d.fact.statement.slice(0, 30)}.md`, + reason: 'bench', + status: 'success' as const, + type: 'ADD' as const, + })), + summary: {added: decisions.length, deleted: 0, failed: 0, merged: 0, updated: 0}, + }), + } +} + +interface FixtureResult { + bytes: number + fixture: string + llmCallCount: number + perSlotMs: Record + totalChunks: number + wallClockMs: number +} + +async function runOnce(label: string, fixturePath: string, extractConcurrency: number): Promise { + const context = readFileSync(fixturePath, 'utf8') + const services = makeStubServices() + const taskId = `bench-${label}-c${extractConcurrency}` + const collector = new MetricsCollector(taskId) + + const ctx: NodeContext = { + extractConcurrency, + initialInput: {context, history: {}, meta: {}}, + metricsCollector: collector, + sandboxed: true, + services, + taskId, + } + + const start = Date.now() + const dag = buildCurationDAG() + const result = await new TopologicalCurationRunner().run(dag, ctx) + const wallClockMs = Date.now() - start + + const {totalChunks} = (result.outputs.get('chunk') as {totalChunks: number}) + return { + bytes: context.length, + fixture: label, + llmCallCount: services.llmCallCount(), + perSlotMs: collector.emit().nodeTimings, + totalChunks, + wallClockMs, + } +} + +const BENCH_ENABLED = process.env.BENCH === '1' + +;(BENCH_ENABLED ? describe : describe.skip)('curate-flow bench (opt-in: BENCH=1)', function () { + this.timeout(60_000) + + const fixtures = ['small', 'large', 'xlarge'] as const + const results: Array<{concurrency: number; result: FixtureResult}> = [] + + for (const fixture of fixtures) { + // c=1 baseline (sequential), c=4 Phase 2 default, c=8 Phase 2.5 R-5 default. + for (const concurrency of [1, 4, 8]) { + it(`${fixture} fixture @ extractConcurrency=${concurrency}`, async () => { + const fixturePath = join(FIXTURES_DIR, `${fixture}.txt`) + const r = await runOnce(fixture, fixturePath, concurrency) + results.push({concurrency, result: r}) + + // Sanity: results should be plausible (positive durations, > 0 chunks). + expect(r.wallClockMs).to.be.greaterThan(0) + expect(r.totalChunks).to.be.greaterThan(0) + + process.stdout.write( + ` → ${fixture} (${r.bytes}B, ${r.totalChunks} chunks) c=${concurrency}: ` + + `${r.wallClockMs}ms wall, ${r.llmCallCount} llm calls\n`, + ) + }) + } + } + + after(() => { + const ts = new Date().toISOString().replaceAll(/[:.]/g, '-') + const outPath = join(RESULTS_DIR, `curate-flow-${ts}.json`) + writeFileSync( + outPath, + JSON.stringify( + { + perChunkLatencyMs: PER_CHUNK_LATENCY_MS, + results, + timestamp: new Date().toISOString(), + }, + null, + 2, + ), + ) + process.stdout.write(`\n Wrote ${outPath}\n`) + }) +}) diff --git a/test/benchmarks/results/curate-flow-phase2-baseline.json b/test/benchmarks/results/curate-flow-phase2-baseline.json new file mode 100644 index 000000000..c4c331fe2 --- /dev/null +++ b/test/benchmarks/results/curate-flow-phase2-baseline.json @@ -0,0 +1,120 @@ +{ + "perChunkLatencyMs": 50, + "results": [ + { + "concurrency": 1, + "result": { + "bytes": 2487, + "fixture": "small", + "llmCallCount": 2, + "perSlotMs": { + "recon": 0.8857920000000377, + "chunk": 0.26466599999980644, + "extract": 51.48933299999999, + "group": 0.20637500000020736, + "dedup": 0.25520800000003874, + "conflict": 0.09195799999997689, + "write": 0.1636670000000322 + }, + "totalChunks": 1, + "wallClockMs": 53 + } + }, + { + "concurrency": 4, + "result": { + "bytes": 2487, + "fixture": "small", + "llmCallCount": 2, + "perSlotMs": { + "recon": 0.09837500000003274, + "chunk": 0.12308299999995143, + "extract": 51.26324999999997, + "group": 0.11295800000016243, + "dedup": 0.1097500000000764, + "conflict": 0.1382499999999709, + "write": 0.07799999999997453 + }, + "totalChunks": 1, + "wallClockMs": 52 + } + }, + { + "concurrency": 1, + "result": { + "bytes": 64471, + "fixture": "large", + "llmCallCount": 12, + "perSlotMs": { + "recon": 0.21529099999997925, + "chunk": 0.6496669999999085, + "extract": 564.2360829999998, + "group": 0.5332080000000587, + "dedup": 0.46787499999982174, + "conflict": 0.08054199999992306, + "write": 0.12095799999997325 + }, + "totalChunks": 11, + "wallClockMs": 567 + } + }, + { + "concurrency": 4, + "result": { + "bytes": 64471, + "fixture": "large", + "llmCallCount": 12, + "perSlotMs": { + "recon": 0.31712500000003274, + "chunk": 0.844957999999906, + "extract": 153.14337499999988, + "group": 0.37258299999984956, + "dedup": 0.44829100000015387, + "conflict": 0.08962500000006912, + "write": 0.14383399999996982 + }, + "totalChunks": 11, + "wallClockMs": 156 + } + }, + { + "concurrency": 1, + "result": { + "bytes": 154841, + "fixture": "xlarge", + "llmCallCount": 23, + "perSlotMs": { + "recon": 0.38704199999983757, + "chunk": 1.0731249999998909, + "extract": 1126.965541, + "group": 0.846458999999868, + "dedup": 1.1194169999998849, + "conflict": 0.11520899999959511, + "write": 0.13129200000003038 + }, + "totalChunks": 22, + "wallClockMs": 1130 + } + }, + { + "concurrency": 4, + "result": { + "bytes": 154841, + "fixture": "xlarge", + "llmCallCount": 23, + "perSlotMs": { + "recon": 0.5370829999997113, + "chunk": 0.6504159999999501, + "extract": 307.5136669999997, + "group": 0.6147920000003069, + "dedup": 0.8307079999999587, + "conflict": 0.07437500000014552, + "write": 0.10062500000003638 + }, + "totalChunks": 22, + "wallClockMs": 311 + } + } + ], + "timestamp": "2026-04-28T00:47:26.306Z" +} \ No newline at end of file diff --git a/test/benchmarks/results/curate-flow-phase2.5-baseline.json b/test/benchmarks/results/curate-flow-phase2.5-baseline.json new file mode 100644 index 000000000..7d6f6b97c --- /dev/null +++ b/test/benchmarks/results/curate-flow-phase2.5-baseline.json @@ -0,0 +1,177 @@ +{ + "perChunkLatencyMs": 50, + "results": [ + { + "concurrency": 1, + "result": { + "bytes": 2487, + "fixture": "small", + "llmCallCount": 2, + "perSlotMs": { + "recon": 0.8111250000001746, + "chunk": 0.26537499999994907, + "extract": 51.53241700000012, + "group": 0.2055840000000444, + "dedup": 0.23270899999988615, + "conflict": 0.08412500000008549, + "write": 0.15566600000011022 + }, + "totalChunks": 1, + "wallClockMs": 54 + } + }, + { + "concurrency": 4, + "result": { + "bytes": 2487, + "fixture": "small", + "llmCallCount": 2, + "perSlotMs": { + "recon": 0.10470899999995709, + "chunk": 0.11129200000004857, + "extract": 51.16416600000002, + "group": 0.0697499999998854, + "dedup": 0.07258299999989504, + "conflict": 0.08808399999998073, + "write": 0.05587500000001455 + }, + "totalChunks": 1, + "wallClockMs": 52 + } + }, + { + "concurrency": 8, + "result": { + "bytes": 2487, + "fixture": "small", + "llmCallCount": 2, + "perSlotMs": { + "recon": 0.04158300000017334, + "chunk": 0.02670799999987139, + "extract": 51.177208000000064, + "group": 0.0632909999999356, + "dedup": 0.08270900000002257, + "conflict": 0.035999999999830834, + "write": 0.1324170000000322 + }, + "totalChunks": 1, + "wallClockMs": 52 + } + }, + { + "concurrency": 1, + "result": { + "bytes": 64471, + "fixture": "large", + "llmCallCount": 12, + "perSlotMs": { + "recon": 0.1300410000001193, + "chunk": 0.43716700000004494, + "extract": 561.1200839999999, + "group": 0.22087499999997817, + "dedup": 0.18929099999991195, + "conflict": 0.03716700000018136, + "write": 0.04941699999994853 + }, + "totalChunks": 11, + "wallClockMs": 563 + } + }, + { + "concurrency": 4, + "result": { + "bytes": 64471, + "fixture": "large", + "llmCallCount": 12, + "perSlotMs": { + "recon": 0.1426249999999527, + "chunk": 0.31233399999996436, + "extract": 153.80104200000005, + "group": 0.15829200000007404, + "dedup": 0.1900829999999587, + "conflict": 0.0708750000001146, + "write": 0.053666999999904874 + }, + "totalChunks": 11, + "wallClockMs": 155 + } + }, + { + "concurrency": 8, + "result": { + "bytes": 64471, + "fixture": "large", + "llmCallCount": 12, + "perSlotMs": { + "recon": 0.12145800000007512, + "chunk": 0.2675409999999374, + "extract": 101.82033300000012, + "group": 0.28845799999999144, + "dedup": 0.364875000000211, + "conflict": 0.0702080000000933, + "write": 0.14091599999983373 + }, + "totalChunks": 11, + "wallClockMs": 103 + } + }, + { + "concurrency": 1, + "result": { + "bytes": 154841, + "fixture": "xlarge", + "llmCallCount": 23, + "perSlotMs": { + "recon": 0.40162499999996726, + "chunk": 1.0075410000004013, + "extract": 1121.9451250000002, + "group": 0.6178749999999127, + "dedup": 0.40866700000015044, + "conflict": 0.04645899999968606, + "write": 0.25787499999978536 + }, + "totalChunks": 22, + "wallClockMs": 1125 + } + }, + { + "concurrency": 4, + "result": { + "bytes": 154841, + "fixture": "xlarge", + "llmCallCount": 23, + "perSlotMs": { + "recon": 0.19370799999978772, + "chunk": 0.28566600000021936, + "extract": 305.25549999999976, + "group": 0.40970900000002075, + "dedup": 0.6985419999996338, + "conflict": 0.06345800000008239, + "write": 0.0808750000001055 + }, + "totalChunks": 22, + "wallClockMs": 307 + } + }, + { + "concurrency": 8, + "result": { + "bytes": 154841, + "fixture": "xlarge", + "llmCallCount": 23, + "perSlotMs": { + "recon": 0.39304200000015044, + "chunk": 0.5394999999998618, + "extract": 153.49779200000012, + "group": 0.4946250000002692, + "dedup": 0.6731669999999212, + "conflict": 0.056666999999833934, + "write": 0.0777499999999236 + }, + "totalChunks": 22, + "wallClockMs": 156 + } + } + ], + "timestamp": "2026-04-28T05:14:32.792Z" +} \ No newline at end of file diff --git a/test/commands/gather.test.ts b/test/commands/gather.test.ts new file mode 100644 index 000000000..8c1ea892f --- /dev/null +++ b/test/commands/gather.test.ts @@ -0,0 +1,178 @@ +/* eslint-disable camelcase -- gather payload uses snake_case per DESIGN §6.2 */ +/** + * `brv gather` CLI command tests (Phase 5 Task 5.3 — bonus deliverable). + * + * Verifies: + * - Required positional `query` arg + * - Flag handling (--limit, --scope, --token-budget, --format) + * - Sends task:create with type 'gather' and properly encoded content + * - Text format renders the bundle with section markers + * - JSON format pipes through the daemon's JSON payload + */ + +import type {ConnectionResult, ITransportClient} from '@campfirein/brv-transport-client' +import type {Config} from '@oclif/core' + +import {Config as OclifConfig} from '@oclif/core' +import {expect} from 'chai' +import sinon, {restore, stub} from 'sinon' + +import Gather from '../../src/oclif/commands/gather.js' + +class TestableGather extends Gather { + private readonly mockConnector: () => Promise + + constructor(argv: string[], mockConnector: () => Promise, config: Config) { + super(argv, config) + this.mockConnector = mockConnector + } + + protected override getDaemonClientOptions() { + return {maxRetries: 1, retryDelayMs: 0, transportConnector: this.mockConnector} + } +} + +describe('Gather Command', () => { + let config: Config + let loggedMessages: string[] + let stdoutOutput: string[] + let mockClient: sinon.SinonStubbedInstance + let mockConnector: sinon.SinonStub<[], Promise> + + before(async () => { + config = await OclifConfig.load(import.meta.url) + }) + + beforeEach(() => { + loggedMessages = [] + stdoutOutput = [] + + mockClient = { + connect: stub().resolves(), + disconnect: stub().resolves(), + getClientId: stub().returns('test-client-id'), + getState: stub().returns('connected'), + isConnected: stub().resolves(true), + joinRoom: stub().resolves(), + leaveRoom: stub().resolves(), + on: stub().callsFake((event: string, handler: (data: unknown) => void) => { + // Auto-fire task:completed on next tick after task:create issued + if (event === 'task:completed') { + setTimeout(() => handler(samplePayload()), 0) + } + + return () => {} + }), + once: stub(), + onStateChange: stub().returns(() => {}), + request: stub() as unknown as ITransportClient['request'], + requestWithAck: stub().resolves(), + } as unknown as sinon.SinonStubbedInstance + + mockConnector = stub<[], Promise>().resolves({ + client: mockClient as unknown as ITransportClient, + projectRoot: '/test/project', + }) + }) + + afterEach(() => { + restore() + }) + + function samplePayload(): {result: string; taskId: string} { + const lastCreateCall = (mockClient.requestWithAck as sinon.SinonStub).getCalls().find( + (c) => c.args[0] === 'task:create', + ) + const taskId = (lastCreateCall?.args[1] as {taskId?: string})?.taskId ?? 'unknown' + const result = JSON.stringify({ + follow_up_hints: ['top score 0.30 indicates low confidence'], + prefetched_context: '### JWT\n**Source**: .brv/context-tree/auth.md\n\nJWT info', + search_metadata: {result_count: 1, top_score: 0.95, total_found: 1}, + total_tokens_estimated: 30, + }) + return {result, taskId} + } + + function createCommand(...argv: string[]): TestableGather { + const command = new TestableGather(argv, mockConnector, config) + stub(command, 'log').callsFake((msg?: string) => { + if (msg !== undefined) loggedMessages.push(msg) + }) + return command + } + + function createJsonCommand(...argv: string[]): TestableGather { + const command = new TestableGather([...argv, '--format', 'json'], mockConnector, config) + stub(command, 'log').callsFake((msg?: string) => { + if (msg !== undefined) loggedMessages.push(msg) + }) + stub(process.stdout, 'write').callsFake((chunk: string | Uint8Array) => { + stdoutOutput.push(String(chunk)) + return true + }) + return command + } + + describe('input validation', () => { + it('shows usage when query arg missing', async () => { + try { + await createCommand().run() + expect.fail('expected oclif to throw on missing required arg') + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + expect(message.toLowerCase()).to.match(/missing|required|query/) + } + }) + }) + + describe('task routing', () => { + it('sends task:create with type "gather" and the query in encoded content', async () => { + await createCommand('how does auth work').run() + + const requestStub = mockClient.requestWithAck as sinon.SinonStub + const createCall = requestStub.getCalls().find((c) => c.args[0] === 'task:create') + expect(createCall, 'task:create not sent').to.exist + const payload = createCall!.args[1] as {content: string; type: string} + expect(payload.type).to.equal('gather') + + const decoded = JSON.parse(payload.content) as {limit?: number; query: string; scope?: string; tokenBudget?: number} + expect(decoded.query).to.equal('how does auth work') + }) + + it('encodes --scope and --limit and --token-budget into the content payload', async () => { + await createCommand('jwt', '--scope', 'src/auth', '--limit', '15', '--token-budget', '6000').run() + + const requestStub = mockClient.requestWithAck as sinon.SinonStub + const createCall = requestStub.getCalls().find((c) => c.args[0] === 'task:create') + const decoded = JSON.parse((createCall!.args[1] as {content: string}).content) as { + limit?: number + query: string + scope?: string + tokenBudget?: number + } + expect(decoded.scope).to.equal('src/auth') + expect(decoded.limit).to.equal(15) + expect(decoded.tokenBudget).to.equal(6000) + }) + }) + + describe('output formats', () => { + it('text format renders the bundle and the follow-up hints', async () => { + await createCommand('auth').run() + + const joined = loggedMessages.join('\n') + expect(joined).to.include('JWT') + expect(joined.toLowerCase()).to.match(/follow|hint|low confidence/) + }) + + it('json format passes the daemon payload through', async () => { + await createJsonCommand('auth').run() + + const out = stdoutOutput.join('').trim() + const parsed = JSON.parse(out) as {command: string; data: {prefetched_context: string}; success: boolean} + expect(parsed.command).to.equal('gather') + expect(parsed.success).to.equal(true) + expect(parsed.data.prefetched_context).to.include('JWT') + }) + }) +}) diff --git a/test/commands/record-answer.test.ts b/test/commands/record-answer.test.ts new file mode 100644 index 000000000..3e2fa3ff7 --- /dev/null +++ b/test/commands/record-answer.test.ts @@ -0,0 +1,166 @@ +/** + * `brv record-answer` CLI command tests (Phase 5 Task 5.4). + * + * Mirrors the brv-record-answer MCP tool surface for skill/hook-driven + * agents. Verifies the CLI: + * - Requires the {query, answer, --fingerprint} triple + * - Sends task:create with type 'record-answer' and encoded content + * - Reports recorded:true / recorded:false in both text and JSON formats + */ + +import type {ConnectionResult, ITransportClient} from '@campfirein/brv-transport-client' +import type {Config} from '@oclif/core' + +import {Config as OclifConfig} from '@oclif/core' +import {expect} from 'chai' +import sinon, {restore, stub} from 'sinon' + +import RecordAnswer from '../../src/oclif/commands/record-answer.js' + +class TestableRecordAnswer extends RecordAnswer { + private readonly mockConnector: () => Promise + + constructor(argv: string[], mockConnector: () => Promise, config: Config) { + super(argv, config) + this.mockConnector = mockConnector + } + + protected override getDaemonClientOptions() { + return {maxRetries: 1, retryDelayMs: 0, transportConnector: this.mockConnector} + } +} + +describe('RecordAnswer Command', () => { + let config: Config + let loggedMessages: string[] + let stdoutOutput: string[] + let mockClient: sinon.SinonStubbedInstance + let mockConnector: sinon.SinonStub<[], Promise> + + before(async () => { + config = await OclifConfig.load(import.meta.url) + }) + + beforeEach(() => { + loggedMessages = [] + stdoutOutput = [] + + mockClient = { + connect: stub().resolves(), + disconnect: stub().resolves(), + getClientId: stub().returns('test-client-id'), + getState: stub().returns('connected'), + isConnected: stub().resolves(true), + joinRoom: stub().resolves(), + leaveRoom: stub().resolves(), + on: stub().callsFake((event: string, handler: (data: unknown) => void) => { + if (event === 'task:completed') { + setTimeout(() => handler(samplePayload()), 0) + } + + return () => {} + }), + once: stub(), + onStateChange: stub().returns(() => {}), + request: stub() as unknown as ITransportClient['request'], + requestWithAck: stub().resolves(), + } as unknown as sinon.SinonStubbedInstance + + mockConnector = stub<[], Promise>().resolves({ + client: mockClient as unknown as ITransportClient, + projectRoot: '/test/project', + }) + }) + + afterEach(() => { + restore() + }) + + function samplePayload(): {result: string; taskId: string} { + const lastCreateCall = (mockClient.requestWithAck as sinon.SinonStub).getCalls().find( + (c) => c.args[0] === 'task:create', + ) + const taskId = (lastCreateCall?.args[1] as {taskId?: string})?.taskId ?? 'unknown' + const result = JSON.stringify({fingerprint: 'fp-1', recorded: true}) + return {result, taskId} + } + + function createCommand(...argv: string[]): TestableRecordAnswer { + const command = new TestableRecordAnswer(argv, mockConnector, config) + stub(command, 'log').callsFake((msg?: string) => { + if (msg !== undefined) loggedMessages.push(msg) + }) + return command + } + + function createJsonCommand(...argv: string[]): TestableRecordAnswer { + const command = new TestableRecordAnswer([...argv, '--format', 'json'], mockConnector, config) + stub(command, 'log').callsFake((msg?: string) => { + if (msg !== undefined) loggedMessages.push(msg) + }) + stub(process.stdout, 'write').callsFake((chunk: string | Uint8Array) => { + stdoutOutput.push(String(chunk)) + return true + }) + return command + } + + describe('input validation', () => { + it('throws on missing positional args', async () => { + try { + await createCommand('--fingerprint', 'fp').run() + expect.fail('expected oclif to throw on missing required args') + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + expect(msg.toLowerCase()).to.match(/missing|required|query|answer/) + } + }) + + it('throws when --fingerprint is omitted', async () => { + try { + await createCommand('q', 'a').run() + expect.fail('expected oclif to throw on missing --fingerprint') + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + expect(msg.toLowerCase()).to.include('fingerprint') + } + }) + }) + + describe('task routing', () => { + it('sends task:create with type "record-answer" and encoded payload', async () => { + await createCommand('how does auth work', 'Auth uses JWTs', '--fingerprint', 'fp-1').run() + + const requestStub = mockClient.requestWithAck as sinon.SinonStub + const createCall = requestStub.getCalls().find((c) => c.args[0] === 'task:create') + expect(createCall, 'task:create not sent').to.exist + const payload = createCall!.args[1] as {content: string; type: string} + expect(payload.type).to.equal('record-answer') + + const decoded = JSON.parse(payload.content) as {answer: string; fingerprint: string; query: string} + expect(decoded.query).to.equal('how does auth work') + expect(decoded.answer).to.equal('Auth uses JWTs') + expect(decoded.fingerprint).to.equal('fp-1') + }) + }) + + describe('output formats', () => { + it('text format reports the recorded status', async () => { + await createCommand('q', 'a', '--fingerprint', 'fp-1').run() + + const joined = loggedMessages.join('\n').toLowerCase() + expect(joined).to.match(/recorded|cached|fingerprint/) + }) + + it('json format passes the daemon payload through', async () => { + await createJsonCommand('q', 'a', '--fingerprint', 'fp-1').run() + + const out = stdoutOutput.join('').trim() + const parsed = JSON.parse(out) as {command: string; data: {fingerprint: string; recorded: boolean}; success: boolean} + expect(parsed.command).to.equal('record-answer') + expect(parsed.success).to.equal(true) + expect(parsed.data.recorded).to.equal(true) + expect(parsed.data.fingerprint).to.equal('fp-1') + }) + }) +}) diff --git a/test/fixtures/curation/baseline-large.json b/test/fixtures/curation/baseline-large.json new file mode 100644 index 000000000..4074f9f5a --- /dev/null +++ b/test/fixtures/curation/baseline-large.json @@ -0,0 +1,398 @@ +{ + "failures": [], + "fixture": { + "bytes": 64471, + "path": "test/fixtures/curation/large.txt" + }, + "outputs": { + "recon": { + "headPreview": "[USER session-1]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-1]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-1]: Are there any rate limits?\n\n[ASSISTANT session-1]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-1]: Where are session tokens stored on the client?\n\n[ASSISTANT session-1]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-1]: Database for user records?\n\n[ASSISTANT session-1]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-2]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-2]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Re", + "history": { + "domains": {}, + "totalProcessed": 0 + }, + "meta": { + "charCount": 64471, + "lineCount": 1772, + "messageCount": 0 + }, + "suggestedChunkCount": 9, + "suggestedMode": "chunked", + "tailPreview": "s\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-25]: Database for user records?\n\n[ASSISTANT session-25]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n" + }, + "chunk": { + "boundaries": [ + { + "end": 7163, + "start": 0 + }, + { + "end": 14064, + "start": 6963 + }, + { + "end": 21328, + "start": 13864 + }, + { + "end": 28706, + "start": 21128 + }, + { + "end": 35883, + "start": 28506 + }, + { + "end": 43351, + "start": 35683 + }, + { + "end": 50184, + "start": 43151 + }, + { + "end": 57470, + "start": 49984 + }, + { + "end": 64286, + "start": 57270 + }, + { + "end": 64471, + "start": 64086 + }, + { + "end": 64471, + "start": 64271 + } + ], + "chunks": [ + "[USER session-1]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-1]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-1]: Are there any rate limits?\n\n[ASSISTANT session-1]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-1]: Where are session tokens stored on the client?\n\n[ASSISTANT session-1]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-1]: Database for user records?\n\n[ASSISTANT session-1]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-2]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-2]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-2]: Are there any rate limits?\n\n[ASSISTANT session-2]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-2]: Where are session tokens stored on the client?\n\n[ASSISTANT session-2]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-2]: Database for user records?\n\n[ASSISTANT session-2]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-3]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-3]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-3]: Are there any rate limits?\n\n[ASSISTANT session-3]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-3]: Where are session tokens stored on the client?\n\n[ASSISTANT session-3]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-3]: Database for user records?\n\n", + "so\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-3]: Database for user records?\n\n[ASSISTANT session-3]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-4]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-4]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-4]: Are there any rate limits?\n\n[ASSISTANT session-4]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-4]: Where are session tokens stored on the client?\n\n[ASSISTANT session-4]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-4]: Database for user records?\n\n[ASSISTANT session-4]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-5]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-5]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-5]: Are there any rate limits?\n\n[ASSISTANT session-5]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-5]: Where are session tokens stored on the client?\n\n[ASSISTANT session-5]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-5]: Database for user records?\n\n[ASSISTANT session-5]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-6]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-6]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-6]: Are there any rate limits?\n\n[ASSISTANT session-6]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\n", + "100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-6]: Where are session tokens stored on the client?\n\n[ASSISTANT session-6]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-6]: Database for user records?\n\n[ASSISTANT session-6]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-7]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-7]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-7]: Are there any rate limits?\n\n[ASSISTANT session-7]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-7]: Where are session tokens stored on the client?\n\n[ASSISTANT session-7]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-7]: Database for user records?\n\n[ASSISTANT session-7]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-8]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-8]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-8]: Are there any rate limits?\n\n[ASSISTANT session-8]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-8]: Where are session tokens stored on the client?\n\n[ASSISTANT session-8]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-8]: Database for user records?\n\n[ASSISTANT session-8]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-9]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-9]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n", + "nauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-9]: Are there any rate limits?\n\n[ASSISTANT session-9]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-9]: Where are session tokens stored on the client?\n\n[ASSISTANT session-9]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-9]: Database for user records?\n\n[ASSISTANT session-9]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-10]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-10]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-10]: Are there any rate limits?\n\n[ASSISTANT session-10]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-10]: Where are session tokens stored on the client?\n\n[ASSISTANT session-10]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-10]: Database for user records?\n\n[ASSISTANT session-10]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-11]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-11]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-11]: Are there any rate limits?\n\n[ASSISTANT session-11]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-11]: Where are session tokens stored on the client?\n\n[ASSISTANT session-11]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-11]: Database for user records?\n\n[ASSISTANT session-11]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-12]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-12]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\n", + "ies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-12]: Are there any rate limits?\n\n[ASSISTANT session-12]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-12]: Where are session tokens stored on the client?\n\n[ASSISTANT session-12]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-12]: Database for user records?\n\n[ASSISTANT session-12]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-13]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-13]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-13]: Are there any rate limits?\n\n[ASSISTANT session-13]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-13]: Where are session tokens stored on the client?\n\n[ASSISTANT session-13]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-13]: Database for user records?\n\n[ASSISTANT session-13]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-14]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-14]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-14]: Are there any rate limits?\n\n[ASSISTANT session-14]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-14]: Where are session tokens stored on the client?\n\n[ASSISTANT session-14]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-14]: Database for user records?\n\n[ASSISTANT session-14]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n", + "VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-15]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-15]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-15]: Are there any rate limits?\n\n[ASSISTANT session-15]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-15]: Where are session tokens stored on the client?\n\n[ASSISTANT session-15]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-15]: Database for user records?\n\n[ASSISTANT session-15]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-16]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-16]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-16]: Are there any rate limits?\n\n[ASSISTANT session-16]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-16]: Where are session tokens stored on the client?\n\n[ASSISTANT session-16]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-16]: Database for user records?\n\n[ASSISTANT session-16]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-17]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-17]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-17]: Are there any rate limits?\n\n[ASSISTANT session-17]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-17]: Where are session tokens stored on the client?\n\n[ASSISTANT session-17]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-17]: Database for user records?\n\n[ASSISTANT session-17]: PostgreSQL 15. The users table has the following schema:\n\n```sql\n", + "ie first, then falls back to parsing the Authorization header.\n\n[USER session-17]: Database for user records?\n\n[ASSISTANT session-17]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-18]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-18]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-18]: Are there any rate limits?\n\n[ASSISTANT session-18]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-18]: Where are session tokens stored on the client?\n\n[ASSISTANT session-18]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-18]: Database for user records?\n\n[ASSISTANT session-18]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-19]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-19]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-19]: Are there any rate limits?\n\n[ASSISTANT session-19]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-19]: Where are session tokens stored on the client?\n\n[ASSISTANT session-19]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-19]: Database for user records?\n\n[ASSISTANT session-19]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-20]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-20]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-20]: Are there any rate limits?\n\n[ASSISTANT session-20]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\n", + "100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-20]: Where are session tokens stored on the client?\n\n[ASSISTANT session-20]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-20]: Database for user records?\n\n[ASSISTANT session-20]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-21]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-21]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-21]: Are there any rate limits?\n\n[ASSISTANT session-21]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-21]: Where are session tokens stored on the client?\n\n[ASSISTANT session-21]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-21]: Database for user records?\n\n[ASSISTANT session-21]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-22]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-22]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-22]: Are there any rate limits?\n\n[ASSISTANT session-22]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-22]: Where are session tokens stored on the client?\n\n[ASSISTANT session-22]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-22]: Database for user records?\n\n[ASSISTANT session-22]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-23]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-23]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n", + "nauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-23]: Are there any rate limits?\n\n[ASSISTANT session-23]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-23]: Where are session tokens stored on the client?\n\n[ASSISTANT session-23]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-23]: Database for user records?\n\n[ASSISTANT session-23]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-24]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-24]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-24]: Are there any rate limits?\n\n[ASSISTANT session-24]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-24]: Where are session tokens stored on the client?\n\n[ASSISTANT session-24]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-24]: Database for user records?\n\n[ASSISTANT session-24]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n\n\n---\n\n[USER session-25]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT session-25]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER session-25]: Are there any rate limits?\n\n[ASSISTANT session-25]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER session-25]: Where are session tokens stored on the client?\n\n[ASSISTANT session-25]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER session-25]: Database for user records?\n\n[ASSISTANT session-25]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\n", + "ARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n", + " (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n" + ], + "totalChunks": 11 + }, + "extract": { + "facts": [ + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + }, + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + }, + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + }, + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + }, + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + }, + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + }, + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + }, + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + }, + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + } + ], + "failed": 0, + "succeeded": 27, + "total": 11 + }, + "group": { + "grouped": { + "auth": [ + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + } + ], + "database": [ + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + } + ], + "rate-limit": [ + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + } + ] + } + }, + "dedup": { + "deduped": [ + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + } + ] + }, + "conflict": { + "decisions": [ + { + "action": "add", + "fact": { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + } + }, + { + "action": "add", + "fact": { + "statement": "Database is PostgreSQL 15", + "subject": "database" + } + }, + { + "action": "add", + "fact": { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + } + } + ] + }, + "write": { + "applied": [ + { + "confidence": "high", + "impact": "low", + "needsReview": false, + "path": "auth/Auth uses JWT in httpOnly cook.md", + "reason": "baseline capture", + "status": "success", + "type": "ADD" + }, + { + "confidence": "high", + "impact": "low", + "needsReview": false, + "path": "database/Database is PostgreSQL 15.md", + "reason": "baseline capture", + "status": "success", + "type": "ADD" + }, + { + "confidence": "high", + "impact": "low", + "needsReview": false, + "path": "rate-limit/Rate limit is 100/min per IP.md", + "reason": "baseline capture", + "status": "success", + "type": "ADD" + } + ], + "summary": { + "added": 3, + "deleted": 0, + "failed": 0, + "merged": 0, + "updated": 0 + } + } + } +} \ No newline at end of file diff --git a/test/fixtures/curation/baseline-small.json b/test/fixtures/curation/baseline-small.json new file mode 100644 index 000000000..b8ce83031 --- /dev/null +++ b/test/fixtures/curation/baseline-small.json @@ -0,0 +1,156 @@ +{ + "failures": [], + "fixture": { + "bytes": 2487, + "path": "test/fixtures/curation/small.txt" + }, + "outputs": { + "recon": { + "headPreview": "[USER]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER]: Are there any rate limits?\n\n[ASSISTANT]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER]: Where are session tokens stored on the client?\n\n[ASSISTANT]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER]: Database for user records?\n\n[ASSISTANT]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n", + "history": { + "domains": {}, + "totalProcessed": 0 + }, + "meta": { + "charCount": 2487, + "lineCount": 68, + "messageCount": 7 + }, + "suggestedChunkCount": 1, + "suggestedMode": "single-pass", + "tailPreview": "Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER]: Database for user records?\n\n[ASSISTANT]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n" + }, + "chunk": { + "boundaries": [ + { + "end": 2487, + "start": 0 + } + ], + "chunks": [ + "[USER]: Document the auth middleware in src/auth/middleware.ts.\n\n[ASSISTANT]: Looking at the file. The auth module uses JWT tokens stored\nin httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate\non every use. The middleware validates the JWT signature against the\nproject's secret key (loaded from env var AUTH_SECRET).\n\n```typescript\nexport function authMiddleware(req: Request, res: Response, next: NextFunction) {\n const token = req.cookies.session\n if (!token) return res.status(401).send('Unauthorized')\n\n try {\n const payload = jwt.verify(token, process.env.AUTH_SECRET!)\n req.user = payload as User\n next()\n } catch {\n return res.status(401).send('Invalid token')\n }\n}\n```\n\nThe handler attaches the decoded payload to `req.user` so downstream\nhandlers can access user identity without re-parsing.\n\n[USER]: Are there any rate limits?\n\n[ASSISTANT]: Yes, the project uses express-rate-limit. The default config\nis 100 requests per minute per IP, with a stricter 10/min window for the\n/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining,\nX-RateLimit-Reset) are included in every response.\n\nThe configuration lives in src/middleware/rate-limit.ts. There's a\ndedicated bypass for internal service tokens — these are identified by\nthe X-Service-Token header containing one of the keys listed in\nSERVICE_TOKEN_ALLOWLIST.\n\n[USER]: Where are session tokens stored on the client?\n\n[ASSISTANT]: httpOnly cookies, with the SameSite=Strict and Secure flags\nset in production. The cookie name is \"session\" and it's set by the login\nhandler at /auth/login on a successful authentication. Logout clears the\ncookie by setting Max-Age=0.\n\nFor mobile clients (where cookies aren't ideal), the auth module also\nsupports Bearer tokens via the Authorization header. The middleware checks\nfor the cookie first, then falls back to parsing the Authorization header.\n\n[USER]: Database for user records?\n\n[ASSISTANT]: PostgreSQL 15. The users table has the following schema:\n\n```sql\nCREATE TABLE users (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n email VARCHAR(255) UNIQUE NOT NULL,\n password_hash VARCHAR(72) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n last_login_at TIMESTAMPTZ\n);\n\nCREATE INDEX idx_users_email ON users (email);\n```\n\nPasswords are hashed with bcrypt (cost factor 12). The application uses\nthe `pg` Node driver with a connection pool of 10 connections by default,\nconfigurable via DB_POOL_SIZE env var.\n" + ], + "totalChunks": 1 + }, + "extract": { + "facts": [ + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + } + ], + "failed": 0, + "succeeded": 3, + "total": 1 + }, + "group": { + "grouped": { + "auth": [ + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + } + ], + "database": [ + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + } + ], + "rate-limit": [ + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + } + ] + } + }, + "dedup": { + "deduped": [ + { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + }, + { + "statement": "Database is PostgreSQL 15", + "subject": "database" + }, + { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + } + ] + }, + "conflict": { + "decisions": [ + { + "action": "add", + "fact": { + "statement": "Auth uses JWT in httpOnly cookies", + "subject": "auth" + } + }, + { + "action": "add", + "fact": { + "statement": "Database is PostgreSQL 15", + "subject": "database" + } + }, + { + "action": "add", + "fact": { + "statement": "Rate limit is 100/min per IP", + "subject": "rate-limit" + } + } + ] + }, + "write": { + "applied": [ + { + "confidence": "high", + "impact": "low", + "needsReview": false, + "path": "auth/Auth uses JWT in httpOnly cook.md", + "reason": "baseline capture", + "status": "success", + "type": "ADD" + }, + { + "confidence": "high", + "impact": "low", + "needsReview": false, + "path": "database/Database is PostgreSQL 15.md", + "reason": "baseline capture", + "status": "success", + "type": "ADD" + }, + { + "confidence": "high", + "impact": "low", + "needsReview": false, + "path": "rate-limit/Rate limit is 100/min per IP.md", + "reason": "baseline capture", + "status": "success", + "type": "ADD" + } + ], + "summary": { + "added": 3, + "deleted": 0, + "failed": 0, + "merged": 0, + "updated": 0 + } + } + } +} \ No newline at end of file diff --git a/test/fixtures/curation/large.txt b/test/fixtures/curation/large.txt new file mode 100644 index 000000000..6521f7070 --- /dev/null +++ b/test/fixtures/curation/large.txt @@ -0,0 +1,1771 @@ +[USER session-1]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-1]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-1]: Are there any rate limits? + +[ASSISTANT session-1]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-1]: Where are session tokens stored on the client? + +[ASSISTANT session-1]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-1]: Database for user records? + +[ASSISTANT session-1]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-2]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-2]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-2]: Are there any rate limits? + +[ASSISTANT session-2]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-2]: Where are session tokens stored on the client? + +[ASSISTANT session-2]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-2]: Database for user records? + +[ASSISTANT session-2]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-3]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-3]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-3]: Are there any rate limits? + +[ASSISTANT session-3]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-3]: Where are session tokens stored on the client? + +[ASSISTANT session-3]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-3]: Database for user records? + +[ASSISTANT session-3]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-4]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-4]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-4]: Are there any rate limits? + +[ASSISTANT session-4]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-4]: Where are session tokens stored on the client? + +[ASSISTANT session-4]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-4]: Database for user records? + +[ASSISTANT session-4]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-5]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-5]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-5]: Are there any rate limits? + +[ASSISTANT session-5]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-5]: Where are session tokens stored on the client? + +[ASSISTANT session-5]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-5]: Database for user records? + +[ASSISTANT session-5]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-6]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-6]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-6]: Are there any rate limits? + +[ASSISTANT session-6]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-6]: Where are session tokens stored on the client? + +[ASSISTANT session-6]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-6]: Database for user records? + +[ASSISTANT session-6]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-7]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-7]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-7]: Are there any rate limits? + +[ASSISTANT session-7]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-7]: Where are session tokens stored on the client? + +[ASSISTANT session-7]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-7]: Database for user records? + +[ASSISTANT session-7]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-8]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-8]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-8]: Are there any rate limits? + +[ASSISTANT session-8]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-8]: Where are session tokens stored on the client? + +[ASSISTANT session-8]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-8]: Database for user records? + +[ASSISTANT session-8]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-9]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-9]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-9]: Are there any rate limits? + +[ASSISTANT session-9]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-9]: Where are session tokens stored on the client? + +[ASSISTANT session-9]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-9]: Database for user records? + +[ASSISTANT session-9]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-10]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-10]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-10]: Are there any rate limits? + +[ASSISTANT session-10]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-10]: Where are session tokens stored on the client? + +[ASSISTANT session-10]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-10]: Database for user records? + +[ASSISTANT session-10]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-11]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-11]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-11]: Are there any rate limits? + +[ASSISTANT session-11]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-11]: Where are session tokens stored on the client? + +[ASSISTANT session-11]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-11]: Database for user records? + +[ASSISTANT session-11]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-12]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-12]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-12]: Are there any rate limits? + +[ASSISTANT session-12]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-12]: Where are session tokens stored on the client? + +[ASSISTANT session-12]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-12]: Database for user records? + +[ASSISTANT session-12]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-13]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-13]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-13]: Are there any rate limits? + +[ASSISTANT session-13]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-13]: Where are session tokens stored on the client? + +[ASSISTANT session-13]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-13]: Database for user records? + +[ASSISTANT session-13]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-14]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-14]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-14]: Are there any rate limits? + +[ASSISTANT session-14]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-14]: Where are session tokens stored on the client? + +[ASSISTANT session-14]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-14]: Database for user records? + +[ASSISTANT session-14]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-15]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-15]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-15]: Are there any rate limits? + +[ASSISTANT session-15]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-15]: Where are session tokens stored on the client? + +[ASSISTANT session-15]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-15]: Database for user records? + +[ASSISTANT session-15]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-16]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-16]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-16]: Are there any rate limits? + +[ASSISTANT session-16]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-16]: Where are session tokens stored on the client? + +[ASSISTANT session-16]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-16]: Database for user records? + +[ASSISTANT session-16]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-17]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-17]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-17]: Are there any rate limits? + +[ASSISTANT session-17]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-17]: Where are session tokens stored on the client? + +[ASSISTANT session-17]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-17]: Database for user records? + +[ASSISTANT session-17]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-18]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-18]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-18]: Are there any rate limits? + +[ASSISTANT session-18]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-18]: Where are session tokens stored on the client? + +[ASSISTANT session-18]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-18]: Database for user records? + +[ASSISTANT session-18]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-19]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-19]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-19]: Are there any rate limits? + +[ASSISTANT session-19]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-19]: Where are session tokens stored on the client? + +[ASSISTANT session-19]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-19]: Database for user records? + +[ASSISTANT session-19]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-20]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-20]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-20]: Are there any rate limits? + +[ASSISTANT session-20]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-20]: Where are session tokens stored on the client? + +[ASSISTANT session-20]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-20]: Database for user records? + +[ASSISTANT session-20]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-21]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-21]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-21]: Are there any rate limits? + +[ASSISTANT session-21]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-21]: Where are session tokens stored on the client? + +[ASSISTANT session-21]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-21]: Database for user records? + +[ASSISTANT session-21]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-22]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-22]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-22]: Are there any rate limits? + +[ASSISTANT session-22]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-22]: Where are session tokens stored on the client? + +[ASSISTANT session-22]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-22]: Database for user records? + +[ASSISTANT session-22]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-23]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-23]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-23]: Are there any rate limits? + +[ASSISTANT session-23]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-23]: Where are session tokens stored on the client? + +[ASSISTANT session-23]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-23]: Database for user records? + +[ASSISTANT session-23]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-24]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-24]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-24]: Are there any rate limits? + +[ASSISTANT session-24]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-24]: Where are session tokens stored on the client? + +[ASSISTANT session-24]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-24]: Database for user records? + +[ASSISTANT session-24]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-25]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-25]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-25]: Are there any rate limits? + +[ASSISTANT session-25]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-25]: Where are session tokens stored on the client? + +[ASSISTANT session-25]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-25]: Database for user records? + +[ASSISTANT session-25]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. diff --git a/test/fixtures/curation/small.txt b/test/fixtures/curation/small.txt new file mode 100644 index 000000000..2cb1d0449 --- /dev/null +++ b/test/fixtures/curation/small.txt @@ -0,0 +1,67 @@ +[USER]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER]: Are there any rate limits? + +[ASSISTANT]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER]: Where are session tokens stored on the client? + +[ASSISTANT]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER]: Database for user records? + +[ASSISTANT]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. diff --git a/test/fixtures/curation/xlarge.txt b/test/fixtures/curation/xlarge.txt new file mode 100644 index 000000000..eaa9a1c54 --- /dev/null +++ b/test/fixtures/curation/xlarge.txt @@ -0,0 +1,4256 @@ +[USER session-1]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-1]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-1]: Are there any rate limits? + +[ASSISTANT session-1]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-1]: Where are session tokens stored on the client? + +[ASSISTANT session-1]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-1]: Database for user records? + +[ASSISTANT session-1]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-2]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-2]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-2]: Are there any rate limits? + +[ASSISTANT session-2]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-2]: Where are session tokens stored on the client? + +[ASSISTANT session-2]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-2]: Database for user records? + +[ASSISTANT session-2]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-3]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-3]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-3]: Are there any rate limits? + +[ASSISTANT session-3]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-3]: Where are session tokens stored on the client? + +[ASSISTANT session-3]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-3]: Database for user records? + +[ASSISTANT session-3]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-4]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-4]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-4]: Are there any rate limits? + +[ASSISTANT session-4]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-4]: Where are session tokens stored on the client? + +[ASSISTANT session-4]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-4]: Database for user records? + +[ASSISTANT session-4]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-5]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-5]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-5]: Are there any rate limits? + +[ASSISTANT session-5]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-5]: Where are session tokens stored on the client? + +[ASSISTANT session-5]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-5]: Database for user records? + +[ASSISTANT session-5]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-6]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-6]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-6]: Are there any rate limits? + +[ASSISTANT session-6]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-6]: Where are session tokens stored on the client? + +[ASSISTANT session-6]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-6]: Database for user records? + +[ASSISTANT session-6]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-7]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-7]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-7]: Are there any rate limits? + +[ASSISTANT session-7]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-7]: Where are session tokens stored on the client? + +[ASSISTANT session-7]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-7]: Database for user records? + +[ASSISTANT session-7]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-8]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-8]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-8]: Are there any rate limits? + +[ASSISTANT session-8]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-8]: Where are session tokens stored on the client? + +[ASSISTANT session-8]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-8]: Database for user records? + +[ASSISTANT session-8]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-9]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-9]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-9]: Are there any rate limits? + +[ASSISTANT session-9]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-9]: Where are session tokens stored on the client? + +[ASSISTANT session-9]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-9]: Database for user records? + +[ASSISTANT session-9]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-10]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-10]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-10]: Are there any rate limits? + +[ASSISTANT session-10]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-10]: Where are session tokens stored on the client? + +[ASSISTANT session-10]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-10]: Database for user records? + +[ASSISTANT session-10]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-11]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-11]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-11]: Are there any rate limits? + +[ASSISTANT session-11]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-11]: Where are session tokens stored on the client? + +[ASSISTANT session-11]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-11]: Database for user records? + +[ASSISTANT session-11]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-12]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-12]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-12]: Are there any rate limits? + +[ASSISTANT session-12]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-12]: Where are session tokens stored on the client? + +[ASSISTANT session-12]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-12]: Database for user records? + +[ASSISTANT session-12]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-13]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-13]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-13]: Are there any rate limits? + +[ASSISTANT session-13]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-13]: Where are session tokens stored on the client? + +[ASSISTANT session-13]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-13]: Database for user records? + +[ASSISTANT session-13]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-14]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-14]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-14]: Are there any rate limits? + +[ASSISTANT session-14]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-14]: Where are session tokens stored on the client? + +[ASSISTANT session-14]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-14]: Database for user records? + +[ASSISTANT session-14]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-15]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-15]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-15]: Are there any rate limits? + +[ASSISTANT session-15]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-15]: Where are session tokens stored on the client? + +[ASSISTANT session-15]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-15]: Database for user records? + +[ASSISTANT session-15]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-16]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-16]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-16]: Are there any rate limits? + +[ASSISTANT session-16]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-16]: Where are session tokens stored on the client? + +[ASSISTANT session-16]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-16]: Database for user records? + +[ASSISTANT session-16]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-17]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-17]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-17]: Are there any rate limits? + +[ASSISTANT session-17]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-17]: Where are session tokens stored on the client? + +[ASSISTANT session-17]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-17]: Database for user records? + +[ASSISTANT session-17]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-18]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-18]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-18]: Are there any rate limits? + +[ASSISTANT session-18]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-18]: Where are session tokens stored on the client? + +[ASSISTANT session-18]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-18]: Database for user records? + +[ASSISTANT session-18]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-19]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-19]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-19]: Are there any rate limits? + +[ASSISTANT session-19]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-19]: Where are session tokens stored on the client? + +[ASSISTANT session-19]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-19]: Database for user records? + +[ASSISTANT session-19]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-20]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-20]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-20]: Are there any rate limits? + +[ASSISTANT session-20]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-20]: Where are session tokens stored on the client? + +[ASSISTANT session-20]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-20]: Database for user records? + +[ASSISTANT session-20]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-21]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-21]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-21]: Are there any rate limits? + +[ASSISTANT session-21]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-21]: Where are session tokens stored on the client? + +[ASSISTANT session-21]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-21]: Database for user records? + +[ASSISTANT session-21]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-22]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-22]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-22]: Are there any rate limits? + +[ASSISTANT session-22]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-22]: Where are session tokens stored on the client? + +[ASSISTANT session-22]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-22]: Database for user records? + +[ASSISTANT session-22]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-23]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-23]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-23]: Are there any rate limits? + +[ASSISTANT session-23]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-23]: Where are session tokens stored on the client? + +[ASSISTANT session-23]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-23]: Database for user records? + +[ASSISTANT session-23]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-24]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-24]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-24]: Are there any rate limits? + +[ASSISTANT session-24]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-24]: Where are session tokens stored on the client? + +[ASSISTANT session-24]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-24]: Database for user records? + +[ASSISTANT session-24]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-25]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-25]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-25]: Are there any rate limits? + +[ASSISTANT session-25]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-25]: Where are session tokens stored on the client? + +[ASSISTANT session-25]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-25]: Database for user records? + +[ASSISTANT session-25]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-26]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-26]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-26]: Are there any rate limits? + +[ASSISTANT session-26]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-26]: Where are session tokens stored on the client? + +[ASSISTANT session-26]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-26]: Database for user records? + +[ASSISTANT session-26]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-27]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-27]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-27]: Are there any rate limits? + +[ASSISTANT session-27]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-27]: Where are session tokens stored on the client? + +[ASSISTANT session-27]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-27]: Database for user records? + +[ASSISTANT session-27]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-28]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-28]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-28]: Are there any rate limits? + +[ASSISTANT session-28]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-28]: Where are session tokens stored on the client? + +[ASSISTANT session-28]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-28]: Database for user records? + +[ASSISTANT session-28]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-29]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-29]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-29]: Are there any rate limits? + +[ASSISTANT session-29]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-29]: Where are session tokens stored on the client? + +[ASSISTANT session-29]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-29]: Database for user records? + +[ASSISTANT session-29]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-30]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-30]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-30]: Are there any rate limits? + +[ASSISTANT session-30]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-30]: Where are session tokens stored on the client? + +[ASSISTANT session-30]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-30]: Database for user records? + +[ASSISTANT session-30]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-31]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-31]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-31]: Are there any rate limits? + +[ASSISTANT session-31]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-31]: Where are session tokens stored on the client? + +[ASSISTANT session-31]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-31]: Database for user records? + +[ASSISTANT session-31]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-32]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-32]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-32]: Are there any rate limits? + +[ASSISTANT session-32]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-32]: Where are session tokens stored on the client? + +[ASSISTANT session-32]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-32]: Database for user records? + +[ASSISTANT session-32]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-33]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-33]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-33]: Are there any rate limits? + +[ASSISTANT session-33]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-33]: Where are session tokens stored on the client? + +[ASSISTANT session-33]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-33]: Database for user records? + +[ASSISTANT session-33]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-34]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-34]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-34]: Are there any rate limits? + +[ASSISTANT session-34]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-34]: Where are session tokens stored on the client? + +[ASSISTANT session-34]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-34]: Database for user records? + +[ASSISTANT session-34]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-35]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-35]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-35]: Are there any rate limits? + +[ASSISTANT session-35]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-35]: Where are session tokens stored on the client? + +[ASSISTANT session-35]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-35]: Database for user records? + +[ASSISTANT session-35]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-36]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-36]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-36]: Are there any rate limits? + +[ASSISTANT session-36]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-36]: Where are session tokens stored on the client? + +[ASSISTANT session-36]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-36]: Database for user records? + +[ASSISTANT session-36]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-37]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-37]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-37]: Are there any rate limits? + +[ASSISTANT session-37]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-37]: Where are session tokens stored on the client? + +[ASSISTANT session-37]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-37]: Database for user records? + +[ASSISTANT session-37]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-38]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-38]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-38]: Are there any rate limits? + +[ASSISTANT session-38]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-38]: Where are session tokens stored on the client? + +[ASSISTANT session-38]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-38]: Database for user records? + +[ASSISTANT session-38]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-39]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-39]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-39]: Are there any rate limits? + +[ASSISTANT session-39]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-39]: Where are session tokens stored on the client? + +[ASSISTANT session-39]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-39]: Database for user records? + +[ASSISTANT session-39]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-40]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-40]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-40]: Are there any rate limits? + +[ASSISTANT session-40]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-40]: Where are session tokens stored on the client? + +[ASSISTANT session-40]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-40]: Database for user records? + +[ASSISTANT session-40]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-41]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-41]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-41]: Are there any rate limits? + +[ASSISTANT session-41]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-41]: Where are session tokens stored on the client? + +[ASSISTANT session-41]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-41]: Database for user records? + +[ASSISTANT session-41]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-42]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-42]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-42]: Are there any rate limits? + +[ASSISTANT session-42]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-42]: Where are session tokens stored on the client? + +[ASSISTANT session-42]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-42]: Database for user records? + +[ASSISTANT session-42]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-43]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-43]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-43]: Are there any rate limits? + +[ASSISTANT session-43]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-43]: Where are session tokens stored on the client? + +[ASSISTANT session-43]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-43]: Database for user records? + +[ASSISTANT session-43]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-44]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-44]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-44]: Are there any rate limits? + +[ASSISTANT session-44]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-44]: Where are session tokens stored on the client? + +[ASSISTANT session-44]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-44]: Database for user records? + +[ASSISTANT session-44]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-45]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-45]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-45]: Are there any rate limits? + +[ASSISTANT session-45]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-45]: Where are session tokens stored on the client? + +[ASSISTANT session-45]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-45]: Database for user records? + +[ASSISTANT session-45]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-46]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-46]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-46]: Are there any rate limits? + +[ASSISTANT session-46]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-46]: Where are session tokens stored on the client? + +[ASSISTANT session-46]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-46]: Database for user records? + +[ASSISTANT session-46]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-47]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-47]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-47]: Are there any rate limits? + +[ASSISTANT session-47]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-47]: Where are session tokens stored on the client? + +[ASSISTANT session-47]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-47]: Database for user records? + +[ASSISTANT session-47]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-48]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-48]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-48]: Are there any rate limits? + +[ASSISTANT session-48]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-48]: Where are session tokens stored on the client? + +[ASSISTANT session-48]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-48]: Database for user records? + +[ASSISTANT session-48]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-49]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-49]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-49]: Are there any rate limits? + +[ASSISTANT session-49]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-49]: Where are session tokens stored on the client? + +[ASSISTANT session-49]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-49]: Database for user records? + +[ASSISTANT session-49]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-50]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-50]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-50]: Are there any rate limits? + +[ASSISTANT session-50]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-50]: Where are session tokens stored on the client? + +[ASSISTANT session-50]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-50]: Database for user records? + +[ASSISTANT session-50]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-51]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-51]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-51]: Are there any rate limits? + +[ASSISTANT session-51]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-51]: Where are session tokens stored on the client? + +[ASSISTANT session-51]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-51]: Database for user records? + +[ASSISTANT session-51]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-52]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-52]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-52]: Are there any rate limits? + +[ASSISTANT session-52]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-52]: Where are session tokens stored on the client? + +[ASSISTANT session-52]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-52]: Database for user records? + +[ASSISTANT session-52]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-53]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-53]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-53]: Are there any rate limits? + +[ASSISTANT session-53]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-53]: Where are session tokens stored on the client? + +[ASSISTANT session-53]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-53]: Database for user records? + +[ASSISTANT session-53]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-54]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-54]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-54]: Are there any rate limits? + +[ASSISTANT session-54]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-54]: Where are session tokens stored on the client? + +[ASSISTANT session-54]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-54]: Database for user records? + +[ASSISTANT session-54]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-55]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-55]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-55]: Are there any rate limits? + +[ASSISTANT session-55]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-55]: Where are session tokens stored on the client? + +[ASSISTANT session-55]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-55]: Database for user records? + +[ASSISTANT session-55]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-56]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-56]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-56]: Are there any rate limits? + +[ASSISTANT session-56]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-56]: Where are session tokens stored on the client? + +[ASSISTANT session-56]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-56]: Database for user records? + +[ASSISTANT session-56]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-57]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-57]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-57]: Are there any rate limits? + +[ASSISTANT session-57]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-57]: Where are session tokens stored on the client? + +[ASSISTANT session-57]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-57]: Database for user records? + +[ASSISTANT session-57]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-58]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-58]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-58]: Are there any rate limits? + +[ASSISTANT session-58]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-58]: Where are session tokens stored on the client? + +[ASSISTANT session-58]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-58]: Database for user records? + +[ASSISTANT session-58]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-59]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-59]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-59]: Are there any rate limits? + +[ASSISTANT session-59]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-59]: Where are session tokens stored on the client? + +[ASSISTANT session-59]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-59]: Database for user records? + +[ASSISTANT session-59]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. + + +--- + +[USER session-60]: Document the auth middleware in src/auth/middleware.ts. + +[ASSISTANT session-60]: Looking at the file. The auth module uses JWT tokens stored +in httpOnly cookies. Tokens expire after 24 hours and refresh tokens rotate +on every use. The middleware validates the JWT signature against the +project's secret key (loaded from env var AUTH_SECRET). + +```typescript +export function authMiddleware(req: Request, res: Response, next: NextFunction) { + const token = req.cookies.session + if (!token) return res.status(401).send('Unauthorized') + + try { + const payload = jwt.verify(token, process.env.AUTH_SECRET!) + req.user = payload as User + next() + } catch { + return res.status(401).send('Invalid token') + } +} +``` + +The handler attaches the decoded payload to `req.user` so downstream +handlers can access user identity without re-parsing. + +[USER session-60]: Are there any rate limits? + +[ASSISTANT session-60]: Yes, the project uses express-rate-limit. The default config +is 100 requests per minute per IP, with a stricter 10/min window for the +/auth/login endpoint specifically. Rate limit headers (X-RateLimit-Remaining, +X-RateLimit-Reset) are included in every response. + +The configuration lives in src/middleware/rate-limit.ts. There's a +dedicated bypass for internal service tokens — these are identified by +the X-Service-Token header containing one of the keys listed in +SERVICE_TOKEN_ALLOWLIST. + +[USER session-60]: Where are session tokens stored on the client? + +[ASSISTANT session-60]: httpOnly cookies, with the SameSite=Strict and Secure flags +set in production. The cookie name is "session" and it's set by the login +handler at /auth/login on a successful authentication. Logout clears the +cookie by setting Max-Age=0. + +For mobile clients (where cookies aren't ideal), the auth module also +supports Bearer tokens via the Authorization header. The middleware checks +for the cookie first, then falls back to parsing the Authorization header. + +[USER session-60]: Database for user records? + +[ASSISTANT session-60]: PostgreSQL 15. The users table has the following schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(72) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE INDEX idx_users_email ON users (email); +``` + +Passwords are hashed with bcrypt (cost factor 12). The application uses +the `pg` Node driver with a connection pool of 10 connections by default, +configurable via DB_POOL_SIZE env var. diff --git a/test/helpers/delay.ts b/test/helpers/delay.ts new file mode 100644 index 000000000..651644d63 --- /dev/null +++ b/test/helpers/delay.ts @@ -0,0 +1,15 @@ +/** + * Shared `delay` helper for tests that need to simulate async work or + * wait on cooperative AbortSignal cancellation. Extracted in Phase 2 to + * avoid duplication across curate-flow tests (PHASE-2-CODE-REVIEW I1). + */ + +export function delay(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout(resolve, ms) + signal?.addEventListener('abort', () => { + clearTimeout(t) + reject(new Error('aborted')) + }) + }) +} diff --git a/test/integration/curate/dag-end-to-end.test.ts b/test/integration/curate/dag-end-to-end.test.ts new file mode 100644 index 000000000..e48de3492 --- /dev/null +++ b/test/integration/curate/dag-end-to-end.test.ts @@ -0,0 +1,100 @@ +/** + * Phase 1 sanity check — runs the default curate DAG end-to-end via the + * TopologicalCurationRunner with stub services. Proves the 7 default + * nodes wire together correctly under a real Kahn's execution. + */ + +import {expect} from 'chai' +import {stub} from 'sinon' + +import type {NodeContext, NodeServices} from '../../../src/agent/core/curation/flow/runner.js' + +import {TopologicalCurationRunner} from '../../../src/agent/core/curation/flow/runner.js' +import {buildCurationDAG} from '../../../src/agent/infra/curation/flow/dag-builder.js' + +describe('curate DAG — end-to-end smoke test', () => { + it('runs the full 7-slot default DAG and produces a write outcome', async () => { + const services: NodeServices = { + detectConflicts: stub().resolves({ + decisions: [ + {action: 'add' as const, fact: {statement: 'JWT expires in 24h', subject: 'auth'}}, + ], + }), + extract: stub().resolves({ + facts: [{statement: 'JWT expires in 24h', subject: 'auth'}], + failed: 0, + succeeded: 1, + total: 1, + }), + write: stub().resolves({ + applied: [ + { + confidence: 'high' as const, + impact: 'low' as const, + needsReview: false, + path: 'auth/jwt.md', + reason: 'Documenting JWT', + status: 'success' as const, + type: 'ADD' as const, + }, + ], + summary: {added: 1, deleted: 0, failed: 0, merged: 0, updated: 0}, + }), + } + + const ctx: NodeContext = { + initialInput: { + context: 'JWT tokens expire in 24h. Stored in httpOnly cookies.', + history: {}, + meta: {}, + }, + services, + taskId: 'e2e-task', + } + + const dag = buildCurationDAG() + const runner = new TopologicalCurationRunner() + const result = await runner.run(dag, ctx) + + expect(result.failures, JSON.stringify(result.failures)).to.be.empty + + // Every slot produced an output + for (const slot of ['recon', 'chunk', 'extract', 'group', 'dedup', 'conflict', 'write']) { + expect(result.outputs.has(slot), `output for ${slot} present`).to.be.true + } + + // Final write summary visible + const writeOut = result.outputs.get('write') as { + applied: unknown[] + summary: {added: number; deleted: number; failed: number; merged: number; updated: number} + } + expect(writeOut.summary.added).to.equal(1) + expect(writeOut.summary.failed).to.equal(0) + }) + + it('runs end-to-end even when input has no facts (empty short-circuit)', async () => { + const services: NodeServices = { + // Extract returns no facts → group/dedup/conflict/write all short-circuit + detectConflicts: stub().resolves({decisions: []}), + extract: stub().resolves({facts: [], failed: 0, succeeded: 0, total: 0}), + write: stub().resolves({ + applied: [], + summary: {added: 0, deleted: 0, failed: 0, merged: 0, updated: 0}, + }), + } + + const ctx: NodeContext = { + initialInput: {context: '', history: {}, meta: {}}, + services, + taskId: 'e2e-empty', + } + + const dag = buildCurationDAG() + const runner = new TopologicalCurationRunner() + const result = await runner.run(dag, ctx) + + expect(result.failures, JSON.stringify(result.failures)).to.be.empty + const writeOut = result.outputs.get('write') as {summary: {added: number}} + expect(writeOut.summary.added).to.equal(0) + }) +}) diff --git a/test/integration/curate/pre-compaction-hoist.test.ts b/test/integration/curate/pre-compaction-hoist.test.ts new file mode 100644 index 000000000..da9321ee5 --- /dev/null +++ b/test/integration/curate/pre-compaction-hoist.test.ts @@ -0,0 +1,154 @@ +/** + * Phase 1 Task 1.3 — async pre-compaction hoist. + * + * Asserts that pre-compaction and task-session creation now run concurrently + * via Promise.all, instead of the previous serial sequence (compact → then + * createTaskSession). + */ + +import {expect} from 'chai' +import {restore, stub} from 'sinon' + +import type {ICipherAgent} from '../../../src/agent/core/interfaces/i-cipher-agent.js' + +import {CurateExecutor} from '../../../src/server/infra/executor/curate-executor.js' +import {PreCompactionService} from '../../../src/server/infra/executor/pre-compaction/pre-compaction-service.js' + +const STUB_LATENCY_MS = 200 +const HOIST_BUDGET_MS = 350 +const SERIAL_FLOOR_MS = 400 + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +/** + * Build a stub ICipherAgent that immediately fulfills the calls + * the executor makes after the hoist (variable injection, executeOnSession). + * createTaskSession is intentionally slow so we can measure parallelism. + */ +function buildStubAgent(taskSessionLatencyMs: number): {agent: ICipherAgent; createSessionResolvedAt: () => number; createSessionStartedAt: () => number} { + let createSessionStartedAt = -1 + let createSessionResolvedAt = -1 + + const agent = { + cancel: stub().resolves(false), + createTaskSession: stub().callsFake(async () => { + createSessionStartedAt = Date.now() + await delay(taskSessionLatencyMs) + createSessionResolvedAt = Date.now() + return 'task-session-id' + }), + deleteSandboxVariable: stub(), + deleteSandboxVariableOnSession: stub(), + deleteSession: stub().resolves(true), + deleteTaskSession: stub().resolves(), + execute: stub().resolves(''), + executeOnSession: stub().resolves( + '```json\n{"summary":{"added":0,"updated":0,"merged":0,"deleted":0,"failed":0}}\n```', + ), + generate: stub().resolves({content: '', toolCalls: [], usage: {inputTokens: 0, outputTokens: 0}}), + getSessionMetadata: stub().resolves(), + getState: stub().returns({ + currentIteration: 0, + executionHistory: [], + executionState: 'idle', + toolCallsExecuted: 0, + }), + listPersistedSessions: stub().resolves([]), + reset: stub(), + setSandboxVariable: stub(), + setSandboxVariableOnSession: stub(), + start: stub().resolves(), + stream: stub().resolves({ + [Symbol.asyncIterator]: () => ({ + next: () => Promise.resolve({done: true, value: undefined}), + }), + }), + } as unknown as ICipherAgent + + return { + agent, + createSessionResolvedAt: () => createSessionResolvedAt, + createSessionStartedAt: () => createSessionStartedAt, + } +} + +describe('CurateExecutor — async pre-compaction hoist', () => { + let compactStartedAt = -1 + let compactResolvedAt = -1 + + beforeEach(() => { + compactStartedAt = -1 + compactResolvedAt = -1 + + // Force compaction path to run by returning a "not compacted" PreCompactionResult + // after a deliberate latency (so we can observe the hoist overlap). + stub(PreCompactionService.prototype, 'compact').callsFake(async (_agent, context) => { + compactStartedAt = Date.now() + await delay(STUB_LATENCY_MS) + compactResolvedAt = Date.now() + return { + context, + originalCharCount: context.length, + preCompacted: false, + } + }) + }) + + afterEach(() => { + restore() + }) + + it('runs preCompactionService.compact and agent.createTaskSession concurrently', async () => { + const {agent, createSessionResolvedAt, createSessionStartedAt} = buildStubAgent(STUB_LATENCY_MS) + const executor = new CurateExecutor() + + const start = Date.now() + await executor.executeWithAgent(agent, { + content: 'small content', + taskId: 'test-task-hoist-1', + }) + const elapsed = Date.now() - start + + // Both operations should have started before either finished — proves overlap. + expect(createSessionStartedAt(), 'createTaskSession started').to.be.greaterThan(0) + expect(compactStartedAt, 'compact started').to.be.greaterThan(0) + + const earlierStart = Math.min(compactStartedAt, createSessionStartedAt()) + const laterStart = Math.max(compactStartedAt, createSessionStartedAt()) + const earlierEnd = Math.min(compactResolvedAt, createSessionResolvedAt()) + + // The later-starting one started BEFORE the earlier-starting one resolved → overlap. + expect(laterStart, 'second op starts before first resolves (overlap)').to.be.lessThan(earlierEnd) + + // Total wall-clock proves it's not the old serial path. + // Allow generous headroom for CI timing variance. + expect(elapsed, `total wall-clock under hoist budget (${HOIST_BUDGET_MS}ms)`).to.be.lessThan(HOIST_BUDGET_MS) + expect(elapsed, `total wall-clock under serial floor (${SERIAL_FLOOR_MS}ms)`).to.be.lessThan(SERIAL_FLOOR_MS) + + // Sanity: ensure earlierStart was used (TS would otherwise mark unused). + expect(earlierStart).to.be.greaterThan(0) + }) + + it('preserves task-session lifecycle (created, used, cleaned up)', async () => { + const {agent} = buildStubAgent(STUB_LATENCY_MS) + const executor = new CurateExecutor() + + await executor.executeWithAgent(agent, { + content: 'test content for downstream', + taskId: 'test-task-hoist-2', + }) + + // Post-cutover: the DAG runner doesn't inject sandbox variables (those + // were for the old agent loop's code_exec). Instead we assert the + // session lifecycle: createTaskSession resolved a session ID, and + // deleteTaskSession was called with that same ID at the end. + const createStub = agent.createTaskSession as ReturnType + const deleteStub = agent.deleteTaskSession as ReturnType + expect(createStub.calledOnce, 'task session created exactly once').to.be.true + expect(deleteStub.calledOnceWithExactly('task-session-id'), 'task session deleted with the same id').to.be.true + }) +}) diff --git a/test/integration/curate/services-adapter-live-write.test.ts b/test/integration/curate/services-adapter-live-write.test.ts new file mode 100644 index 000000000..c5358f04e --- /dev/null +++ b/test/integration/curate/services-adapter-live-write.test.ts @@ -0,0 +1,768 @@ +/** + * Phase 1 — services-adapter live write path integration test. + * + * Exercises `buildLiveServices(...).write(...)` against the REAL `executeCurate` + * implementation (not a stub) writing to a tempdir. This is the production + * path that the cutover put in place; the DAG snapshot tests stub services + * and would not catch executeCurate-side regressions like the + * "Invalid path format" bug uncovered in code review. + * + * If you change the operation shape produced by `services-adapter.write`, + * this test is what protects you from silently producing failed writes. + */ + +import {expect} from 'chai' +import {mkdtemp, readdir, readFile, rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import type {ICipherAgent} from '../../../src/agent/core/interfaces/i-cipher-agent.js' + +import {buildLiveServices} from '../../../src/agent/infra/curation/flow/services-adapter.js' +import {executeCurate} from '../../../src/agent/infra/tools/implementations/curate-tool.js' + +function stubAgent(): ICipherAgent { + // write() does not call agent.generate; an empty stub is fine here. + return {} as ICipherAgent +} + +describe('services-adapter — live write to tempdir via real executeCurate', () => { + let tempDir: string + let basePath: string + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'brv-curate-flow-')) + basePath = join(tempDir, '.brv', 'context-tree') + }) + + afterEach(async () => { + // eslint-disable-next-line unicorn/no-useless-undefined -- explicit `undefined` keeps awaited type narrow + await rm(tempDir, {force: true, recursive: true}).catch(() => undefined) + }) + + it('produces a 2+ segment path so executeCurate accepts ADD operations', async () => { + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + }) + + const result = await services.write!([ + { + action: 'add', + fact: {category: 'project', statement: 'JWT tokens expire after 24 hours', subject: 'auth'}, + }, + ]) + + // The big regression catcher: every applied op must report success, + // not "Invalid path format". + expect(result.summary.failed, JSON.stringify(result.applied)).to.equal(0) + expect(result.summary.added).to.equal(1) + expect(result.applied).to.have.length(1) + expect(result.applied[0].status).to.equal('success') + expect(result.applied[0].path).to.match(/\//) // multi-segment + }) + + it('falls back to "extracted" domain when fact.category is missing', async () => { + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + }) + + const result = await services.write!([ + { + action: 'add', + // no category → domain defaults to 'extracted', topic = subject + fact: {statement: 'Database is PostgreSQL 15', subject: 'database'}, + }, + ]) + + expect(result.summary.failed, JSON.stringify(result.applied)).to.equal(0) + expect(result.summary.added).to.equal(1) + + // Verify the file actually landed under the expected folder. + const extractedDir = await readdir(join(basePath, 'extracted')) + expect(extractedDir).to.include('database') + }) + + it('writes a non-empty markdown file the curate-tool can later read', async () => { + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + }) + + await services.write!([ + { + action: 'add', + fact: {category: 'project', statement: 'Auth uses JWT in httpOnly cookies', subject: 'auth'}, + }, + ]) + + const projectDir = await readdir(join(basePath, 'project', 'auth')) + const mdFile = projectDir.find((f) => f.endsWith('.md')) + expect(mdFile, 'expected at least one .md file under project/auth').to.exist + + if (mdFile) { + const content = await readFile(join(basePath, 'project', 'auth', mdFile), 'utf8') + expect(content.length).to.be.greaterThan(0) + expect(content).to.include('Auth uses JWT in httpOnly cookies') + } + }) + + it('UPDATE honors existingId — points at matched existing file, not a new title', async () => { + // Regression for the "UPDATE writes ignore the matched existing file" bug: + // detectConflicts sets `existingId` to the path of the matched file; + // write() must use that path verbatim rather than re-deriving the title + // from the new statement (which would point at a different file). + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + }) + + // Step 1: ADD an initial fact, then discover the actual file path the + // curate-tool wrote (snake-cased title + .md suffix under the topic + // folder). That relative path is what the existing-memory loader would + // later return as `existingId` for the same subject in production. + const addResult = await services.write!([ + { + action: 'add', + fact: {category: 'project', statement: 'Auth uses JWT', subject: 'auth'}, + }, + ]) + expect(addResult.summary.failed, JSON.stringify(addResult.applied)).to.equal(0) + + const topicDir = await readdir(join(basePath, 'project', 'auth')) + const addedFile = topicDir.find((f) => f.endsWith('.md') && f !== 'context.md') + expect(addedFile, 'expected the ADD to leave a non-context .md file').to.exist + const existingId = `project/auth/${addedFile!}` // mirrors loader output shape + + // Step 2: UPDATE the same fact (different statement) by passing the + // captured path as `existingId`. Pre-fix this returned + // "File does not exist: project/auth/auth_uses_jwt_in_httponly_cookies.md". + const updateResult = await services.write!([ + { + action: 'update', + existingId, + fact: { + category: 'project', + statement: 'Auth uses JWT in httpOnly cookies (24h expiry)', + subject: 'auth', + }, + reason: 'Refining JWT description with cookie + expiry detail', + }, + ]) + + expect(updateResult.summary.failed, JSON.stringify(updateResult.applied)).to.equal(0) + expect(updateResult.summary.updated).to.equal(1) + expect(updateResult.applied).to.have.length(1) + expect(updateResult.applied[0].status).to.equal('success') + + // Verify the same on-disk file was modified (no new file created). + const topicDirAfter = await readdir(join(basePath, 'project', 'auth')) + const mdFilesAfter = topicDirAfter.filter((f) => f.endsWith('.md') && f !== 'context.md') + expect(mdFilesAfter, 'no new .md file should be created on UPDATE').to.have.length(1) + expect(mdFilesAfter[0]).to.equal(addedFile) + }) + + it('UPDATE falls back to ADD when existingId is missing or unparseable', async () => { + // Defensive behavior: an UPDATE without a valid existingId would fail + // with "File does not exist" if we issued an UPDATE op blindly. The + // adapter falls back to ADD in this case so the new content still lands. + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + }) + + const result = await services.write!([ + { + action: 'update', // marked update but no existingId + fact: {category: 'project', statement: 'Some new fact', subject: 'newsubject'}, + }, + { + action: 'update', + existingId: 'malformed', // unparseable (single segment) + fact: {category: 'project', statement: 'Another new fact', subject: 'othersubject'}, + }, + ]) + + expect(result.summary.failed, JSON.stringify(result.applied)).to.equal(0) + expect(result.summary.added).to.equal(2) + expect(result.summary.updated).to.equal(0) + }) + + it('handles multiple decisions in a single write call', async () => { + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + }) + + const result = await services.write!([ + { + action: 'add', + fact: {category: 'project', statement: 'JWT expires in 24h', subject: 'auth'}, + }, + { + action: 'add', + fact: {category: 'environment', statement: 'PostgreSQL 15', subject: 'database'}, + }, + { + action: 'add', + fact: {statement: 'Rate limit is 100/min per IP', subject: 'rate-limit'}, + }, + ]) + + expect(result.summary.failed, JSON.stringify(result.applied)).to.equal(0) + expect(result.summary.added).to.equal(3) + expect(result.applied).to.have.length(3) + for (const op of result.applied) { + expect(op.status).to.equal('success') + } + }) + + // R-1 hotfix regression test (PHASE-2-UAT.md §5.4 Scenario 4): + // UPDATE must preserve existing facts when the new operation carries + // only the new fact. Pre-fix, executeUpdate's structural-loss machinery + // ignored facts/keywords/tags — the existing fact was silently + // overwritten by the single new fact and the original survived only + // in `.brv/review-backups/`. Post-fix, both facts must coexist in the + // live tree. See conflict-detector.ts + conflict-resolver.ts. + it('preserves existing facts when UPDATE adds a new fact to the same subject', async () => { + // Step A: ADD a fact about JWT token expiry. + const addResult = await executeCurate({ + basePath, + operations: [ + { + confidence: 'high', + content: { + facts: [{statement: 'JWT tokens expire after 24 hours', subject: 'jwt_token_expiration'}], + }, + impact: 'low', + path: 'project/jwt_token_expiration', + reason: 'initial add', + summary: 'JWT tokens expire after 24 hours', + title: 'jwt_tokens_expire_after_24_hours', + type: 'ADD', + }, + ], + }) + + expect(addResult.applied[0].status, JSON.stringify(addResult.applied)).to.equal('success') + + // Step B: UPDATE the same file with a new (different) fact. + // Pre-fix, this overwrote the 24h fact entirely. + const updateResult = await executeCurate({ + basePath, + operations: [ + { + confidence: 'high', + content: { + facts: [{statement: 'JWT tokens use SameSite=Strict', subject: 'jwt_samesite_policy'}], + }, + impact: 'low', + path: 'project/jwt_token_expiration', + reason: 'subject "jwt_samesite_policy" already present', + summary: 'JWT tokens use SameSite=Strict', + title: 'jwt_tokens_expire_after_24_hours', + type: 'UPDATE', + }, + ], + }) + + expect(updateResult.applied[0].status, JSON.stringify(updateResult.applied)).to.equal('success') + + // Read the resulting file — BOTH facts must be present. + const filePath = join( + basePath, + 'project', + 'jwt_token_expiration', + 'jwt_tokens_expire_after_24_hours.md', + ) + const content = await readFile(filePath, 'utf8') + + expect(content, 'original 24h fact must survive UPDATE').to.include('expire after 24 hours') + expect(content, 'new SameSite fact must be present').to.include('SameSite=Strict') + + // R-6 (PHASE-2.5-PLAN.md §3.2): the frontmatter `summary:` field must + // reflect the merged set, not just the most-recently-written fact. + // Pre-fix, summary read 'JWT tokens use SameSite=Strict' only — Phase 3 + // UAT flagged this as a stale-header bug (file content vs frontmatter + // disagreed). After R-6, summary is the `; `-joined statements. + const summaryMatch = content.match(/^summary:\s*['"]?(.+?)['"]?\s*$/m) + expect(summaryMatch, 'frontmatter must have a summary line').to.exist + const summaryLine = summaryMatch![1] + expect(summaryLine, 'merged summary must mention 24h fact').to.include('expire after 24 hours') + expect(summaryLine, 'merged summary must mention SameSite fact').to.include('SameSite=Strict') + }) + + // R-4 + Phase B batching (PR578-TOKEN-REGRESSION-FIX-PLAN.md §3.1): + // two same-subject ADDs share `(path, title)` after Phase A coarsening + // and now batch into ONE operation via Phase B's `byTarget` map. Both + // facts are emitted in the operation's `facts[]` array; one file, two + // facts, one write — no UPSERT-collision sequencing needed. + it('two same-subject ADDs in one batch produce ONE operation with both facts (Phase B batching)', async () => { + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], // no existing memory; both decisions look like fresh ADDs + }) + + const result = await services.write!([ + { + action: 'add', + fact: {category: 'project', statement: 'JWT tokens expire after 24 hours', subject: 'auth'}, + }, + { + action: 'add', + // SAME subject as above — both decisions resolve to the same target + // (path: project/auth, title: auth) and Phase B batches them. + fact: {category: 'project', statement: 'JWT tokens use httpOnly cookies', subject: 'auth'}, + }, + ]) + + // Phase B: 2 decisions → 1 batched operation. + expect(result.summary.failed, JSON.stringify(result.applied)).to.equal(0) + expect(result.applied).to.have.length(1) + expect(result.applied[0].status).to.equal('success') + + // The file lives at //.md where topic = + // derivePrefixTopic(subject). For single-token subject 'auth' the topic + // equals the subject — same path as legacy. + const filePath = join(basePath, 'project', 'auth', 'auth.md') + const content = await readFile(filePath, 'utf8') + + // BOTH facts must be present in the merged file's body. + expect(content, 'first fact must survive batching').to.include('expire after 24 hours') + expect(content, 'second fact must be present').to.include('httpOnly cookies') + }) + + // Phase A coarsening (PR578 §3.1): filename is derived from the subject's + // FIRST underscore-token (the prefix-cluster topic), not the full subject. + // 'jwt_token_expiry' → topic 'jwt' → file at project/jwt/jwt.md. + // This collapses related subjects (jwt_token_expiry, jwt_storage, etc.) + // into one folder/file instead of scattering them. + it('Phase A derives ADD filename from subject prefix-cluster topic', async () => { + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + }) + + await services.write!([ + { + action: 'add', + fact: {category: 'project', statement: 'Some long statement here for testing', subject: 'jwt_token_expiry'}, + }, + ]) + + // Filename is .md where topic = derivePrefixTopic('jwt_token_expiry') = 'jwt'. + const expected = join(basePath, 'project', 'jwt', 'jwt.md') + await readFile(expected, 'utf8') // throws if missing — that's the assertion + }) + + // R-2 (PHASE-2.5-PLAN.md §3.3): tags/keywords populated; relations + // resolve to actual sibling files (slug-parity guard against the + // toSnakeCase vs normalizeRelationPath mismatch). + it('R-2 populates frontmatter tags, keywords, and relations on ADD', async () => { + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + }) + + await services.write!([ + { + action: 'add', + fact: {category: 'project', statement: 'JWT tokens expire after 24 hours', subject: 'jwt_expiry'}, + }, + { + action: 'add', + fact: {category: 'project', statement: 'Rate limit is 100 req per minute per IP', subject: 'rate_limit'}, + }, + ]) + + // After Phase A coarsening: subject 'jwt_expiry' → topic 'jwt' → + // path project/jwt/jwt.md. Subject 'rate_limit' → topic 'rate' → + // path project/rate/rate.md. Different prefixes → distinct files. + const filePath = join(basePath, 'project', 'jwt', 'jwt.md') + const content = await readFile(filePath, 'utf8') + + // Writer uses YAML flow-style arrays (`tags: [a, b]`) per markdown-writer.ts:194. + // Tags/keywords still derive from the full subject (preserved in fact body), + // not from the coarsened topic. + expect(content, 'tags include category').to.match(/tags:\s*\[[^\]]*\bproject\b/) + expect(content, 'tags include subject').to.match(/tags:\s*\[[^\]]*\bjwt_expiry\b/) + + // Keywords: subject + content tokens (filtered) + expect(content, 'keywords include subject').to.match(/keywords:\s*\[[^\]]*\bjwt_expiry\b/) + expect(content, 'keywords include content token').to.match(/keywords:\s*\[[^\]]*\btokens\b/) + + // Relations: 3-segment path to the sibling's coarsened location. + expect(content, 'relations include coarsened sibling').to.match(/related:\s*\[[^\]]*project\/rate\/rate\.md/) + }) + + // R-2 SLUG PARITY (PHASE-2.5-PLAN review P1): subjects with HYPHENS + // must produce relation paths the writer's actual file path matches. + // toSnakeCase('rate-limit') → 'rate_limit'; normalizeRelationPath only + // handles spaces, so without the deriveRelated slug fix the relation + // would be 'project/rate-limit/rate-limit.md' but the file would be + // at 'project/rate_limit/rate_limit.md'. Broken link. + it('R-2 derived `related` paths point to actual sibling files (slug parity for hyphens)', async () => { + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + }) + + await services.write!([ + {action: 'add', fact: {category: 'project', statement: 'Rate limit is 100/min', subject: 'rate-limit'}}, + {action: 'add', fact: {category: 'project', statement: 'JWT expires in 24h', subject: 'jwt-token'}}, + ]) + + // First file's `related` should resolve to the second file's actual on-disk path. + const firstFilePath = join(basePath, 'project', 'rate_limit', 'rate_limit.md') + const content = await readFile(firstFilePath, 'utf8') + + // Relation must use the slugged form (jwt_token, not jwt-token). + expect(content, 'relation must use snake_case sibling').to.match(/project\/jwt_token\/jwt_token\.md/) + expect(content, 'relation must NOT preserve hyphen').to.not.include('jwt-token') + + // The file the relation points at must exist on disk. + await readFile(join(basePath, 'project', 'jwt_token', 'jwt_token.md'), 'utf8') // throws if missing + }) + + // R-3 (PHASE-2.5-PLAN.md §3.4): per-leaf `Reason` carries cur- + + // source provenance + statement preview. Envelope is emitted on EVERY + // path, including UPDATE (review P2 — UPDATE used to bypass provenance). + it('R-3 Reason envelope appears on ADD with cur-logId + source + statement preview', async () => { + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + logId: 'cur-1777347876578', + lookupSubject: async () => [], + provenance: {name: 'cli-text', type: 'text'}, + taskId: 'task-uuid-A', + }) + + await services.write!([ + { + action: 'add', + fact: {category: 'project', statement: 'JWT tokens expire after 24 hours', subject: 'jwt_expiry'}, + }, + ]) + + // Phase A: subject 'jwt_expiry' → topic 'jwt' → file at project/jwt/jwt.md. + const filePath = join(basePath, 'project', 'jwt', 'jwt.md') + const content = await readFile(filePath, 'utf8') + expect(content, 'Reason includes cur-').to.include('Curated from cur-1777347876578') + expect(content, 'Reason includes provenance').to.include('text:"cli-text"') + expect(content, 'Reason includes subject').to.include('"jwt_expiry"') + expect(content, 'Reason includes category bracket').to.include('[project]') + expect(content, 'Reason includes statement preview').to.include('JWT tokens expire after 24 hours') + expect(content, 'ADD has no Decision: appendix').to.not.include('Decision:') + }) + + it('R-3 Reason envelope appears on UPDATE AND appends d.reason as Decision: (P2 fix)', async () => { + // Pre-seed an existing file so the second op routes through executeUpdate. + const seedServices = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + provenance: {name: 'seed', type: 'text'}, + taskId: 'seed-task', + }) + await seedServices.write!([ + {action: 'add', fact: {category: 'project', statement: 'seed fact', subject: 'shared_subject'}}, + ]) + + // Phase A: subject 'shared_subject' → topic 'shared' → file at project/shared/shared.md. + // The UPDATE's existingId must reference the actual on-disk path. + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + logId: 'cur-1777347889041', + lookupSubject: async () => [], + provenance: {name: 'cli-text', type: 'text'}, + taskId: 'task-uuid-B', + }) + await services.write!([ + { + action: 'update', + existingId: 'project/shared/shared.md', + fact: {category: 'project', statement: 'new related fact', subject: 'shared_subject'}, + reason: 'subject "shared_subject" already present at project/shared/shared.md', + }, + ]) + + const filePath = join(basePath, 'project', 'shared', 'shared.md') + const content = await readFile(filePath, 'utf8') + // Envelope must be present (review P2 — pre-fix this was missing entirely on UPDATE). + expect(content, 'UPDATE Reason includes cur-').to.include('Curated from cur-1777347889041') + expect(content, 'UPDATE Reason includes provenance').to.include('text:"cli-text"') + expect(content, 'UPDATE Reason includes subject').to.include('"shared_subject"') + // d.reason appended as Decision: AFTER envelope. + expect(content, 'UPDATE Reason appends Decision: with d.reason').to.include('Decision: subject "shared_subject" already present') + }) + + // NEW-1 (PHASE-2.6-PLAN.md §3.2): when multiple decisions in one batch + // resolve to the SAME target file (UPSERT collision OR cross-batch + // UPDATE merge), the merged file's `related` field must NOT reference + // any of those merged-away subjects as if they were separate files. + // Phase 4 UAT showed `related: [project/jwt_storage/jwt_storage.md, ...]` + // pointing to files that were never created. + it('NEW-1: in-batch UPSERT collision produces ZERO dangling related paths', async () => { + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + }) + + // Three same-subject decisions. R-4 UPSERT consolidates all three into + // ONE file (project/auth/auth.md). related must NOT list phantom siblings. + await services.write!([ + {action: 'add', fact: {category: 'project', statement: 'Auth uses JWT', subject: 'auth'}}, + {action: 'add', fact: {category: 'project', statement: 'Auth httpOnly cookies', subject: 'auth'}}, + {action: 'add', fact: {category: 'project', statement: 'Auth SameSite=Strict', subject: 'auth'}}, + ]) + + const file = join(basePath, 'project', 'auth', 'auth.md') + const content = await readFile(file, 'utf8') + + const m = content.match(/^related:\s*\[(.*?)\]/m) + expect(m, 'frontmatter must have a related line').to.exist + if (m![1].trim().length > 0) { + const paths = m![1].split(',').map((s) => s.trim().replaceAll(/['"]/g, '')).filter(Boolean) + // Must resolve to files that exist. Promise.all so any missing file + // surfaces as ENOENT immediately — that's the assertion. + await Promise.all(paths.map((relPath) => readFile(join(basePath, relPath), 'utf8'))) + } + }) + + it('NEW-1: cross-batch UPDATE merge (Scenario 4 reproducer) produces ZERO dangling related paths', async () => { + // Pre-seed an existing file at project/jwt/jwt.md (Phase A: 'jwt_expiry' → topic 'jwt'). + const seed = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + }) + await seed.write!([ + {action: 'add', fact: {category: 'project', statement: 'JWT 24h', subject: 'jwt_expiry'}}, + ]) + + // Now simulate Scenario 4 step B: 2 decisions with DIFFERENT subjects all + // matched to the existing file by lookupSubject → both UPDATE-route to + // jwt.md. Their `related` would normally cross-link each other as separate + // sibling files; post-fix they must not. + const matchedExistingId = 'project/jwt/jwt.md' + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [{existingId: matchedExistingId, statement: matchedExistingId}], + }) + + await services.write!([ + {action: 'update', existingId: matchedExistingId, fact: {category: 'project', statement: 'TTL', subject: 'jwt_ttl'}}, + {action: 'update', existingId: matchedExistingId, fact: {category: 'project', statement: 'cookies', subject: 'jwt_storage'}}, + ]) + + const file = join(basePath, 'project', 'jwt', 'jwt.md') + const content = await readFile(file, 'utf8') + + const m = content.match(/^related:\s*\[(.*?)\]/m) + expect(m, 'frontmatter must have a related line').to.exist + if (m![1].trim().length > 0) { + const paths = m![1].split(',').map((s) => s.trim().replaceAll(/['"]/g, '')).filter(Boolean) + // Phantom paths like 'project/jwt_storage/jwt_storage.md' would throw + // here pre-fix. Promise.all surfaces any missing file as ENOENT. + await Promise.all(paths.map((relPath) => readFile(join(basePath, relPath), 'utf8'))) + } + }) + + // ──────────────────────────────────────────────────────────────────── + // Phase A coarsening + Phase B batching (PR578-TOKEN-REGRESSION-FIX-PLAN.md) + // ──────────────────────────────────────────────────────────────────── + + it('Phase A+B: 5 different-subject auth_* facts in shared category collapse to 1 file with 5 facts', async () => { + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], // cold-start: no existing matches + }) + + await services.write!([ + {action: 'add', fact: {category: 'project', statement: 'Login at /auth/login', subject: 'auth_login_endpoint'}}, + {action: 'add', fact: {category: 'project', statement: 'Tokens in httpOnly cookies', subject: 'auth_token_storage'}}, + {action: 'add', fact: {category: 'project', statement: 'TTL 24h', subject: 'auth_token_expiration'}}, + {action: 'add', fact: {category: 'project', statement: 'req.user attached after JWT decode', subject: 'auth_handler_request_user'}}, + {action: 'add', fact: {category: 'project', statement: 'Auth middleware runs before rate-limit', subject: 'auth_middleware_precedence'}}, + ]) + + const file = join(basePath, 'project', 'auth', 'auth.md') + const content = await readFile(file, 'utf8') + + // All five statements must be present in the merged file body. + expect(content).to.include('Login at /auth/login') + expect(content).to.include('httpOnly cookies') + expect(content).to.include('TTL 24h') + expect(content).to.include('req.user attached') + expect(content).to.include('runs before rate-limit') + + // No sibling folders created — the 5 distinct subjects all coarsened to 'auth'. + const siblings = await readdir(join(basePath, 'project')) + expect(siblings).to.deep.equal(['auth']) + }) + + it('Phase A: different-prefix subjects stay in separate folders (no over-collapse)', async () => { + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + }) + + await services.write!([ + {action: 'add', fact: {category: 'project', statement: 'a', subject: 'auth_jwt'}}, + {action: 'add', fact: {category: 'project', statement: 'b', subject: 'database_pg'}}, + {action: 'add', fact: {category: 'project', statement: 'c', subject: 'cache_redis'}}, + ]) + + const dirs = (await readdir(join(basePath, 'project'))).sort() + expect(dirs).to.deep.equal(['auth', 'cache', 'database']) + }) + + it('Phase B: mixed UPDATE+UPSERT bucket resolves to UPDATE (UPDATE wins)', async () => { + // Pre-seed a file at project/auth/auth.md. + const seed = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + }) + await seed.write!([ + {action: 'add', fact: {category: 'project', statement: 'pre-existing fact', subject: 'auth_existing'}}, + ]) + + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + }) + + // Both decisions resolve to (path: project/auth, title: auth) — same bucket. + // One has existingId (UPDATE), one is fresh (UPSERT). Type resolution: UPDATE wins. + await services.write!([ + {action: 'update', existingId: 'project/auth/auth.md', fact: {category: 'project', statement: 'updated existing', subject: 'auth_existing'}}, + {action: 'add', fact: {category: 'project', statement: 'new related fact', subject: 'auth_new'}}, + ]) + + const content = await readFile(join(basePath, 'project', 'auth', 'auth.md'), 'utf8') + // The pre-existing fact survives R-1 merge (UPDATE path was taken). + expect(content, 'pre-existing fact preserved by R-1 merge').to.include('pre-existing fact') + // The new fact was added in the same operation. + expect(content, 'new fact present').to.include('new related fact') + }) + + it('Phase B: per-fact reason envelopes preserved in joined reason field (R-3)', async () => { + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + logId: 'cur-batch-reason', + lookupSubject: async () => [], + provenance: {name: 'cli-text', type: 'text'}, + taskId: 'task-batch-reason', + }) + + // Three different-subject decisions that all coarsen to project/auth/auth.md. + await services.write!([ + {action: 'add', fact: {category: 'project', statement: 'first auth fact', subject: 'auth_alpha'}}, + {action: 'add', fact: {category: 'project', statement: 'second auth fact', subject: 'auth_beta'}}, + {action: 'add', fact: {category: 'project', statement: 'third auth fact', subject: 'auth_gamma'}}, + ]) + + const content = await readFile(join(basePath, 'project', 'auth', 'auth.md'), 'utf8') + // The per-fact envelope text appears for each subject — joined-newlines + // preserved each decision's R-3 audit trail. + expect(content, 'envelope mentions auth_alpha').to.include('"auth_alpha"') + expect(content, 'envelope mentions auth_beta').to.include('"auth_beta"') + expect(content, 'envelope mentions auth_gamma').to.include('"auth_gamma"') + // All envelopes share the same correlation id (one curate run). + const occurrences = content.split('Curated from cur-batch-reason').length - 1 + expect(occurrences, 'one envelope per decision in the bucket').to.equal(3) + }) + + it('Phase A+B: handles empty, undefined, and underscore-only subjects (resolves review-block #3)', async () => { + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + }) + await services.write!([ + {action: 'add', fact: {category: 'project', statement: 'a', subject: ''}}, + {action: 'add', fact: {category: 'project', statement: 'b', subject: undefined}}, + {action: 'add', fact: {category: 'project', statement: 'c', subject: '_'}}, + ]) + // All three derive topic = undefined → fall back to 'misc'. + const file = join(basePath, 'project', 'misc', 'misc.md') + const content = await readFile(file, 'utf8') + expect(content).to.include('a') + expect(content).to.include('b') + expect(content).to.include('c') + }) + + it('Phase A: same prefix in different categories stays in separate folders (resolves review-block #5)', async () => { + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + }) + await services.write!([ + {action: 'add', fact: {category: 'project', statement: 'project auth fact', subject: 'auth_jwt'}}, + {action: 'add', fact: {category: 'convention', statement: 'convention auth fact', subject: 'auth_jwt'}}, + ]) + // Same prefix-topic, different category → different paths, no batching. + await readFile(join(basePath, 'project', 'auth', 'auth.md'), 'utf8') // throws if missing + await readFile(join(basePath, 'convention', 'auth', 'auth.md'), 'utf8') // throws if missing + }) + + it('Phase B: large bucket — 8 same-prefix facts UPDATE existing file without R-1 structural loss (resolves review-block #4)', async () => { + // Pre-seed file with 2 existing facts (both 'auth_existing_*' subjects coarsen to project/auth/auth.md). + const seed = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + }) + await seed.write!([ + {action: 'add', fact: {category: 'project', statement: 'pre-existing fact A', subject: 'auth_existing_a'}}, + {action: 'add', fact: {category: 'project', statement: 'pre-existing fact B', subject: 'auth_existing_b'}}, + ]) + + const services = buildLiveServices({ + agent: stubAgent(), + basePath, + lookupSubject: async () => [], + }) + + // 2 UPDATE + 6 ADD, all coarsen to project/auth/auth.md → bucket of size 8. + // Bucket contains UPDATE → operation type is UPDATE → executeUpdate runs R-1 + // structural-loss merge over all 8 facts. + await services.write!([ + {action: 'update', existingId: 'project/auth/auth.md', fact: {category: 'project', statement: 'updated A', subject: 'auth_existing_a'}}, + {action: 'update', existingId: 'project/auth/auth.md', fact: {category: 'project', statement: 'updated B', subject: 'auth_existing_b'}}, + ...['c', 'd', 'e', 'f', 'g', 'h'].map((suffix) => ({ + action: 'add' as const, + fact: {category: 'project' as const, statement: `new fact ${suffix}`, subject: `auth_new_${suffix}`}, + })), + ]) + + const content = await readFile(join(basePath, 'project', 'auth', 'auth.md'), 'utf8') + // Pre-existing facts survive the merge (R-1 structural-loss handling). + expect(content, 'pre-existing fact A survived R-1 merge').to.match(/pre-existing fact A|updated A/) + expect(content, 'pre-existing fact B survived R-1 merge').to.match(/pre-existing fact B|updated B/) + // All 6 new facts are present. + for (const suffix of ['c', 'd', 'e', 'f', 'g', 'h']) { + expect(content, `new fact ${suffix} present`).to.include(`new fact ${suffix}`) + } + }) +}) diff --git a/test/integration/curate/snapshot-parity.test.ts b/test/integration/curate/snapshot-parity.test.ts new file mode 100644 index 000000000..bd6736869 --- /dev/null +++ b/test/integration/curate/snapshot-parity.test.ts @@ -0,0 +1,174 @@ +/** + * Phase 1 Task 1.0 / 1.8 — DAG plumbing-consistency snapshot test. + * + * ⚠️ SCOPE — read this before changing the test or trusting it as a parity + * gate: + * + * What this test PROVES: + * - The 7-slot DAG runs end-to-end against representative fixture inputs. + * - Given identical stub services, the DAG produces byte-identical + * outputs at every slot edge across runs and across commits. + * - If a node implementation drifts (extract count aggregation, dedup + * thresholds, group bucket keys, etc.), this test catches the drift. + * + * What this test does NOT prove: + * - Behavioral parity vs the pre-cutover monolithic curate loop. The + * baselines are captured FROM the new DAG (using the same stub + * services for both capture and assertion); they are NOT captured + * from the old executor against a real LLM. + * - The original Phase 1 plan called for capturing snapshots from the + * pre-cutover executor across three fixture sizes with fact-set + * Jaccard, summary counts, LLM-call counts, and applied-operation + * checks. THAT WAS NOT DONE before the cutover landed. Doing it now + * would require reverting the cutover, capturing, and re-applying. + * - That `services-adapter.write` builds operations the real + * `executeCurate` accepts. See `services-adapter-live-write.test.ts` + * for that — it exercises the production write path against a real + * `executeCurate` writing to a tempdir, and catches regressions like + * the path-format bug uncovered in code review. + * + * The intended safety net for true behavioral parity is manual smoke- + * testing against a real configured LLM provider on real codebases + * during the dogfood window between Phase 1 and Phase 2. If the new + * path produces visibly worse curations than the old loop, that needs + * to surface before merging. This test cannot catch that. + * + * Two fixtures (small, large) — not three as the plan called for. The + * medium fixture was dropped because chunked behavior is already + * exercised by the large fixture and the marginal coverage didn't + * justify the maintenance cost. + */ + +import {expect} from 'chai' +import {readFileSync} from 'node:fs' +import {join} from 'node:path' + +import { + type NodeContext, + type NodeServices, + TopologicalCurationRunner, +} from '../../../src/agent/core/curation/flow/runner.js' +import {buildCurationDAG} from '../../../src/agent/infra/curation/flow/dag-builder.js' + +const FIXTURES_DIR = join(process.cwd(), 'test', 'fixtures', 'curation') + +function loadFixture(name: string): string { + return readFileSync(join(FIXTURES_DIR, `${name}.txt`), 'utf8') +} + +function loadBaseline(name: string): { + failures: unknown[] + fixture: {bytes: number; path: string} + outputs: Record +} { + return JSON.parse(readFileSync(join(FIXTURES_DIR, `baseline-${name}.json`), 'utf8')) +} + +// SAME stub services as in scripts/capture-curate-baseline.ts. +// If you change one, change both, then re-run capture and commit the +// updated baseline JSON. +function makeStubServices(): NodeServices { + return { + detectConflicts: async (facts) => ({ + decisions: facts.map((fact) => ({action: 'add' as const, fact})), + }), + async extract(chunk, _taskId) { + const facts: Array<{statement: string; subject: string}> = [] + if (chunk.includes('JWT')) { + facts.push({statement: 'Auth uses JWT in httpOnly cookies', subject: 'auth'}) + } + + if (chunk.includes('PostgreSQL')) { + facts.push({statement: 'Database is PostgreSQL 15', subject: 'database'}) + } + + if (chunk.includes('rate limit') || chunk.includes('rate-limit')) { + facts.push({statement: 'Rate limit is 100/min per IP', subject: 'rate-limit'}) + } + + return {facts, failed: 0, succeeded: facts.length, total: 1} + }, + write: async (decisions) => ({ + applied: decisions.map((d) => ({ + confidence: 'high' as const, + impact: 'low' as const, + needsReview: false, + path: `${d.fact.subject ?? 'misc'}/${d.fact.statement.slice(0, 30)}.md`, + reason: 'baseline capture', + status: 'success' as const, + type: 'ADD' as const, + })), + summary: {added: decisions.length, deleted: 0, failed: 0, merged: 0, updated: 0}, + }), + } +} + +async function runDagAgainstFixture(label: string): Promise<{ + failures: ReadonlyArray + outputs: Record +}> { + const context = loadFixture(label) + const ctx: NodeContext = { + initialInput: {context, history: {}, meta: {}}, + services: makeStubServices(), + taskId: `baseline-${label}`, + } + + const dag = buildCurationDAG() + const runner = new TopologicalCurationRunner() + const result = await runner.run(dag, ctx) + + return { + failures: result.failures, + outputs: Object.fromEntries(result.outputs.entries()), + } +} + +describe('curate DAG — snapshot parity', () => { + for (const label of ['small', 'large']) { + describe(`${label} fixture`, () => { + it('matches the committed baseline (failures empty)', async () => { + const result = await runDagAgainstFixture(label) + const baseline = loadBaseline(label) + + expect(result.failures).to.deep.equal(baseline.failures) + }) + + it('produces matching recon output', async () => { + const result = await runDagAgainstFixture(label) + const baseline = loadBaseline(label) + + expect(result.outputs.recon).to.deep.equal(baseline.outputs.recon) + }) + + it('produces matching chunk output (totalChunks + chunks)', async () => { + const result = await runDagAgainstFixture(label) + const baseline = loadBaseline(label) + + expect(result.outputs.chunk).to.deep.equal(baseline.outputs.chunk) + }) + + it('produces matching extract output (facts + counts)', async () => { + const result = await runDagAgainstFixture(label) + const baseline = loadBaseline(label) + + expect(result.outputs.extract).to.deep.equal(baseline.outputs.extract) + }) + + it('produces matching group + dedup outputs', async () => { + const result = await runDagAgainstFixture(label) + const baseline = loadBaseline(label) + + expect(result.outputs.group).to.deep.equal(baseline.outputs.group) + expect(result.outputs.dedup).to.deep.equal(baseline.outputs.dedup) + }) + + it('produces matching write summary', async () => { + const result = await runDagAgainstFixture(label) + const baseline = loadBaseline(label) + + expect(result.outputs.write).to.deep.equal(baseline.outputs.write) + }) + }) + } +}) diff --git a/test/integration/mcp/query-split-mcp-harness.test.ts b/test/integration/mcp/query-split-mcp-harness.test.ts new file mode 100644 index 000000000..21a509e25 --- /dev/null +++ b/test/integration/mcp/query-split-mcp-harness.test.ts @@ -0,0 +1,303 @@ +/** + * Phase 5 round-trip — MCP transport harness (PHASE-5-CODE-REVIEW.md Pass 7 F3). + * + * Drives the search → gather → record → cached-search loop through a REAL MCP + * client/server protocol over `InMemoryTransport`. Catches what the + * executor-only round-trip test (`query-split-roundtrip.test.ts`) misses: + * + * - Tool name registration mismatches (would 404 if SKILL.md taught + * underscored names that don't match the hyphenated registrations). + * - Schema regressions (Zod input validation goes through MCP). + * - Content / `_meta` encoding regressions on the wire. + * - `encodeSearchContent` / `decodeSearchContent` round-trip on the + * transport `content` field. + * - `toBrvSearchResult` public-DTO mapping correctness. + * + * The daemon transport is mocked: `task:create` events are routed directly + * to real `QueryDispatcher` / `GatherExecutor` / `RecordAnswerExecutor` + * instances sharing one `QueryResultCache` (mirrors `agent-process.ts` + * wiring). This isolates the MCP-layer assertions from daemon process + * boot complexity while still exercising the public protocol. + */ + +import type {ConnectionState, ConnectionStateHandler, ITransportClient} from '@campfirein/brv-transport-client' + +import {Client} from '@modelcontextprotocol/sdk/client/index.js' +import {InMemoryTransport} from '@modelcontextprotocol/sdk/inMemory.js' +import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js' +import {expect} from 'chai' +import {restore, stub} from 'sinon' + +import type {IFileSystem} from '../../../src/agent/core/interfaces/i-file-system.js' +import type {ISearchKnowledgeService, SearchKnowledgeResult} from '../../../src/agent/infra/sandbox/tools-sdk.js' + +import {QueryDispatcher, toBrvSearchResult} from '../../../src/server/infra/dispatcher/query-dispatcher.js' +import {GatherExecutor} from '../../../src/server/infra/executor/gather-executor.js' +import {QueryResultCache} from '../../../src/server/infra/executor/query-result-cache.js' +import {RecordAnswerExecutor} from '../../../src/server/infra/executor/record-answer-executor.js' +import {registerBrvCurateTool} from '../../../src/server/infra/mcp/tools/brv-curate-tool.js' +import {registerBrvGatherTool} from '../../../src/server/infra/mcp/tools/brv-gather-tool.js' +import {registerBrvQueryTool} from '../../../src/server/infra/mcp/tools/brv-query-tool.js' +import {registerBrvRecordAnswerTool} from '../../../src/server/infra/mcp/tools/brv-record-answer-tool.js' +import {registerBrvSearchTool} from '../../../src/server/infra/mcp/tools/brv-search-tool.js' + +const QUERY = 'how does authentication work' +const FINGERPRINT = 'mcp-harness-fp-001' +const SYNTHESIZED_ANSWER = 'Auth uses JWTs with 24h expiry. Tokens stored in httpOnly cookies via authMiddleware.ts.' + +function makeResults(scores: number[]): SearchKnowledgeResult { + const results = scores.map((score, i) => ({ + excerpt: `excerpt ${i}`, + path: `topics/doc-${i}.md`, + score, + title: `Doc ${i}`, + })) + return {message: '', results, totalFound: results.length} +} + +function makeFileSystem(): IFileSystem { + return {readFile: stub().resolves({content: 'doc body', encoding: 'utf8'})} as unknown as IFileSystem +} + +const TEST_PROJECT_ROOT = '/test/proj' +const getTestCwd = (): string | undefined => TEST_PROJECT_ROOT +const getTestStartup = () => ({projectRoot: TEST_PROJECT_ROOT, worktreeRoot: TEST_PROJECT_ROOT}) + +/** + * Mock daemon transport that routes `task:create` events directly to the + * real Phase 5 executors and simulates `task:completed` responses. Mirrors + * the shape used by the brv-* MCP tool handlers. + */ +function createMockDaemonTransport(deps: { + dispatcher: QueryDispatcher + gatherExecutor: GatherExecutor + recordAnswerExecutor: RecordAnswerExecutor +}): ITransportClient { + const eventHandlers = new Map void>>() + // stateHandlers is intentionally collected but never fired in this harness + // (no reconnect events are simulated); kept so onStateChange callers can + // still register and receive a working teardown closure. + const stateHandlers = new Set() + + const requestWithAckStub = stub().callsFake(async (event: string, payload?: unknown) => { + if (event !== 'task:create') return + const task = payload as {content: string; taskId: string; type: string} + + // Route to the right executor based on task type, exactly like + // agent-process.ts switch. + let result: string + switch (task.type) { + case 'gather': { + const decoded = JSON.parse(task.content) as {limit?: number; query: string; scope?: string; tokenBudget?: number} + const gatherResult = await deps.gatherExecutor.execute({ + ...(decoded.limit === undefined ? {} : {limit: decoded.limit}), + query: decoded.query, + ...(decoded.scope === undefined ? {} : {scope: decoded.scope}), + ...(decoded.tokenBudget === undefined ? {} : {tokenBudget: decoded.tokenBudget}), + }) + result = JSON.stringify(gatherResult) + break + } + + case 'mcp-search': { + const decoded = JSON.parse(task.content) as {limit?: number; query: string; scope?: string} + const dispatch = await deps.dispatcher.dispatch({ + fingerprint: FINGERPRINT, + ...(decoded.limit === undefined ? {} : {limit: decoded.limit}), + query: decoded.query, + ...(decoded.scope === undefined ? {} : {scope: decoded.scope}), + }) + result = JSON.stringify(toBrvSearchResult(dispatch)) + break + } + + case 'record-answer': { + const decoded = JSON.parse(task.content) as {answer: string; fingerprint: string; query: string} + const recordResult = await deps.recordAnswerExecutor.execute(decoded) + result = JSON.stringify(recordResult) + break + } + + default: { + throw new Error(`unexpected task type in MCP harness: ${task.type}`) + } + } + + // Simulate task:completed event after handler returns + const completedHandlers = eventHandlers.get('task:completed') + if (completedHandlers) { + for (const handler of completedHandlers) { + handler({result, taskId: task.taskId}) + } + } + + + }) + + return { + connect: stub().resolves(), + disconnect: stub().resolves(), + getClientId: stub().returns('mcp-harness-client'), + getState: stub().returns('connected' as ConnectionState), + isConnected: stub().resolves(true), + joinRoom: stub().resolves(), + leaveRoom: stub().resolves(), + on(event: string, handler: (data: T) => void) { + if (!eventHandlers.has(event)) eventHandlers.set(event, new Set()) + eventHandlers.get(event)!.add(handler as (data: unknown) => void) + return () => { + eventHandlers.get(event)?.delete(handler as (data: unknown) => void) + } + }, + once: stub(), + onStateChange(handler) { + stateHandlers.add(handler) + return () => stateHandlers.delete(handler) + }, + request: stub() as unknown as ITransportClient['request'], + requestWithAck: requestWithAckStub as unknown as ITransportClient['requestWithAck'], + } +} + +describe('Phase 5 round-trip via MCP transport (Pass 7 F3 harness)', () => { + afterEach(() => restore()) + + + it('drives search → gather → record-answer → cached-search through a real MCP client/server protocol', async () => { + // Daemon-side singletons (one cache shared across executors — mirrors agent-process.ts) + const cache = new QueryResultCache() + const searchService = {search: stub().resolves(makeResults([0.3, 0.25, 0.2]))} as unknown as ISearchKnowledgeService + const fileSystem = makeFileSystem() + const dispatcher = new QueryDispatcher({cache, fileSystem, searchService}) + const gatherExecutor = new GatherExecutor({searchService}) + const recordAnswerExecutor = new RecordAnswerExecutor({cache}) + + // Mock daemon transport intercepts task:create and simulates task:completed + const daemonTransport = createMockDaemonTransport({dispatcher, gatherExecutor, recordAnswerExecutor}) + + // Real McpServer with all 5 brv tools registered (same as ByteRoverMcpServer) + const server = new McpServer({name: 'brv-test', version: '1.0.0'}) + const getClient = () => daemonTransport + registerBrvSearchTool(server, getClient, getTestCwd, getTestStartup) + registerBrvGatherTool(server, getClient, getTestCwd, getTestStartup) + registerBrvRecordAnswerTool(server, getClient, getTestCwd, getTestStartup) + registerBrvQueryTool(server, getClient, getTestCwd, getTestStartup) + registerBrvCurateTool(server, getClient, getTestCwd, getTestStartup) + + // Real MCP client/server linked over in-memory transport + const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({name: 'brv-test-client', version: '1.0.0'}) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + + try { + // === PRECONDITION: tools advertised correctly === + const tools = await client.listTools() + const toolNames = new Set(tools.tools.map((t) => t.name)) + expect(toolNames.has('brv-search')).to.equal(true, 'brv-search must be registered (hyphenated)') + expect(toolNames.has('brv-gather')).to.equal(true, 'brv-gather must be registered (hyphenated)') + expect(toolNames.has('brv-record-answer')).to.equal(true, 'brv-record-answer must be registered (hyphenated)') + expect(toolNames.has('brv-query')).to.equal(true) + expect(toolNames.has('brv-curate')).to.equal(true) + + // === STEP 1: brv-search via MCP === + const search1 = await client.callTool({arguments: {query: QUERY}, name: 'brv-search'}) + const search1Text = (search1.content as Array<{text: string}>)[0].text + const search1Public = JSON.parse(search1Text) as {fingerprint?: string; passages?: unknown[]; status: string; tier: number} + + expect(search1Public.status).to.equal('needs_synthesis') + expect(search1Public.tier).to.equal(2) + expect(search1Public.fingerprint).to.equal(FINGERPRINT) + expect(search1Public.passages).to.have.length(3) + + // === STEP 2: brv-gather via MCP === + const gather = await client.callTool({arguments: {query: QUERY}, name: 'brv-gather'}) + const gatherText = (gather.content as Array<{text: string}>)[0].text + const gatherPublic = JSON.parse(gatherText) as {prefetched_context: string; search_metadata: {result_count: number; top_score: number}} + + expect(gatherPublic.search_metadata.result_count).to.equal(3) + expect(gatherPublic.search_metadata.top_score).to.be.closeTo(0.3, 0.01) + + // === STEP 3: agent synthesizes (simulated) === + + // === STEP 4: brv-record-answer via MCP === + const record = await client.callTool({ + arguments: {answer: SYNTHESIZED_ANSWER, fingerprint: FINGERPRINT, query: QUERY}, + name: 'brv-record-answer', + }) + const recordText = (record.content as Array<{text: string}>)[0].text + const recordPublic = JSON.parse(recordText) as {fingerprint: string; recorded: boolean} + + expect(recordPublic.recorded).to.equal(true) + expect(recordPublic.fingerprint).to.equal(FINGERPRINT) + + // === STEP 5: brv-search again — should now hit tier 0 cache === + const search2 = await client.callTool({arguments: {query: QUERY}, name: 'brv-search'}) + const search2Text = (search2.content as Array<{text: string}>)[0].text + const search2Public = JSON.parse(search2Text) as {cached_answer?: string; status: string; tier: number} + + expect(search2Public.status).to.equal('cached_answer') + expect(search2Public.tier).to.equal(0) + expect(search2Public.cached_answer).to.equal(SYNTHESIZED_ANSWER) + } finally { + await Promise.all([client.close(), server.close()]) + } + }) + + it('listTools surfaces brv-query as deprecated (so MCP clients can hide / warn)', async () => { + const cache = new QueryResultCache() + const searchService = {search: stub().resolves(makeResults([]))} as unknown as ISearchKnowledgeService + const dispatcher = new QueryDispatcher({cache, fileSystem: makeFileSystem(), searchService}) + const gatherExecutor = new GatherExecutor({searchService}) + const recordAnswerExecutor = new RecordAnswerExecutor({cache}) + const daemonTransport = createMockDaemonTransport({dispatcher, gatherExecutor, recordAnswerExecutor}) + + const server = new McpServer({name: 'brv-test', version: '1.0.0'}) + registerBrvQueryTool(server, () => daemonTransport, () => '/p', () => ({projectRoot: '/p', worktreeRoot: '/p'})) + + const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({name: 'brv-test-client', version: '1.0.0'}) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + + try { + const tools = await client.listTools() + const brvQuery = tools.tools.find((t) => t.name === 'brv-query') + expect(brvQuery, 'brv-query must be advertised over MCP').to.exist + expect(brvQuery!.description ?? '').to.match(/^\[deprecated]/i) + // _meta.deprecated should round-trip through MCP wire so tool-aware clients see it + const meta = brvQuery!._meta as Record | undefined + expect(meta).to.exist + expect(meta!.deprecated).to.equal(true) + } finally { + await Promise.all([client.close(), server.close()]) + } + }) + + it('schema validation runs through MCP — calling brv-search without query is rejected', async () => { + const cache = new QueryResultCache() + const searchService = {search: stub().resolves(makeResults([]))} as unknown as ISearchKnowledgeService + const dispatcher = new QueryDispatcher({cache, fileSystem: makeFileSystem(), searchService}) + const gatherExecutor = new GatherExecutor({searchService}) + const recordAnswerExecutor = new RecordAnswerExecutor({cache}) + const daemonTransport = createMockDaemonTransport({dispatcher, gatherExecutor, recordAnswerExecutor}) + + const server = new McpServer({name: 'brv-test', version: '1.0.0'}) + registerBrvSearchTool(server, () => daemonTransport, () => '/p', () => ({projectRoot: '/p', worktreeRoot: '/p'})) + + const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair() + const client = new Client({name: 'brv-test-client', version: '1.0.0'}) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + + try { + try { + await client.callTool({arguments: {}, name: 'brv-search'}) + expect.fail('expected MCP-side schema validation to reject missing query') + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + expect(message.toLowerCase()).to.match(/required|missing|query|invalid/) + } + } finally { + await Promise.all([client.close(), server.close()]) + } + }) + +}) diff --git a/test/integration/mcp/query-split-roundtrip.test.ts b/test/integration/mcp/query-split-roundtrip.test.ts new file mode 100644 index 000000000..d88498ce5 --- /dev/null +++ b/test/integration/mcp/query-split-roundtrip.test.ts @@ -0,0 +1,185 @@ +/** + * Phase 5 round-trip integration test (PHASE-5-IMPLEMENTATION.md §2 #8). + * + * Drives the full LLM-free pipeline end-to-end on the daemon-side executors: + * + * 1. brv_search "query" with fingerprint → status: 'no_results' OR 'needs_synthesis' + * 2. brv_gather "query" → context bundle (no LLM) + * 3. (agent synthesizes locally) + * 4. brv_record_answer({query, answer, fp})→ {recorded: true, fingerprint: fp} + * 5. brv_search "query" with same fp → tier: 0, status: 'cached_answer' + * + * Bypasses the MCP transport layer: instantiates the real executors with a + * shared QueryResultCache and asserts the cache loop closes correctly. This + * is the regression net Codex flagged in F7 — unit tests alone don't catch + * cache-instance mismatches between dispatch and record-answer paths. + * + * If this test breaks, the search→gather→record→cached-search contract is + * broken — whether by cache-instance forking, fingerprint scope drift, or + * dispatch-mapper omissions. + */ + +import {expect} from 'chai' +import {stub} from 'sinon' + +import type {IFileSystem} from '../../../src/agent/core/interfaces/i-file-system.js' +import type {ISearchKnowledgeService, SearchKnowledgeResult} from '../../../src/agent/infra/sandbox/tools-sdk.js' + +import {QueryDispatcher, toBrvSearchResult} from '../../../src/server/infra/dispatcher/query-dispatcher.js' +import {GatherExecutor} from '../../../src/server/infra/executor/gather-executor.js' +import {QueryResultCache} from '../../../src/server/infra/executor/query-result-cache.js' +import {RecordAnswerExecutor} from '../../../src/server/infra/executor/record-answer-executor.js' + +const QUERY = 'how does authentication work' +const FINGERPRINT = 'integration-fp-001' +const SYNTHESIZED_ANSWER = 'Auth uses JWTs with 24h expiry. Tokens stored in httpOnly cookies via authMiddleware.ts.' + +function makeSearchService(result: SearchKnowledgeResult): ISearchKnowledgeService { + return {search: stub().resolves(result)} as unknown as ISearchKnowledgeService +} + +function makeFileSystem(): IFileSystem { + return { + readFile: stub().resolves({content: 'doc body', encoding: 'utf8'}), + } as unknown as IFileSystem +} + +function makeResults(scores: number[]): SearchKnowledgeResult { + const results = scores.map((score, i) => ({ + excerpt: `excerpt ${i}`, + path: `topics/doc-${i}.md`, + score, + title: `Doc ${i}`, + })) + return {message: '', results, totalFound: results.length} +} + +describe('Phase 5 round-trip — search → gather → record → cached-search', () => { + it('agent synthesizes after needs_synthesis, records answer, and re-search hits tier 0', async () => { + + // Daemon-side singletons (mirrors how agent-process.ts wires them — one + // cache shared between dispatcher + record-answer; if this fork were ever + // re-introduced, F7 would catch it). + const cache = new QueryResultCache() + const searchService = makeSearchService(makeResults([0.3, 0.25, 0.2])) // low-score → needs_synthesis + const fileSystem = makeFileSystem() + + const dispatcher = new QueryDispatcher({cache, fileSystem, searchService}) + const gatherExecutor = new GatherExecutor({searchService}) + const recordAnswerExecutor = new RecordAnswerExecutor({cache}) + + // Step 1: agent calls brv_search — no cache entry yet, low scores → needs_synthesis + const search1 = await dispatcher.dispatch({fingerprint: FINGERPRINT, query: QUERY}) + const search1Public = toBrvSearchResult(search1) + + expect(search1Public.status).to.equal('needs_synthesis') + expect(search1Public.tier).to.equal(2) + expect(search1Public.fingerprint).to.equal(FINGERPRINT) + expect(search1Public.passages).to.have.length(3) + + // Step 2: agent calls brv_gather to assemble a context bundle (no LLM) + const gather = await gatherExecutor.execute({query: QUERY}) + + // Bundle is for human/agent inspection. Even with low scores, search_metadata reflects what was found. + expect(gather.search_metadata.result_count).to.equal(3) + expect(gather.search_metadata.top_score).to.be.closeTo(0.3, 0.01) + + // Step 3: agent runs its own LLM with the bundle (simulated — we just have an answer string) + // No daemon involvement here. + + // Step 4: agent calls brv_record_answer to close the cache loop + const record = await recordAnswerExecutor.execute({ + answer: SYNTHESIZED_ANSWER, + fingerprint: FINGERPRINT, + query: QUERY, + }) + + expect(record.recorded).to.equal(true) + expect(record.fingerprint).to.equal(FINGERPRINT) + + // Step 5: agent calls brv_search again with the same query+fingerprint — tier 0 hit + const search2 = await dispatcher.dispatch({fingerprint: FINGERPRINT, query: QUERY}) + const search2Public = toBrvSearchResult(search2) + + expect(search2Public.status).to.equal('cached_answer') + expect(search2Public.tier).to.equal(0) + expect(search2Public.cached_answer).to.equal(SYNTHESIZED_ANSWER) + expect(search2Public.fingerprint).to.equal(FINGERPRINT) + + }) + + it('fuzzy-similar query hits tier 1 after recording (proves Jaccard fallback works for the loop)', async () => { + + const cache = new QueryResultCache() + const dispatcher = new QueryDispatcher({ + cache, + fileSystem: makeFileSystem(), + searchService: makeSearchService(makeResults([0.3])), + }) + const recordAnswerExecutor = new RecordAnswerExecutor({cache}) + + // Record answer for the original query + await recordAnswerExecutor.execute({ + answer: SYNTHESIZED_ANSWER, + fingerprint: FINGERPRINT, + query: 'authentication security guide overview', + }) + + // Different query, but high token overlap (Jaccard >= threshold) + const search = await dispatcher.dispatch({ + fingerprint: FINGERPRINT, + query: 'authentication security guide detailed', + }) + const publicResult = toBrvSearchResult(search) + + expect(publicResult.status).to.equal('cached_answer') + expect(publicResult.tier).to.equal(1) + expect(publicResult.cached_answer).to.equal(SYNTHESIZED_ANSWER) + + }) + + it('different fingerprint after recording → cache miss (fingerprint isolation holds across the loop)', async () => { + const cache = new QueryResultCache() + const dispatcher = new QueryDispatcher({ + cache, + fileSystem: makeFileSystem(), + searchService: makeSearchService(makeResults([])), + }) + const recordAnswerExecutor = new RecordAnswerExecutor({cache}) + + await recordAnswerExecutor.execute({ + answer: SYNTHESIZED_ANSWER, + fingerprint: FINGERPRINT, + query: QUERY, + }) + + // Different fingerprint — cache key changes; the prior entry is invisible + const search = await dispatcher.dispatch({fingerprint: 'different-fp', query: QUERY}) + const publicResult = toBrvSearchResult(search) + + expect(publicResult.status).to.equal('no_results') + expect(publicResult.tier).to.equal(2) + }) + + it('record-answer is graceful when cache is disabled (no daemon error)', async () => { + // Daemon started without cache (enableCache: false on QueryExecutor) + const dispatcher = new QueryDispatcher({ + fileSystem: makeFileSystem(), + searchService: makeSearchService(makeResults([])), + }) + // RecordAnswerExecutor without cache must still resolve, just with recorded: false + const recordAnswerExecutor = new RecordAnswerExecutor({}) + + const record = await recordAnswerExecutor.execute({ + answer: SYNTHESIZED_ANSWER, + fingerprint: FINGERPRINT, + query: QUERY, + }) + expect(record.recorded).to.equal(false) + + // Subsequent search still works (no cache, no error) + const search = await dispatcher.dispatch({fingerprint: FINGERPRINT, query: QUERY}) + const publicResult = toBrvSearchResult(search) + expect(publicResult.status).to.equal('no_results') + }) +}) diff --git a/test/integration/workspace/workspace-scoped-execution.test.ts b/test/integration/workspace/workspace-scoped-execution.test.ts index 35978d518..4f9ac697f 100644 --- a/test/integration/workspace/workspace-scoped-execution.test.ts +++ b/test/integration/workspace/workspace-scoped-execution.test.ts @@ -376,7 +376,9 @@ describe('workspace-scoped execution (integration)', () => { }, ]) const fakeReader = {readFiles} - const executor = new CurateExecutor(fakeReader as never) + // Phase 1 cutover: CurateExecutor now takes a deps object instead of + // a positional FileContentReader. See plan/agent-driven-graph/PHASE-1-IMPLEMENTATION.md. + const executor = new CurateExecutor({fileContentReader: fakeReader as never}) await executor.executeWithAgent(agent, { clientCwd, diff --git a/test/unit/agent/curate-flow/dag-builder.test.ts b/test/unit/agent/curate-flow/dag-builder.test.ts new file mode 100644 index 000000000..2278f5f24 --- /dev/null +++ b/test/unit/agent/curate-flow/dag-builder.test.ts @@ -0,0 +1,92 @@ +import {expect} from 'chai' + +import {NODE_SLOT_ORDER} from '../../../../src/agent/core/curation/flow/types.js' +import {buildCurationDAG} from '../../../../src/agent/infra/curation/flow/dag-builder.js' + +describe('buildCurationDAG', () => { + describe('default linear topology', () => { + it('produces a DAG with 7 nodes (one per slot)', () => { + const dag = buildCurationDAG() + expect(Object.keys(dag.nodes)).to.have.length(7) + }) + + it('wires nodes in canonical order: recon → chunk → extract → group → dedup → conflict → write', () => { + const dag = buildCurationDAG() + + // Build adjacency map for assertion + const successors: Record = {} + for (const slot of NODE_SLOT_ORDER) { + successors[slot] = [] + } + + for (const {from, to} of dag.edges) { + successors[from].push(to) + } + + expect(successors.recon).to.deep.equal(['chunk']) + expect(successors.chunk).to.deep.equal(['extract']) + expect(successors.extract).to.deep.equal(['group']) + expect(successors.group).to.deep.equal(['dedup']) + expect(successors.dedup).to.deep.equal(['conflict']) + expect(successors.conflict).to.deep.equal(['write']) + expect(successors.write).to.deep.equal([]) + }) + + it('declares recon as the only entry node', () => { + const dag = buildCurationDAG() + expect([...dag.entryNodeIds]).to.deep.equal(['recon']) + }) + + it('declares write as the only exit node', () => { + const dag = buildCurationDAG() + expect([...dag.exitNodeIds]).to.deep.equal(['write']) + }) + }) + + describe('node identity', () => { + it('every node has its slot as its id by default', () => { + const dag = buildCurationDAG() + for (const slot of NODE_SLOT_ORDER) { + expect(dag.nodes[slot]).to.exist + expect(dag.nodes[slot].id).to.equal(slot) + expect(dag.nodes[slot].slot).to.equal(slot) + } + }) + + it('every node exposes an execute function', () => { + const dag = buildCurationDAG() + for (const slot of NODE_SLOT_ORDER) { + expect(dag.nodes[slot].execute).to.be.a('function') + } + }) + }) + + describe('configuration', () => { + it('defaults maxConcurrency to 1 in Phase 1 (parallel fan-out is Phase 2)', () => { + const dag = buildCurationDAG() + expect(dag.maxConcurrency).to.equal(1) + }) + + it('accepts a maxConcurrency override', () => { + const dag = buildCurationDAG({maxConcurrency: 4}) + expect(dag.maxConcurrency).to.equal(4) + }) + }) + + describe('edge integrity', () => { + it('produces exactly 6 edges (linear chain of 7 nodes)', () => { + const dag = buildCurationDAG() + expect(dag.edges).to.have.length(6) + }) + + it('every edge endpoint references a node that exists', () => { + const dag = buildCurationDAG() + const nodeIds = new Set(Object.keys(dag.nodes)) + + for (const {from, to} of dag.edges) { + expect(nodeIds.has(from), `edge from ${from}`).to.be.true + expect(nodeIds.has(to), `edge to ${to}`).to.be.true + } + }) + }) +}) diff --git a/test/unit/agent/curate-flow/metadata-derivers.test.ts b/test/unit/agent/curate-flow/metadata-derivers.test.ts new file mode 100644 index 000000000..24379c48c --- /dev/null +++ b/test/unit/agent/curate-flow/metadata-derivers.test.ts @@ -0,0 +1,234 @@ +/** + * Phase 2.5 R-2 — metadata derivers (PHASE-2.5-PLAN.md §3.3). + * + * Tests three pure-function helpers that populate `tags` / `keywords` / + * `related` for curate operations, replacing the always-empty arrays + * the Phase 3 UAT flagged on 86/86 leaf files. + * + * The helpers live in their own module (NOT scope-private inside + * services-adapter.ts) per §3.3 P3a — direct import for tests is the + * right boundary because the helpers are pure functions over plain + * inputs with no infrastructure dependencies. + */ + +import {expect} from 'chai' + +import { + deriveKeywords, + deriveRelated, + deriveRelatedFromResolved, + deriveTags, +} from '../../../../src/agent/infra/curation/flow/metadata-derivers.js' + +describe('metadata-derivers', () => { + describe('deriveTags', () => { + it('includes both category and subject as tags', () => { + const tags = deriveTags({category: 'project', statement: 'X', subject: 'auth'}) + expect(tags).to.include('project') + expect(tags).to.include('auth') + expect(tags).to.have.length(2) + }) + + it('lowercases and dedupes when category and subject collide case-insensitively', () => { + const tags = deriveTags({category: 'Project', statement: 'X', subject: 'project'}) + expect(tags).to.deep.equal(['project']) + }) + + it('returns empty array when both category and subject are missing', () => { + expect(deriveTags({statement: 'X'})).to.deep.equal([]) + }) + + it('returns just category when subject missing', () => { + expect(deriveTags({category: 'project', statement: 'X'})).to.deep.equal(['project']) + }) + }) + + describe('deriveKeywords', () => { + it('includes subject as the first keyword when present', () => { + const kw = deriveKeywords({statement: 'JWT tokens expire after 24 hours', subject: 'jwt'}) + expect(kw[0]).to.equal('jwt') + }) + + it('filters out stop words and short (≤2 char) tokens', () => { + const kw = deriveKeywords({statement: 'The cat is on the mat'}) + expect(kw).to.not.include('the') + expect(kw).to.not.include('is') + expect(kw).to.not.include('on') + // 'cat' and 'mat' are >2 chars and not stopwords — should survive + expect(kw).to.include('cat') + expect(kw).to.include('mat') + }) + + it('caps total keywords at 8', () => { + const long = 'alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi' + const kw = deriveKeywords({statement: long}) + expect(kw.length).to.be.at.most(8) + }) + + it('strips punctuation/non-word chars from tokens', () => { + const kw = deriveKeywords({statement: 'JWT,tokens; auth!'}) + expect(kw).to.include('jwt') + expect(kw).to.include('tokens') + expect(kw).to.include('auth') + }) + + it('does NOT duplicate the subject token when statement also contains it', () => { + const kw = deriveKeywords({statement: 'auth uses jwt tokens', subject: 'auth'}) + // subject is included once; statement-derived 'auth' should be dedup'd + expect(kw.filter((k) => k === 'auth')).to.have.length(1) + expect(kw[0]).to.equal('auth') // subject is first + }) + }) + + describe('deriveRelated', () => { + it('links decisions sharing the same category but with distinct subjects', () => { + const current = {action: 'add' as const, fact: {category: 'project', statement: 's1', subject: 'auth'}} + const all = [ + current, + {action: 'add' as const, fact: {category: 'project', statement: 's2', subject: 'logging'}}, + {action: 'add' as const, fact: {category: 'convention', statement: 's3', subject: 'naming'}}, + ] + const related = deriveRelated(current, all) + // 3-segment path matching // (R-4 file layout) + expect(related).to.deep.equal(['project/logging/logging']) + }) + + it('skips the current decision (no self-link)', () => { + const current = {action: 'add' as const, fact: {category: 'project', statement: 's', subject: 'auth'}} + const related = deriveRelated(current, [current]) + expect(related).to.deep.equal([]) + }) + + it('skips decisions with same subject (would point to same file)', () => { + const current = {action: 'add' as const, fact: {category: 'project', statement: 's1', subject: 'auth'}} + const all = [ + current, + {action: 'add' as const, fact: {category: 'project', statement: 's2', subject: 'auth'}}, + ] + expect(deriveRelated(current, all)).to.deep.equal([]) + }) + + it('emits empty array when no other decisions in batch share the category', () => { + const current = {action: 'add' as const, fact: {category: 'project', statement: 's', subject: 'auth'}} + const all = [ + current, + {action: 'add' as const, fact: {category: 'convention', statement: 's2', subject: 'naming'}}, + ] + expect(deriveRelated(current, all)).to.deep.equal([]) + }) + + // SLUG PARITY (PHASE-2.5-PLAN review P1) — each path segment must use + // toSnakeCase so the relation resolves to the actual file written by + // executeAdd (which uses toSnakeCase). normalizeRelationPath only + // lowercases + replaces SPACES — hyphens/punctuation would slug-mismatch. + it('uses toSnakeCase for every segment so hyphens/punctuation match the writer', () => { + const current = {action: 'add' as const, fact: {category: 'project', statement: 's1', subject: 'rate-limit'}} + const all = [ + current, + {action: 'add' as const, fact: {category: 'project', statement: 's2', subject: 'jwt-token'}}, + ] + const related = deriveRelated(current, all) + // hyphens in 'jwt-token' must become underscores so the relation points + // to the real file at project/jwt_token/jwt_token.md + expect(related).to.deep.equal(['project/jwt_token/jwt_token']) + }) + }) + + // NEW-1 (PHASE-2.6-PLAN.md §3.2) — like deriveRelated but operates over + // RESOLVED target paths. Filters out same-target decisions (UPSERT + // collision OR cross-batch UPDATE merge), so the emitted `related` + // never points at a file that won't be materialized on disk. + describe('deriveRelatedFromResolved (NEW-1 fix)', () => { + it('returns same-category sibling targets, skipping current', () => { + const a = { + decision: {action: 'add' as const, fact: {category: 'project', statement: 's', subject: 'auth'}}, + path: 'project/auth', + title: 'auth', + } + const b = { + decision: {action: 'add' as const, fact: {category: 'project', statement: 's', subject: 'logging'}}, + path: 'project/logging', + title: 'logging', + } + const related = deriveRelatedFromResolved(a, [a, b]) + expect(related).to.deep.equal(['project/logging/logging']) + }) + + it('FILTERS OUT decisions whose RESOLVED target equals the current target (R-4 in-batch UPSERT collision)', () => { + // Two decisions with the same subject → R-4 routes both to same file. + // Both have path='project/auth', title='auth' → resolved target is identical. + // deriveRelatedFromResolved must NOT cross-link them. + const a = { + decision: {action: 'add' as const, fact: {category: 'project', statement: 's1', subject: 'auth'}}, + path: 'project/auth', + title: 'auth', + } + const b = { + decision: {action: 'add' as const, fact: {category: 'project', statement: 's2', subject: 'auth'}}, + path: 'project/auth', + title: 'auth', + } + // Different subject FIELDS but same resolved target — must be filtered. + // (In practice, deriveTitle returns subject so same-subject implies same target.) + const related = deriveRelatedFromResolved(a, [a, b]) + expect(related, 'same target → not related').to.deep.equal([]) + }) + + it('FILTERS OUT decisions with DIFFERENT subjects but SAME resolved target (cross-batch UPDATE merge)', () => { + // Scenario 4 step B reproducer: 3 decisions with distinct subjects + // (jwt_ttl, jwt_storage) all UPDATE-route to the existing jwt_expiry file. + // Resolved targets identical → must be filtered out of each others' related. + const a = { + decision: {action: 'update' as const, fact: {category: 'project', statement: 's', subject: 'jwt_ttl'}}, + path: 'project/jwt_expiry', + title: 'jwt_expiry', + } + const b = { + decision: {action: 'update' as const, fact: {category: 'project', statement: 's', subject: 'jwt_storage'}}, + path: 'project/jwt_expiry', + title: 'jwt_expiry', + } + const related = deriveRelatedFromResolved(a, [a, b]) + expect(related, 'merged-into-same-file siblings are not related').to.deep.equal([]) + }) + + it('uses toSnakeCase on path AND title segments for slug-parity (matches what executeCurate writes)', () => { + const a = { + decision: {action: 'add' as const, fact: {category: 'project', statement: 's', subject: 'rate-limit'}}, + path: 'project/rate-limit', + title: 'rate-limit', + } + const b = { + decision: {action: 'add' as const, fact: {category: 'project', statement: 's', subject: 'jwt-token'}}, + path: 'project/jwt-token', + title: 'jwt-token', + } + const related = deriveRelatedFromResolved(a, [a, b]) + // hyphens → underscores in BOTH path segments (matches executeCurate's toSnakeCase per segment) + expect(related).to.deep.equal(['project/jwt_token/jwt_token']) + }) + + it('skips other-decisions with different category (in-batch isolation between subtrees)', () => { + const a = { + decision: {action: 'add' as const, fact: {category: 'project', statement: 's', subject: 'auth'}}, + path: 'project/auth', + title: 'auth', + } + const b = { + decision: {action: 'add' as const, fact: {category: 'environment', statement: 's', subject: 'deploy'}}, + path: 'environment/deploy', + title: 'deploy', + } + expect(deriveRelatedFromResolved(a, [a, b])).to.deep.equal([]) + }) + + it('returns empty array when only the current decision is in the batch', () => { + const a = { + decision: {action: 'add' as const, fact: {category: 'project', statement: 's', subject: 'auth'}}, + path: 'project/auth', + title: 'auth', + } + expect(deriveRelatedFromResolved(a, [a])).to.deep.equal([]) + }) + }) +}) diff --git a/test/unit/agent/curate-flow/metrics.test.ts b/test/unit/agent/curate-flow/metrics.test.ts new file mode 100644 index 000000000..30989b16b --- /dev/null +++ b/test/unit/agent/curate-flow/metrics.test.ts @@ -0,0 +1,132 @@ +import {expect} from 'chai' + +import type {NodeSlot} from '../../../../src/agent/core/curation/flow/types.js' + +import {MetricsCollector} from '../../../../src/agent/core/curation/flow/metrics.js' + +describe('MetricsCollector', () => { + describe('per-node timing', () => { + it('records a duration > 0 for a started+ended node', async () => { + const collector = new MetricsCollector('task-123') + + collector.startNode('recon') + await new Promise((resolve) => { + setTimeout(resolve, 5) + }) + collector.endNode('recon') + + const event = collector.emit() + expect(event.nodeTimings.recon).to.be.greaterThan(0) + }) + + it('records 0 ms (not undefined) when endNode is called immediately after startNode', () => { + const collector = new MetricsCollector('task-123') + + collector.startNode('chunk') + collector.endNode('chunk') + + const event = collector.emit() + expect(event.nodeTimings.chunk).to.be.a('number') + expect(event.nodeTimings.chunk).to.be.gte(0) + }) + + it('tracks multiple nodes independently', async () => { + const collector = new MetricsCollector('task-123') + + collector.startNode('recon') + await new Promise((resolve) => { + setTimeout(resolve, 3) + }) + collector.endNode('recon') + + collector.startNode('extract') + await new Promise((resolve) => { + setTimeout(resolve, 5) + }) + collector.endNode('extract') + + const event = collector.emit() + expect(event.nodeTimings.recon).to.be.greaterThan(0) + expect(event.nodeTimings.extract).to.be.greaterThan(0) + }) + }) + + describe('emit() shape', () => { + it('returns an event matching the documented shape', () => { + const collector = new MetricsCollector('task-abc') + collector.startNode('recon') + collector.endNode('recon') + collector.recordFallback('extract-chunk-2') + + const event = collector.emit() + + expect(event.type).to.equal('curate-flow:run') + expect(event.taskId).to.equal('task-abc') + expect(event.nodeTimings).to.be.an('object') + expect(event.totalWallClockMs).to.be.a('number') + expect(event.fallbacksTriggered).to.deep.equal(['extract-chunk-2']) + }) + + it('zero-fills all slots in nodeTimings (per Phase 1 contract)', () => { + const collector = new MetricsCollector('task-xyz') + collector.startNode('recon') + collector.endNode('recon') + + const event = collector.emit() + + // Recon ran → > 0 + expect(event.nodeTimings.recon).to.be.gte(0) + // All other slots present and zero-filled + expect(event.nodeTimings.chunk).to.equal(0) + expect(event.nodeTimings.extract).to.equal(0) + expect(event.nodeTimings.group).to.equal(0) + expect(event.nodeTimings.dedup).to.equal(0) + expect(event.nodeTimings.conflict).to.equal(0) + expect(event.nodeTimings.write).to.equal(0) + }) + }) + + describe('zero-state', () => { + it('returns nodeTimings with all 7 slots zero-filled when no nodes ran', () => { + const collector = new MetricsCollector('task-empty') + const event = collector.emit() + + expect(event.type).to.equal('curate-flow:run') + expect(event.taskId).to.equal('task-empty') + expect(Object.keys(event.nodeTimings)).to.have.length(7) + for (const slot of [ + 'recon', 'chunk', 'extract', 'group', 'dedup', 'conflict', 'write', + ] as const) { + expect(event.nodeTimings[slot], `${slot} should be zero-filled`).to.equal(0) + } + + expect(event.fallbacksTriggered).to.deep.equal([]) + expect(event.totalWallClockMs).to.be.gte(0) + }) + }) + + describe('totalWallClockMs', () => { + it('reflects time from first startNode to last endNode', async () => { + const collector = new MetricsCollector('task-wall') + collector.startNode('recon') + collector.endNode('recon') + + await new Promise((resolve) => { + setTimeout(resolve, 10) + }) + + collector.startNode('write') + collector.endNode('write') + + const event = collector.emit() + expect(event.totalWallClockMs).to.be.greaterThan(8) + }) + }) + + describe('error cases', () => { + it('throws if endNode called without matching startNode', () => { + const collector = new MetricsCollector('task-err') + expect(() => collector.endNode('recon' as NodeSlot)).to.throw(/recon/) + }) + }) +}) diff --git a/test/unit/agent/curate-flow/nodes/chunk-node.test.ts b/test/unit/agent/curate-flow/nodes/chunk-node.test.ts new file mode 100644 index 000000000..7cb28afa5 --- /dev/null +++ b/test/unit/agent/curate-flow/nodes/chunk-node.test.ts @@ -0,0 +1,84 @@ +import {expect} from 'chai' + +import type {NodeContext} from '../../../../../src/agent/core/curation/flow/runner.js' + +import {slotContracts} from '../../../../../src/agent/core/curation/flow/slots/contracts.js' +import {createChunkNode} from '../../../../../src/agent/infra/curation/flow/nodes/chunk-node.js' + +function ctxWithContext(context: string): NodeContext { + return {initialInput: {context, history: {}, meta: {}}, taskId: 'test-task'} +} + +// Recon-output shape — chunk now receives this as its edge input. +function reconLike(suggestedChunkCount: number): { + headPreview: string + history: {domains: Record; totalProcessed: number} + meta: {charCount: number; lineCount: number; messageCount: number} + suggestedChunkCount: number + suggestedMode: 'chunked' | 'single-pass' + tailPreview: string +} { + return { + headPreview: '', + history: {domains: {}, totalProcessed: 0}, + meta: {charCount: 0, lineCount: 0, messageCount: 0}, + suggestedChunkCount, + suggestedMode: suggestedChunkCount === 1 ? 'single-pass' : 'chunked', + tailPreview: '', + } +} + +describe('chunkNode', () => { + it('returns a single chunk when suggestedChunkCount = 1', async () => { + const node = createChunkNode() + const result = await node.execute(reconLike(1), ctxWithContext('hello world')) + + expect(result.chunks).to.deep.equal(['hello world']) + expect(result.totalChunks).to.equal(1) + expect(result.boundaries).to.have.length(1) + expect(result.boundaries[0]).to.deep.equal({end: 11, start: 0}) + }) + + it('returns multiple chunks when suggestedChunkCount > 1 on a long input', async () => { + const longText = 'A'.repeat(10_000) + const node = createChunkNode() + const result = await node.execute(reconLike(4), ctxWithContext(longText)) + + expect(result.totalChunks).to.be.gte(2) + expect(result.chunks.length).to.equal(result.totalChunks) + const totalChars = result.chunks.reduce((sum, c) => sum + c.length, 0) + expect(totalChars).to.be.gte(longText.length) + }) + + it('returns empty result when ctx.initialInput.context is empty', async () => { + const node = createChunkNode() + const result = await node.execute(reconLike(0), ctxWithContext('')) + + expect(result.chunks).to.deep.equal([]) + expect(result.totalChunks).to.equal(0) + expect(result.boundaries).to.deep.equal([]) + }) + + it('returns empty result when ctx.initialInput is missing entirely', async () => { + const node = createChunkNode() + const result = await node.execute(reconLike(1), {taskId: 't'}) + + expect(result.chunks).to.deep.equal([]) + expect(result.totalChunks).to.equal(0) + }) + + it('output passes the chunk slot output schema', async () => { + const node = createChunkNode() + const result = await node.execute(reconLike(1), ctxWithContext('short text')) + + const parsed = slotContracts.chunk.outputSchema.safeParse(result) + if (!parsed.success) { + throw new Error(`output rejected by schema: ${JSON.stringify(parsed.error.issues, null, 2)}`) + } + }) + + it('declares the chunk slot type', () => { + const node = createChunkNode() + expect(node.slot).to.equal('chunk') + }) +}) diff --git a/test/unit/agent/curate-flow/nodes/conflict-node.test.ts b/test/unit/agent/curate-flow/nodes/conflict-node.test.ts new file mode 100644 index 000000000..4641c3d16 --- /dev/null +++ b/test/unit/agent/curate-flow/nodes/conflict-node.test.ts @@ -0,0 +1,111 @@ +import {expect} from 'chai' +import {stub} from 'sinon' + +import type {NodeContext} from '../../../../../src/agent/core/curation/flow/runner.js' + +import {slotContracts} from '../../../../../src/agent/core/curation/flow/slots/contracts.js' +import {createConflictNode} from '../../../../../src/agent/infra/curation/flow/nodes/conflict-node.js' + +describe('conflictNode', () => { + it('delegates to services.detectConflicts with deduped facts only (no ctx existing param)', async () => { + const detectStub = stub().resolves({ + decisions: [ + {action: 'add', fact: {statement: 'JWT expires', subject: 'auth'}}, + ], + }) + + const ctx: NodeContext = { + services: {detectConflicts: detectStub}, + taskId: 't', + } + + const node = createConflictNode() + const result = await node.execute( + {deduped: [{statement: 'JWT expires', subject: 'auth'}]}, + ctx, + ) + + // detectConflicts is single-arg (facts). The service is responsible + // for sourcing existing memory itself — see runner.ts NodeServices. + expect(detectStub.calledOnce).to.be.true + expect(detectStub.firstCall.args).to.have.length(1) + expect(detectStub.firstCall.args[0]).to.deep.equal([ + {statement: 'JWT expires', subject: 'auth'}, + ]) + expect(result.decisions).to.have.length(1) + expect(result.decisions[0].action).to.equal('add') + }) + + it('fail-open: when service throws, returns add-only decisions for every fact', async () => { + const detectStub = stub().rejects(new Error('LLM unavailable')) + + const ctx: NodeContext = { + services: {detectConflicts: detectStub}, + taskId: 't', + } + + const node = createConflictNode() + const result = await node.execute( + { + deduped: [ + {statement: 'A', subject: 'x'}, + {statement: 'B', subject: 'y'}, + ], + }, + ctx, + ) + + expect(result.decisions).to.have.length(2) + expect(result.decisions.every((d) => d.action === 'add')).to.be.true + }) + + it('returns empty decisions for empty deduped input (no service call)', async () => { + const detectStub = stub().resolves({decisions: []}) + + const ctx: NodeContext = { + services: {detectConflicts: detectStub}, + taskId: 't', + } + + const node = createConflictNode() + const result = await node.execute({deduped: []}, ctx) + + expect(detectStub.called).to.be.false + expect(result.decisions).to.deep.equal([]) + }) + + it('throws a clear error when services.detectConflicts is not provided AND there are facts', async () => { + const ctx: NodeContext = {taskId: 't'} + const node = createConflictNode() + + let thrown: Error | undefined + try { + await node.execute({deduped: [{statement: 'fact', subject: 'x'}]}, ctx) + } catch (error) { + thrown = error as Error + } + + expect(thrown).to.exist + expect(thrown?.message).to.match(/detectConflicts|conflict/i) + }) + + it('output passes the conflict slot output schema', async () => { + const detectStub = stub().resolves({ + decisions: [{action: 'add', fact: {statement: 'X', subject: 'y'}}], + }) + + const ctx: NodeContext = {services: {detectConflicts: detectStub}, taskId: 't'} + const node = createConflictNode() + const result = await node.execute({deduped: [{statement: 'X', subject: 'y'}]}, ctx) + + const parsed = slotContracts.conflict.outputSchema.safeParse(result) + if (!parsed.success) { + throw new Error(`output rejected by schema: ${JSON.stringify(parsed.error.issues, null, 2)}`) + } + }) + + it('declares the conflict slot type', () => { + const node = createConflictNode() + expect(node.slot).to.equal('conflict') + }) +}) diff --git a/test/unit/agent/curate-flow/nodes/dedup-node.test.ts b/test/unit/agent/curate-flow/nodes/dedup-node.test.ts new file mode 100644 index 000000000..3ade5fbc0 --- /dev/null +++ b/test/unit/agent/curate-flow/nodes/dedup-node.test.ts @@ -0,0 +1,88 @@ +import {expect} from 'chai' + +import type {NodeContext} from '../../../../../src/agent/core/curation/flow/runner.js' + +import {slotContracts} from '../../../../../src/agent/core/curation/flow/slots/contracts.js' +import {createDedupNode} from '../../../../../src/agent/infra/curation/flow/nodes/dedup-node.js' + +const ctx: NodeContext = {taskId: 'test-task'} + +describe('dedupNode', () => { + it('collapses near-identical facts under default Jaccard threshold (0.85)', async () => { + // Note: jaccard tokenizes by whitespace, so trailing punctuation creates + // a different token (e.g. "hours" vs "hours."). Use word-order variants + // that share the same token set to actually trigger the dedup. + const node = createDedupNode() + const result = await node.execute( + { + grouped: { + auth: [ + {statement: 'JWT tokens expire after 24 hours', subject: 'auth'}, + {statement: 'after 24 hours JWT tokens expire', subject: 'auth'}, + ], + }, + }, + ctx, + ) + + // Identical token sets → jaccard = 1.0 → collapse to one + expect(result.deduped).to.have.length(1) + }) + + it('keeps facts with low similarity', async () => { + const node = createDedupNode() + const result = await node.execute( + { + grouped: { + auth: [ + {statement: 'JWT expires in 24 hours', subject: 'auth'}, + {statement: 'Refresh tokens rotate weekly', subject: 'auth'}, + ], + }, + }, + ctx, + ) + + expect(result.deduped).to.have.length(2) + }) + + it('flattens facts across all subject groups', async () => { + const node = createDedupNode() + const result = await node.execute( + { + grouped: { + auth: [{statement: 'JWT 24h', subject: 'auth'}], + database: [{statement: 'PostgreSQL 15', subject: 'database'}], + }, + }, + ctx, + ) + + expect(result.deduped).to.have.length(2) + }) + + it('returns empty for empty input', async () => { + const node = createDedupNode() + const result = await node.execute({grouped: {}}, ctx) + + expect(result.deduped).to.deep.equal([]) + }) + + it('output passes the dedup slot output schema', async () => { + const node = createDedupNode() + const result = await node.execute( + {grouped: {auth: [{statement: 'JWT 24h', subject: 'auth'}]}}, + ctx, + ) + + const parsed = slotContracts.dedup.outputSchema.safeParse(result) + if (!parsed.success) { + throw new Error(`output rejected by schema: ${JSON.stringify(parsed.error.issues, null, 2)}`) + } + }) + + it('declares the dedup slot type', () => { + const node = createDedupNode() + expect(node.slot).to.equal('dedup') + }) +}) diff --git a/test/unit/agent/curate-flow/nodes/extract-node.test.ts b/test/unit/agent/curate-flow/nodes/extract-node.test.ts new file mode 100644 index 000000000..fe50c34d1 --- /dev/null +++ b/test/unit/agent/curate-flow/nodes/extract-node.test.ts @@ -0,0 +1,210 @@ +import {expect} from 'chai' +import {stub} from 'sinon' + +import type {NodeContext} from '../../../../../src/agent/core/curation/flow/runner.js' + +import {slotContracts} from '../../../../../src/agent/core/curation/flow/slots/contracts.js' +import {createExtractNode} from '../../../../../src/agent/infra/curation/flow/nodes/extract-node.js' +import {delay} from '../../../../helpers/delay.js' + +// Build a chunk-output-shaped fixture. +function chunkOutput(chunks: string[]): { + boundaries: Array<{end: number; start: number}> + chunks: string[] + totalChunks: number +} { + return { + boundaries: chunks.map((c, i) => ({end: i + c.length, start: i})), + chunks, + totalChunks: chunks.length, + } +} + +describe('extractNode', () => { + it('loops over every chunk and aggregates results', async () => { + const extractStub = stub() + .onFirstCall() + .resolves({ + facts: [{statement: 'A', subject: 'auth'}], + failed: 0, + succeeded: 1, + total: 1, + }) + .onSecondCall() + .resolves({ + facts: [ + {statement: 'B', subject: 'auth'}, + {statement: 'C', subject: 'db'}, + ], + failed: 0, + succeeded: 1, + total: 1, + }) + + const ctx: NodeContext = { + services: {extract: extractStub}, + taskId: 'task-extract-1', + } + + const node = createExtractNode() + const result = await node.execute(chunkOutput(['chunk-1', 'chunk-2']), ctx) + + expect(extractStub.callCount).to.equal(2) + expect(extractStub.firstCall.args).to.deep.equal(['chunk-1', 'task-extract-1']) + expect(extractStub.secondCall.args).to.deep.equal(['chunk-2', 'task-extract-1']) + expect(result.facts).to.have.length(3) + expect(result.total).to.equal(2) + expect(result.succeeded).to.equal(2) + expect(result.failed).to.equal(0) + }) + + it('returns empty result when chunks array is empty', async () => { + const extractStub = stub() + const ctx: NodeContext = { + services: {extract: extractStub}, + taskId: 't', + } + + const node = createExtractNode() + const result = await node.execute(chunkOutput([]), ctx) + + expect(extractStub.called).to.be.false + expect(result.facts).to.deep.equal([]) + expect(result.total).to.equal(0) + }) + + it('throws a clear error when services.extract is not provided AND there are chunks', async () => { + const ctx: NodeContext = {taskId: 't'} + const node = createExtractNode() + + let thrown: Error | undefined + try { + await node.execute(chunkOutput(['chunk-1']), ctx) + } catch (error) { + thrown = error as Error + } + + expect(thrown).to.exist + expect(thrown?.message).to.match(/extract/i) + }) + + it('output passes the extract slot output schema', async () => { + const extractStub = stub().resolves({ + facts: [{statement: 'fact', subject: 'topic'}], + failed: 0, + succeeded: 1, + total: 1, + }) + + const ctx: NodeContext = {services: {extract: extractStub}, taskId: 't'} + const node = createExtractNode() + const result = await node.execute(chunkOutput(['x']), ctx) + + const parsed = slotContracts.extract.outputSchema.safeParse(result) + if (!parsed.success) { + throw new Error(`output rejected by schema: ${JSON.stringify(parsed.error.issues, null, 2)}`) + } + }) + + it('declares the extract slot type', () => { + const node = createExtractNode() + expect(node.slot).to.equal('extract') + }) + + // --------------------------------------------------------------------------- + // Phase 2 Task 2.4 — parallel fan-out via pMap + // --------------------------------------------------------------------------- + + describe('parallel chunk fan-out (Phase 2)', () => { + it('runs 8 chunks under concurrency 4 in ~250ms (4× speedup vs sequential)', async () => { + const chunkLatencyMs = 100 + const extractStub = stub().callsFake(async () => { + await delay(chunkLatencyMs) + return {facts: [{statement: 's', subject: 'topic'}], failed: 0, succeeded: 1, total: 1} + }) + + const ctx: NodeContext = { + extractConcurrency: 4, + services: {extract: extractStub}, + taskId: 't', + } + const node = createExtractNode() + const eight = ['c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'c8'] + + const start = Date.now() + const result = await node.execute(chunkOutput(eight), ctx) + const elapsed = Date.now() - start + + expect(extractStub.callCount).to.equal(8) + expect(result.facts).to.have.length(8) + // 8 chunks × 100ms / concurrency 4 = ~200ms ideal; allow CI headroom. + expect(elapsed, `parallel fan-out should be < 350ms (got ${elapsed})`).to.be.lessThan(350) + }) + + it('serializes when extractConcurrency is 1 (regression — proves pMap honours config)', async () => { + const chunkLatencyMs = 50 + const extractStub = stub().callsFake(async () => { + await delay(chunkLatencyMs) + return {facts: [], failed: 0, succeeded: 1, total: 1} + }) + + const ctx: NodeContext = { + extractConcurrency: 1, + services: {extract: extractStub}, + taskId: 't', + } + const node = createExtractNode() + const four = ['c1', 'c2', 'c3', 'c4'] + + const start = Date.now() + await node.execute(chunkOutput(four), ctx) + const elapsed = Date.now() - start + + // 4 × 50ms serialized = ≥ 200ms (allow some scheduler slop). + expect(elapsed, `serialized wall-clock at least 180ms (got ${elapsed})`).to.be.gte(180) + }) + + it('defaults extractConcurrency to 4 when omitted from NodeContext', async () => { + // Default is 4 — 4 chunks × 100ms / 4 ≈ 100ms; sequential would be ≥ 400ms. + const chunkLatencyMs = 100 + const extractStub = stub().callsFake(async () => { + await delay(chunkLatencyMs) + return {facts: [], failed: 0, succeeded: 1, total: 1} + }) + + const ctx: NodeContext = {services: {extract: extractStub}, taskId: 't'} + const node = createExtractNode() + const four = ['c1', 'c2', 'c3', 'c4'] + + const start = Date.now() + await node.execute(chunkOutput(four), ctx) + const elapsed = Date.now() - start + + expect(elapsed, `default-4 fan-out should be < 250ms (got ${elapsed})`).to.be.lessThan(250) + }) + + it('aggregates per-chunk facts and counts (parallel order does not change content)', async () => { + // Each chunk returns a fact whose statement reflects its own input. + const extractStub = stub().callsFake(async (chunk: string) => ({ + facts: [{statement: `fact-from-${chunk}`, subject: 'topic'}], + failed: 0, + succeeded: 1, + total: 1, + })) + + const ctx: NodeContext = { + extractConcurrency: 4, + services: {extract: extractStub}, + taskId: 't', + } + const node = createExtractNode() + const result = await node.execute(chunkOutput(['c1', 'c2', 'c3']), ctx) + + const statements = result.facts.map((f) => f.statement).sort() + expect(statements).to.deep.equal(['fact-from-c1', 'fact-from-c2', 'fact-from-c3']) + expect(result.total).to.equal(3) + expect(result.succeeded).to.equal(3) + expect(result.failed).to.equal(0) + }) + }) +}) diff --git a/test/unit/agent/curate-flow/nodes/group-node.test.ts b/test/unit/agent/curate-flow/nodes/group-node.test.ts new file mode 100644 index 000000000..d074a0d5d --- /dev/null +++ b/test/unit/agent/curate-flow/nodes/group-node.test.ts @@ -0,0 +1,92 @@ +import {expect} from 'chai' + +import type {NodeContext} from '../../../../../src/agent/core/curation/flow/runner.js' + +import {slotContracts} from '../../../../../src/agent/core/curation/flow/slots/contracts.js' +import {createGroupNode} from '../../../../../src/agent/infra/curation/flow/nodes/group-node.js' + +const ctx: NodeContext = {taskId: 'test-task'} + +type CurationCategory = + | 'convention' + | 'environment' + | 'other' + | 'personal' + | 'preference' + | 'project' + | 'team' + +interface FactFixture { + category?: CurationCategory + statement: string + subject?: string +} + +// Group's input is now extract's full output shape. +function extractOutput(facts: FactFixture[]): { + facts: FactFixture[] + failed: number + succeeded: number + total: number +} { + return {facts, failed: 0, succeeded: facts.length, total: facts.length} +} + +describe('groupNode', () => { + it('groups facts by subject', async () => { + const node = createGroupNode() + const result = await node.execute( + extractOutput([ + {statement: 'JWT expires in 24h', subject: 'auth'}, + {statement: 'Refresh tokens rotate', subject: 'auth'}, + {statement: 'PostgreSQL 15', subject: 'database'}, + ]), + ctx, + ) + + expect(result.grouped.auth).to.have.length(2) + expect(result.grouped.database).to.have.length(1) + }) + + it('uses category when subject is missing', async () => { + const node = createGroupNode() + const result = await node.execute( + extractOutput([{category: 'environment', statement: 'Node 22'}]), + ctx, + ) + + expect(result.grouped.environment).to.have.length(1) + }) + + it('falls back to "uncategorized" when both subject and category are missing', async () => { + const node = createGroupNode() + const result = await node.execute(extractOutput([{statement: 'orphan fact'}]), ctx) + + expect(result.grouped.uncategorized).to.have.length(1) + }) + + it('returns empty grouping for empty input', async () => { + const node = createGroupNode() + const result = await node.execute(extractOutput([]), ctx) + + expect(Object.keys(result.grouped)).to.have.length(0) + }) + + it('output passes the group slot output schema', async () => { + const node = createGroupNode() + const result = await node.execute( + extractOutput([{statement: 'JWT expires', subject: 'auth'}]), + ctx, + ) + + const parsed = slotContracts.group.outputSchema.safeParse(result) + if (!parsed.success) { + throw new Error(`output rejected by schema: ${JSON.stringify(parsed.error.issues, null, 2)}`) + } + }) + + it('declares the group slot type', () => { + const node = createGroupNode() + expect(node.slot).to.equal('group') + }) +}) diff --git a/test/unit/agent/curate-flow/nodes/recon-node.test.ts b/test/unit/agent/curate-flow/nodes/recon-node.test.ts new file mode 100644 index 000000000..ac47c35dd --- /dev/null +++ b/test/unit/agent/curate-flow/nodes/recon-node.test.ts @@ -0,0 +1,94 @@ +import {expect} from 'chai' + +import type {NodeContext} from '../../../../../src/agent/core/curation/flow/runner.js' + +import {slotContracts} from '../../../../../src/agent/core/curation/flow/slots/contracts.js' +import {createReconNode} from '../../../../../src/agent/infra/curation/flow/nodes/recon-node.js' + +const ctx: NodeContext = {taskId: 'test-task'} + +describe('reconNode', () => { + it('returns suggestedMode "single-pass" for short input', async () => { + const node = createReconNode() + const result = await node.execute( + {context: 'short input', history: {}, meta: {}}, + ctx, + ) + + expect(result.suggestedMode).to.equal('single-pass') + expect(result.suggestedChunkCount).to.equal(1) + }) + + it('returns suggestedMode "chunked" for input above the threshold', async () => { + const longContext = 'a'.repeat(50_000) + const node = createReconNode() + const result = await node.execute( + {context: longContext, history: {}, meta: {}}, + ctx, + ) + + expect(result.suggestedMode).to.equal('chunked') + expect(result.suggestedChunkCount).to.be.gte(2) + }) + + it('reports accurate meta counts', async () => { + const node = createReconNode() + const result = await node.execute( + {context: 'line one\nline two\nline three', history: {}, meta: {}}, + ctx, + ) + + expect(result.meta.charCount).to.equal(28) + expect(result.meta.lineCount).to.equal(3) + expect(result.meta.messageCount).to.equal(0) + }) + + it('summarizes history domains from past entries', async () => { + const node = createReconNode() + const result = await node.execute( + { + context: 'short', + history: { + entries: [ + {domain: 'auth', title: 'JWT-tokens'}, + {domain: 'auth', title: 'OAuth-flow'}, + {domain: 'database', title: 'Postgres-15'}, + ], + }, + meta: {}, + }, + ctx, + ) + + expect(result.history.domains.auth).to.deep.equal(['JWT-tokens', 'OAuth-flow']) + expect(result.history.domains.database).to.deep.equal(['Postgres-15']) + }) + + it('returns head and tail previews', async () => { + const node = createReconNode() + const middle = 'M'.repeat(5000) + const context = `START ${middle} END` + const result = await node.execute({context, history: {}, meta: {}}, ctx) + + expect(result.headPreview.startsWith('START ')).to.be.true + expect(result.tailPreview.endsWith(' END')).to.be.true + }) + + it('output passes the recon slot output schema', async () => { + const node = createReconNode() + const result = await node.execute( + {context: 'short', history: {}, meta: {}}, + ctx, + ) + + const parsed = slotContracts.recon.outputSchema.safeParse(result) + if (!parsed.success) { + throw new Error(`output rejected by schema: ${JSON.stringify(parsed.error.issues, null, 2)}`) + } + }) + + it('declares the recon slot type', () => { + const node = createReconNode() + expect(node.slot).to.equal('recon') + }) +}) diff --git a/test/unit/agent/curate-flow/nodes/write-node.test.ts b/test/unit/agent/curate-flow/nodes/write-node.test.ts new file mode 100644 index 000000000..96ef33696 --- /dev/null +++ b/test/unit/agent/curate-flow/nodes/write-node.test.ts @@ -0,0 +1,136 @@ +import {expect} from 'chai' +import {stub} from 'sinon' + +import type {NodeContext} from '../../../../../src/agent/core/curation/flow/runner.js' + +import {slotContracts} from '../../../../../src/agent/core/curation/flow/slots/contracts.js' +import {createWriteNode} from '../../../../../src/agent/infra/curation/flow/nodes/write-node.js' + +describe('writeNode', () => { + it('delegates to services.write and returns applied + summary', async () => { + const writeStub = stub().resolves({ + applied: [ + { + confidence: 'high', + impact: 'low', + needsReview: false, + path: 'auth/jwt.md', + reason: 'Documenting JWT', + status: 'success', + type: 'ADD', + }, + ], + summary: {added: 1, deleted: 0, failed: 0, merged: 0, updated: 0}, + }) + + const ctx: NodeContext = { + services: {write: writeStub}, + taskId: 't', + } + + const node = createWriteNode() + const result = await node.execute( + {decisions: [{action: 'add', fact: {statement: 'JWT expires', subject: 'auth'}}]}, + ctx, + ) + + expect(writeStub.calledOnce).to.be.true + expect(result.applied).to.have.length(1) + expect(result.summary.added).to.equal(1) + }) + + it('partial failure: surfaces failed counts in summary', async () => { + const writeStub = stub().resolves({ + applied: [ + { + confidence: 'high', + impact: 'low', + needsReview: false, + path: 'auth/jwt.md', + reason: 'ok', + status: 'success', + type: 'ADD', + }, + { + confidence: 'low', + impact: 'low', + needsReview: false, + path: 'auth/oauth.md', + reason: 'duplicate path', + status: 'failed', + type: 'ADD', + }, + ], + summary: {added: 1, deleted: 0, failed: 1, merged: 0, updated: 0}, + }) + + const ctx: NodeContext = {services: {write: writeStub}, taskId: 't'} + const node = createWriteNode() + const result = await node.execute( + { + decisions: [ + {action: 'add', fact: {statement: 'A', subject: 'auth'}}, + {action: 'add', fact: {statement: 'B', subject: 'auth'}}, + ], + }, + ctx, + ) + + expect(result.summary.failed).to.equal(1) + expect(result.applied).to.have.length(2) + }) + + it('short-circuits empty decisions without calling the service', async () => { + const writeStub = stub().resolves({ + applied: [], + summary: {added: 0, deleted: 0, failed: 0, merged: 0, updated: 0}, + }) + + const ctx: NodeContext = {services: {write: writeStub}, taskId: 't'} + const node = createWriteNode() + const result = await node.execute({decisions: []}, ctx) + + expect(writeStub.called).to.be.false + expect(result.applied).to.deep.equal([]) + expect(result.summary.added).to.equal(0) + }) + + it('throws when services.write is not provided AND there are decisions to apply', async () => { + const ctx: NodeContext = {taskId: 't'} + const node = createWriteNode() + + let thrown: Error | undefined + try { + await node.execute( + {decisions: [{action: 'add', fact: {statement: 'X', subject: 'y'}}]}, + ctx, + ) + } catch (error) { + thrown = error as Error + } + + expect(thrown).to.exist + expect(thrown?.message).to.match(/write/i) + }) + + it('output passes the write slot output schema', async () => { + const writeStub = stub().resolves({ + applied: [], + summary: {added: 0, deleted: 0, failed: 0, merged: 0, updated: 0}, + }) + + const ctx: NodeContext = {services: {write: writeStub}, taskId: 't'} + const node = createWriteNode() + const result = await node.execute({decisions: []}, ctx) + + const parsed = slotContracts.write.outputSchema.safeParse(result) + if (!parsed.success) { + throw new Error(`output rejected by schema: ${JSON.stringify(parsed.error.issues, null, 2)}`) + } + }) + + it('declares the write slot type', () => { + const node = createWriteNode() + expect(node.slot).to.equal('write') + }) +}) diff --git a/test/unit/agent/curate-flow/runner.test.ts b/test/unit/agent/curate-flow/runner.test.ts new file mode 100644 index 000000000..bfbd4e1f4 --- /dev/null +++ b/test/unit/agent/curate-flow/runner.test.ts @@ -0,0 +1,552 @@ +/** + * Phase 1 Task 1.5 — TopologicalCurationRunner (extended in Phase 2 Task 2.3). + * + * Asserts Kahn's-algorithm topological execution + pMap concurrency + * + per-node fail-open + cycle detection. + * + * Phase 2 additions: sandboxed-by-default execution with per-slot + * timeout, parent-signal propagation, and schema-gate (soft mode) + * enforcement. Existing Phase 1 tests use synthetic input shapes that + * do NOT match the real slot schemas, so they explicitly opt out via + * `sandboxed: false` to keep the runner-level concern separate from + * what each test is asserting. + */ + +import {expect} from 'chai' + +import type {NodeSlot} from '../../../../src/agent/core/curation/flow/types.js' + +import { + type CurationDAG, + type CurationNode, + CycleDetectedError, + type NodeContext, + TopologicalCurationRunner, +} from '../../../../src/agent/core/curation/flow/runner.js' +import {delay} from '../../../helpers/delay.js' + +function makeNode( + id: string, + slot: NodeSlot, + execute: (input: In, ctx: NodeContext) => Promise, +): CurationNode { + return {execute, id, slot} +} + +// Synthetic-shape tests bypass sandbox so the runner doesn't try to +// schema-validate `{value: number}` against `reconInputSchema`. +function emptyCtx(initialInput?: unknown): NodeContext { + return {initialInput, sandboxed: false, taskId: 'test-task'} +} + +// Helpers shared by Phase 2 sandboxed-execution tests. Module-scoped to +// satisfy `unicorn/consistent-function-scoping`. +function reconLikeInput(): {context: string; history: Record; meta: Record} { + // Matches reconInputSchema: {context, history, meta} + return {context: 'hello', history: {}, meta: {}} +} + +function reconLikeOutput(): { + headPreview: string + history: {domains: Record; totalProcessed: number} + meta: {charCount: number; lineCount: number; messageCount: number} + suggestedChunkCount: number + suggestedMode: 'chunked' | 'single-pass' + tailPreview: string +} { + return { + headPreview: '', + history: {domains: {}, totalProcessed: 0}, + meta: {charCount: 5, lineCount: 1, messageCount: 0}, + suggestedChunkCount: 1, + suggestedMode: 'single-pass', + tailPreview: '', + } +} + +describe('TopologicalCurationRunner', () => { + describe('linear topological execution', () => { + it('runs A → B → C with each step incrementing a counter', async () => { + const a = makeNode<{value: number}, {value: number}>('a', 'recon', async (input) => ({ + value: input.value + 1, + })) + const b = makeNode<{value: number}, {value: number}>('b', 'extract', async (input) => ({ + value: input.value + 1, + })) + const c = makeNode<{value: number}, {value: number}>('c', 'write', async (input) => ({ + value: input.value + 1, + })) + + const graph: CurationDAG = { + edges: [ + {from: 'a', to: 'b'}, + {from: 'b', to: 'c'}, + ], + entryNodeIds: ['a'], + exitNodeIds: ['c'], + maxConcurrency: 1, + nodes: {a, b, c} as Record>, + } + + const runner = new TopologicalCurationRunner() + const result = await runner.run(graph, emptyCtx({value: 0})) + + expect(result.outputs.get('a')).to.deep.equal({value: 1}) + expect(result.outputs.get('b')).to.deep.equal({value: 2}) + expect(result.outputs.get('c')).to.deep.equal({value: 3}) + expect(result.failures).to.be.empty + }) + }) + + describe('diamond topology', () => { + it('A → B, A → C, B → D, C → D — D receives both B and C outputs', async () => { + const a = makeNode('a', 'recon', async () => ({tag: 'a'})) + const b = makeNode<{tag: string}, {tag: string}>('b', 'extract', async () => ({tag: 'b'})) + const c = makeNode<{tag: string}, {tag: string}>('c', 'extract', async () => ({tag: 'c'})) + const d = makeNode, {combined: string[]}>( + 'd', + 'write', + async (input) => ({ + combined: Object.values(input) + .map((v) => v.tag) + .sort(), + }), + ) + + const graph: CurationDAG = { + edges: [ + {from: 'a', to: 'b'}, + {from: 'a', to: 'c'}, + {from: 'b', to: 'd'}, + {from: 'c', to: 'd'}, + ], + entryNodeIds: ['a'], + exitNodeIds: ['d'], + maxConcurrency: 4, + nodes: {a, b, c, d} as Record>, + } + + const runner = new TopologicalCurationRunner() + const result = await runner.run(graph, emptyCtx()) + + expect(result.outputs.get('d')).to.deep.equal({combined: ['b', 'c']}) + expect(result.failures).to.be.empty + }) + }) + + describe('cycle detection', () => { + it('throws CycleDetectedError on a graph with a cycle', async () => { + const a = makeNode('a', 'recon', async () => ({})) + const b = makeNode('b', 'extract', async () => ({})) + const c = makeNode('c', 'write', async () => ({})) + + const graph: CurationDAG = { + edges: [ + {from: 'a', to: 'b'}, + {from: 'b', to: 'c'}, + {from: 'c', to: 'a'}, // cycle! + ], + entryNodeIds: [], + exitNodeIds: ['c'], + maxConcurrency: 1, + nodes: {a, b, c} as Record>, + } + + const runner = new TopologicalCurationRunner() + let thrown: Error | undefined + try { + await runner.run(graph, emptyCtx()) + } catch (error) { + thrown = error as Error + } + + expect(thrown).to.be.instanceOf(CycleDetectedError) + }) + }) + + describe('per-node fail-open', () => { + it('records failure and continues other branches when one node throws', async () => { + // A → B (throws), A → C (succeeds). D depends on C only. + const a = makeNode('a', 'recon', async () => ({x: 1})) + const b = makeNode('b', 'extract', async () => { + throw new Error('b boom') + }) + const c = makeNode('c', 'extract', async () => ({y: 2})) + const d = makeNode<{y: number}, {z: number}>('d', 'write', async (input) => ({ + z: input.y + 10, + })) + + const graph: CurationDAG = { + edges: [ + {from: 'a', to: 'b'}, + {from: 'a', to: 'c'}, + {from: 'c', to: 'd'}, + ], + entryNodeIds: ['a'], + exitNodeIds: ['d'], + maxConcurrency: 4, + nodes: {a, b, c, d} as Record>, + } + + const runner = new TopologicalCurationRunner() + const result = await runner.run(graph, emptyCtx()) + + // C/D branch succeeded + expect(result.outputs.get('c')).to.deep.equal({y: 2}) + expect(result.outputs.get('d')).to.deep.equal({z: 12}) + + // B failure recorded + const bFailure = result.failures.find((f) => f.nodeId === 'b') + expect(bFailure).to.exist + expect(bFailure?.error).to.include('b boom') + }) + }) + + describe('bounded concurrency', () => { + it('runs 4 sibling no-LLM nodes in parallel under maxConcurrency=4', async () => { + const sleepMs = 100 + const a = makeNode('a', 'recon', async () => ({tag: 'a'})) + const siblings = ['s1', 's2', 's3', 's4'].map((id) => + makeNode(id, 'extract', async () => { + await delay(sleepMs) + return {tag: id} + }), + ) + + const graph: CurationDAG = { + edges: siblings.map((s) => ({from: 'a', to: s.id})), + entryNodeIds: ['a'], + exitNodeIds: siblings.map((s) => s.id), + maxConcurrency: 4, + nodes: { + a, + ...Object.fromEntries(siblings.map((s) => [s.id, s])), + } as Record>, + } + + const runner = new TopologicalCurationRunner() + const start = Date.now() + const result = await runner.run(graph, emptyCtx()) + const elapsed = Date.now() - start + + // 4 siblings each take 100ms; with concurrency=4 they overlap → total ~100ms. + // Allow generous CI headroom. + expect(elapsed, `4-way parallel wall-clock under 250ms (got ${elapsed})`).to.be.lessThan(250) + for (const s of siblings) { + expect(result.outputs.get(s.id)).to.deep.equal({tag: s.id}) + } + }) + + it('serializes 4 sibling nodes when maxConcurrency=1', async () => { + const sleepMs = 50 + const a = makeNode('a', 'recon', async () => ({tag: 'a'})) + const siblings = ['s1', 's2', 's3', 's4'].map((id) => + makeNode(id, 'extract', async () => { + await delay(sleepMs) + return {tag: id} + }), + ) + + const graph: CurationDAG = { + edges: siblings.map((s) => ({from: 'a', to: s.id})), + entryNodeIds: ['a'], + exitNodeIds: siblings.map((s) => s.id), + maxConcurrency: 1, + nodes: { + a, + ...Object.fromEntries(siblings.map((s) => [s.id, s])), + } as Record>, + } + + const runner = new TopologicalCurationRunner() + const start = Date.now() + await runner.run(graph, emptyCtx()) + const elapsed = Date.now() - start + + // 4 siblings at 50ms each, serialized → at least 4*50=200ms + expect(elapsed, `serialized wall-clock at least 180ms (got ${elapsed})`).to.be.gte(180) + }) + }) + + describe('input plumbing', () => { + it('passes ctx.initialInput to entry nodes (no predecessors)', async () => { + let received: unknown + const a = makeNode('a', 'recon', async (input) => { + received = input + return {ok: true} + }) + + const graph: CurationDAG = { + edges: [], + entryNodeIds: ['a'], + exitNodeIds: ['a'], + maxConcurrency: 1, + nodes: {a} as Record>, + } + + const runner = new TopologicalCurationRunner() + await runner.run(graph, emptyCtx({seed: 'value'})) + + expect(received).to.deep.equal({seed: 'value'}) + }) + + it('passes single-predecessor output directly (no wrapping)', async () => { + const a = makeNode('a', 'recon', async () => ({x: 7})) + let received: unknown + const b = makeNode('b', 'extract', async (input) => { + received = input + return {} + }) + + const graph: CurationDAG = { + edges: [{from: 'a', to: 'b'}], + entryNodeIds: ['a'], + exitNodeIds: ['b'], + maxConcurrency: 1, + nodes: {a, b} as Record>, + } + + const runner = new TopologicalCurationRunner() + await runner.run(graph, emptyCtx()) + + expect(received).to.deep.equal({x: 7}) + }) + + it('keys multi-predecessor inputs by predecessor node id', async () => { + const a = makeNode('a', 'recon', async () => ({x: 1})) + const b = makeNode('b', 'recon', async () => ({y: 2})) + let received: Record | undefined + const c = makeNode, unknown>('c', 'extract', async (input) => { + received = input + return {} + }) + + const graph: CurationDAG = { + edges: [ + {from: 'a', to: 'c'}, + {from: 'b', to: 'c'}, + ], + entryNodeIds: ['a', 'b'], + exitNodeIds: ['c'], + maxConcurrency: 2, + nodes: {a, b, c} as Record>, + } + + const runner = new TopologicalCurationRunner() + await runner.run(graph, emptyCtx()) + + expect(received).to.deep.equal({a: {x: 1}, b: {y: 2}}) + }) + }) + + // ------------------------------------------------------------------------- + // Phase 2 Task 2.3 — sandbox + schema-gate wired into the runner. + // Default: ctx.sandboxed = true. Tests below construct DAGs whose nodes use + // real slot shapes (recon's input/output schemas) so the gate runs cleanly. + // ------------------------------------------------------------------------- + + describe('sandboxed execution (Phase 2)', () => { + it('defaults sandboxed to true when omitted from NodeContext', async () => { + // Recon node returning valid recon output → passes schema gate cleanly. + const recon = makeNode('recon', 'recon', async () => reconLikeOutput()) + + const graph: CurationDAG = { + edges: [], + entryNodeIds: ['recon'], + exitNodeIds: ['recon'], + maxConcurrency: 1, + nodes: {recon}, + } + + const runner = new TopologicalCurationRunner() + // No `sandboxed` key — default behavior should be ON and still pass. + const result = await runner.run(graph, { + initialInput: reconLikeInput(), + taskId: 't', + }) + + expect(result.failures, 'no failures expected on schema-clean run').to.be.empty + expect(result.outputs.get('recon')).to.deep.equal(reconLikeOutput()) + }) + + it('strands downstream when INPUT schema fails (W4: avoids cascade of confusing warnings)', async () => { + // recon expects {context, history, meta}. We pass garbage, which + // fails inputSchema. Downstream chunk should be stranded with a + // single clear failure — NOT cascaded through the whole DAG. + let chunkRan = false + const recon = makeNode('recon', 'recon', async () => reconLikeOutput()) + const chunk = makeNode('chunk', 'chunk', async () => { + chunkRan = true + return {boundaries: [], chunks: [], totalChunks: 0} + }) + + const graph: CurationDAG = { + edges: [{from: 'recon', to: 'chunk'}], + entryNodeIds: ['recon'], + exitNodeIds: ['chunk'], + maxConcurrency: 1, + nodes: {chunk, recon}, + } + + const runner = new TopologicalCurationRunner() + const result = await runner.run(graph, { + // Garbage shape — recon's inputSchema requires {context, history, meta}. + initialInput: {garbage: true}, + sandboxed: true, + taskId: 't', + }) + + const reconFailure = result.failures.find((f) => f.nodeId === 'recon') + expect(reconFailure?.error).to.match(/recon|input|schema/i) + expect(chunkRan, 'downstream must NOT run after upstream input-fail').to.be.false + }) + + it('records (does NOT throw) a schema warning when output is malformed (soft-fail)', async () => { + // Recon node returning a broken shape → soft-fail records into failures. + const recon = makeNode('recon', 'recon', async () => ({ + // Missing required fields like history.domains, meta.charCount, etc. + suggestedMode: 'single-pass', + })) + + const graph: CurationDAG = { + edges: [], + entryNodeIds: ['recon'], + exitNodeIds: ['recon'], + maxConcurrency: 1, + nodes: {recon}, + } + + const runner = new TopologicalCurationRunner() + const result = await runner.run(graph, { + initialInput: reconLikeInput(), + sandboxed: true, + taskId: 't', + }) + + const reconFailure = result.failures.find((f) => f.nodeId === 'recon') + expect(reconFailure, 'schema warning should be recorded').to.exist + expect(reconFailure?.error).to.match(/schema|recon|suggestedMode/i) + }) + + it('aborts a node that exceeds its slot timeout (NodeTimeoutError recorded)', async () => { + // recon timeoutMs = 10_000; we override via ctx for fast test execution. + const recon = makeNode('recon', 'recon', async (_input, ctx) => { + await delay(500, ctx.signal) + return reconLikeOutput() + }) + + const graph: CurationDAG = { + edges: [], + entryNodeIds: ['recon'], + exitNodeIds: ['recon'], + maxConcurrency: 1, + nodes: {recon}, + } + + const runner = new TopologicalCurationRunner() + const result = await runner.run(graph, { + initialInput: reconLikeInput(), + sandboxed: true, + slotTimeoutOverrideMs: 30, + taskId: 't', + }) + + const reconFailure = result.failures.find((f) => f.nodeId === 'recon') + expect(reconFailure).to.exist + expect(reconFailure?.error).to.match(/timeout|aborted|recon/i) + }) + + it('strands downstream nodes when an upstream node times out', async () => { + const recon = makeNode('recon', 'recon', async (_input, ctx) => { + await delay(500, ctx.signal) + return reconLikeOutput() + }) + let downstreamRan = false + const chunk = makeNode('chunk', 'chunk', async () => { + downstreamRan = true + return {boundaries: [], chunks: [], totalChunks: 0} + }) + + const graph: CurationDAG = { + edges: [{from: 'recon', to: 'chunk'}], + entryNodeIds: ['recon'], + exitNodeIds: ['chunk'], + maxConcurrency: 1, + nodes: {chunk, recon}, + } + + const runner = new TopologicalCurationRunner() + const result = await runner.run(graph, { + initialInput: reconLikeInput(), + sandboxed: true, + slotTimeoutOverrideMs: 30, + taskId: 't', + }) + + expect(downstreamRan, 'downstream must NOT run after upstream timeout').to.be.false + expect(result.failures.find((f) => f.nodeId === 'chunk')?.error).to.match(/skipped|predecessor/i) + }) + + it('opts out cleanly with sandboxed=false (escape hatch for tests)', async () => { + // The synthetic shape would fail recon schema, but sandboxed=false + // bypasses the gate entirely — proves the escape hatch works. + const recon = makeNode('recon', 'recon', async () => ({arbitrary: 'shape'})) + + const graph: CurationDAG = { + edges: [], + entryNodeIds: ['recon'], + exitNodeIds: ['recon'], + maxConcurrency: 1, + nodes: {recon}, + } + + const runner = new TopologicalCurationRunner() + const result = await runner.run(graph, { + initialInput: {anything: 'goes'}, + sandboxed: false, + taskId: 't', + }) + + expect(result.failures).to.be.empty + expect(result.outputs.get('recon')).to.deep.equal({arbitrary: 'shape'}) + }) + + it('exposes the allowlist-proxied tools (NOT raw ctx.tools) inside the node — E1 regression', async () => { + // The runner must thread the proxied tools from the sandbox into + // slotCtx, otherwise nodes can call ctx.tools.* directly and bypass + // the per-slot allowlist. This was a latent bug pre-fix because no + // default node touches ctx.tools, but Phase 3 agent code will. + let toolError: Error | undefined + const recon = makeNode('recon', 'recon', async (_input, ctx) => { + try { + // recon's allowlist is ['tools.curation.recon'] — calling + // tools.curate (in write's allowlist, NOT recon's) must throw. + ;(ctx.tools as {curate: () => string}).curate() + } catch (error) { + toolError = error as Error + } + + return reconLikeOutput() + }) + + const graph: CurationDAG = { + edges: [], + entryNodeIds: ['recon'], + exitNodeIds: ['recon'], + maxConcurrency: 1, + nodes: {recon}, + } + + await new TopologicalCurationRunner().run(graph, { + initialInput: reconLikeInput(), + sandboxed: true, + taskId: 't', + // Raw tools that include `curate` — proxy must filter it out for recon slot. + tools: {curate: () => 'should-not-reach', curation: {recon: () => 'ok'}}, + }) + + expect(toolError, 'node should see allowlist-proxied tools, not raw ctx.tools').to.exist + expect(toolError?.name).to.equal('ToolAccessViolation') + }) + }) +}) diff --git a/test/unit/agent/curate-flow/sandbox/schema-gate.test.ts b/test/unit/agent/curate-flow/sandbox/schema-gate.test.ts new file mode 100644 index 000000000..81a027492 --- /dev/null +++ b/test/unit/agent/curate-flow/sandbox/schema-gate.test.ts @@ -0,0 +1,175 @@ +/** + * Phase 2 Task 2.2 — schema validation gate. + * + * Per Phase 2 plan §11 finding F5: hard-failing on every schema mismatch + * risks silent data loss while real-world LLM output variance is still + * being mapped against the static Zod shapes. The gate therefore supports + * both strict (throws `SchemaValidationError`) and soft (returns + * `{ok: false, issues}` without throwing) modes. Phase 2 uses soft mode + * end-to-end; Phase 3 promotes to strict once the schemas are battle-tested. + * + * What the tests assert: + * - strict mode: throws `SchemaValidationError` on input/output failure; + * `fn` is NOT invoked when input fails. + * - soft mode: returns a discriminated union without throwing. + * - happy path returns the fn's output (both modes). + * - Errors carry the slot name + zod issue paths. + */ + +import {expect} from 'chai' +import {z} from 'zod' + +import { + SchemaValidationError, + validateAndRun, +} from '../../../../../src/agent/core/curation/flow/sandbox/schema-gate.js' + +describe('validateAndRun (schema gate)', () => { + describe('strict mode (throws)', () => { + it('input matches → fn invoked, output validated, value returned', async () => { + const inputSchema = z.object({n: z.number()}) + const outputSchema = z.object({double: z.number()}) + + let invoked = false + const result = await validateAndRun({ + async fn(input) { + invoked = true + return {double: input.n * 2} + }, + input: {n: 5}, + inputSchema, + mode: 'strict', + outputSchema, + slot: 'extract', + }) + + expect(invoked).to.be.true + expect(result).to.deep.equal({ok: true, value: {double: 10}}) + }) + + it('input fails schema → throws SchemaValidationError BEFORE fn invocation', async () => { + const inputSchema = z.object({n: z.number()}) + const outputSchema = z.object({double: z.number()}) + + let invoked = false + let thrown: SchemaValidationError | undefined + + try { + await validateAndRun({ + async fn() { + invoked = true + return {double: 0} + }, + input: {n: 'not-a-number'}, + inputSchema, + mode: 'strict', + outputSchema, + slot: 'extract', + }) + } catch (error) { + thrown = error as SchemaValidationError + } + + expect(invoked, 'fn must NOT run when input is invalid').to.be.false + expect(thrown).to.be.instanceOf(SchemaValidationError) + expect(thrown?.slot).to.equal('extract') + expect(thrown?.phase).to.equal('input') + expect(thrown?.issues).to.have.length.greaterThan(0) + }) + + it('output fails schema → throws SchemaValidationError', async () => { + const inputSchema = z.object({n: z.number()}) + const outputSchema = z.object({double: z.number()}) + + let thrown: SchemaValidationError | undefined + try { + await validateAndRun({ + fn: async () => ({double: 'not-a-number'}) as unknown as {double: number}, + input: {n: 5}, + inputSchema, + mode: 'strict', + outputSchema, + slot: 'extract', + }) + } catch (error) { + thrown = error as SchemaValidationError + } + + expect(thrown).to.be.instanceOf(SchemaValidationError) + expect(thrown?.phase).to.equal('output') + }) + }) + + describe('soft mode (returns discriminated union)', () => { + it('input matches → returns ok=true with output', async () => { + const inputSchema = z.object({n: z.number()}) + const outputSchema = z.object({double: z.number()}) + + const result = await validateAndRun({ + fn: async (input) => ({double: input.n * 2}), + input: {n: 3}, + inputSchema, + mode: 'soft', + outputSchema, + slot: 'extract', + }) + + expect(result).to.deep.equal({ok: true, value: {double: 6}}) + }) + + it('input fails schema → returns ok=false with issues, fn NOT invoked', async () => { + const inputSchema = z.object({n: z.number()}) + const outputSchema = z.object({double: z.number()}) + + let invoked = false + const result = await validateAndRun({ + async fn() { + invoked = true + return {double: 0} + }, + input: {n: 'bad'}, + inputSchema, + mode: 'soft', + outputSchema, + slot: 'extract', + }) + + expect(invoked).to.be.false + expect(result.ok).to.be.false + if (!result.ok) { + expect(result.phase).to.equal('input') + expect(result.issues).to.have.length.greaterThan(0) + } + }) + + it('output fails schema → returns ok=false with issues + fn output for fallback use', async () => { + const inputSchema = z.object({n: z.number()}) + const outputSchema = z.object({double: z.number()}) + + const result = await validateAndRun({ + fn: async () => ({double: 'oops'}) as unknown as {double: number}, + input: {n: 1}, + inputSchema, + mode: 'soft', + outputSchema, + slot: 'extract', + }) + + expect(result.ok).to.be.false + if (!result.ok) { + expect(result.phase).to.equal('output') + expect(result.rawOutput).to.deep.equal({double: 'oops'}) + } + }) + }) + + describe('error metadata', () => { + it('SchemaValidationError carries zod issue paths', () => { + const err = new SchemaValidationError('extract', 'input', [ + {message: 'expected number', path: ['n']}, + ]) + expect(err.issues[0].path).to.deep.equal(['n']) + expect(err.message).to.include('expected number') + }) + }) +}) diff --git a/test/unit/agent/curate-flow/sandbox/slot-sandbox-builder.test.ts b/test/unit/agent/curate-flow/sandbox/slot-sandbox-builder.test.ts new file mode 100644 index 000000000..cfc645a1e --- /dev/null +++ b/test/unit/agent/curate-flow/sandbox/slot-sandbox-builder.test.ts @@ -0,0 +1,237 @@ +/** + * Phase 2 Task 2.1 — slot sandbox builder. + * + * Per Phase 2 plan §11 finding F2: Phase 2 uses plain JS wrappers (no + * `node:vm`) for the sandbox boundary. The interface stays identical + * to the eventual VM-based variant so Phase 3 can swap implementations + * when actual untrusted agent code starts running inside. + * + * What this test asserts: + * - `buildSlotSandbox(slot, tools)` returns a handle with `runInSlot`. + * - `tools.*` accessor only exposes keys in `slotContracts[slot].toolAllowlist`. + * - Calling an outside-allowlist tool throws `ToolAccessViolation`. + * - `runInSlot` returns the function's resolved value when it completes + * within `slotContracts[slot].timeoutMs`. + * - A function exceeding the timeout is aborted via `AbortController` and + * `runInSlot` throws `NodeTimeoutError`. + * - The per-slot AbortSignal is exposed to the function so it can opt + * into early cancellation. + */ + +import {expect} from 'chai' + +import { + buildSlotSandbox, + NodeTimeoutError, + ToolAccessViolation, +} from '../../../../../src/agent/core/curation/flow/sandbox/slot-sandbox-builder.js' +import {delay} from '../../../../helpers/delay.js' + +// Minimal stub tools mirror the real ToolsSDK shape: a nested object with +// the dotted keys from `toolAllowlist`. +function makeStubTools(): Record { + return { + curate: () => 'curate-result', + curation: { + conflict: () => 'conflict-result', + mapExtract: () => 'extract-result', + recon: () => 'recon-result', + }, + } +} + +describe('buildSlotSandbox', () => { + describe('tool allowlist enforcement', () => { + it('exposes ONLY tools in the slot allowlist (extract → mapExtract only)', async () => { + const sandbox = buildSlotSandbox('extract', makeStubTools()) + + const result = await sandbox.runInSlot(async ({tools}) => { + // mapExtract is in extract's allowlist (`tools.curation.mapExtract`) + const extracted = (tools as {curation: {mapExtract: () => string}}).curation.mapExtract() + return extracted + }) + + expect(result).to.equal('extract-result') + }) + + it('throws ToolAccessViolation when accessing a tool outside the allowlist', async () => { + const sandbox = buildSlotSandbox('extract', makeStubTools()) + + let thrown: Error | undefined + try { + await sandbox.runInSlot(async ({tools}) => { + // tools.curate is in `write` allowlist, NOT `extract`. + ;(tools as {curate: () => string}).curate() + return 'should-not-reach' + }) + } catch (error) { + thrown = error as Error + } + + expect(thrown).to.be.instanceOf(ToolAccessViolation) + expect(thrown?.message).to.match(/extract|allowlist|curate/i) + }) + + it('write slot exposes tools.curate (allowlist match)', async () => { + const sandbox = buildSlotSandbox('write', makeStubTools()) + + const result = await sandbox.runInSlot(async ({tools}) => (tools as {curate: () => string}).curate()) + + expect(result).to.equal('curate-result') + }) + + it('pure-JS slot (chunk, dedup, group) has empty tools — any access throws', async () => { + const sandbox = buildSlotSandbox('chunk', makeStubTools()) + + let thrown: Error | undefined + try { + await sandbox.runInSlot(async ({tools}) => { + ;(tools as {curate: () => string}).curate() + return 'nope' + }) + } catch (error) { + thrown = error as Error + } + + expect(thrown).to.be.instanceOf(ToolAccessViolation) + }) + }) + + describe('per-slot timeout enforcement', () => { + it('returns the function value when it completes within timeoutMs', async () => { + // chunk timeoutMs = 5000; quick fn finishes well within that. + const sandbox = buildSlotSandbox('chunk', makeStubTools()) + const result = await sandbox.runInSlot(async () => 'ok') + expect(result).to.equal('ok') + }) + + it('aborts and throws NodeTimeoutError when fn exceeds timeoutMs', async () => { + // Use a shorter override so the test runs fast. Slot itself stays 'chunk'. + const sandbox = buildSlotSandbox('chunk', makeStubTools(), {timeoutMsOverride: 50}) + + let thrown: Error | undefined + try { + await sandbox.runInSlot(async ({signal}) => { + await delay(500, signal) + return 'should-not-reach' + }) + } catch (error) { + thrown = error as Error + } + + expect(thrown).to.be.instanceOf(NodeTimeoutError) + expect(thrown?.message).to.match(/chunk|timeout|50/i) + }) + + it('exposes signal to the function for cooperative cancellation', async () => { + const sandbox = buildSlotSandbox('chunk', makeStubTools(), {timeoutMsOverride: 30}) + + let signalSeen: AbortSignal | undefined + try { + await sandbox.runInSlot(async ({signal}) => { + signalSeen = signal + await delay(500, signal) + }) + } catch { + // expected timeout + } + + expect(signalSeen).to.exist + expect(signalSeen?.aborted).to.be.true + }) + }) + + describe('known limit — sync-code escapes timeout (Phase 2 design gap)', () => { + // This test PINS the documented limit so future readers see it's + // intentional. The Phase 2 sandbox is plain JS (not `node:vm`), so + // setTimeout + Promise.race cannot preempt synchronous code that + // hogs the event loop. Phase 3 swaps to `vm.runInContext({timeout})` + // which interrupts mid-instruction; that's also when untrusted + // agent-supplied JS first runs inside the sandbox, so the gap + // closes exactly when it starts mattering. + // + // See slot-sandbox-builder.ts "Known limit" docstring + plan §11 + // finding F2 + PHASE-2-CODE-REVIEW E2/P1. + it('a sync busy loop runs to completion past timeoutMs (NOT aborted)', async () => { + const sandbox = buildSlotSandbox('chunk', {}, {timeoutMsOverride: 10}) + + const result = await sandbox.runInSlot(async () => { + // Sync busy loop: blocks the event loop, so the abort timer + // (scheduled for +10ms) cannot fire until this returns. + const end = Date.now() + 60 + while (Date.now() < end) { + // intentional busy-wait + } + + return 'sync-completed' + }) + + // The fn resolves normally; NodeTimeoutError is NOT thrown. + // This is the documented Phase 2 limitation — Phase 3 fixes it. + expect(result).to.equal('sync-completed') + }) + }) + + describe('linkedSignal composition', () => { + it('aborts when an externally-provided parent signal aborts (before timeout)', async () => { + const parent = new AbortController() + const sandbox = buildSlotSandbox('chunk', makeStubTools(), { + parentSignal: parent.signal, + timeoutMsOverride: 5000, + }) + + // Abort the parent shortly after starting. + setTimeout(() => parent.abort(), 30) + + let thrown: Error | undefined + try { + await sandbox.runInSlot(async ({signal}) => { + await delay(2000, signal) + return 'should-not-reach' + }) + } catch (error) { + thrown = error as Error + } + + // Parent abort propagates through; the slot signal observes it. + expect(thrown).to.exist + expect(thrown?.message).to.match(/abort|cancel|timeout/i) + }) + }) + + describe('errors carry slot identity', () => { + it('ToolAccessViolation includes the slot name', async () => { + const sandbox = buildSlotSandbox('dedup', makeStubTools()) + + let thrown: ToolAccessViolation | undefined + try { + await sandbox.runInSlot(async ({tools}) => { + ;(tools as {curate: () => string}).curate() + return '' + }) + } catch (error) { + thrown = error as ToolAccessViolation + } + + expect(thrown).to.exist + expect(thrown?.slot).to.equal('dedup') + }) + + it('NodeTimeoutError includes the slot name and effective timeout', async () => { + const sandbox = buildSlotSandbox('chunk', makeStubTools(), {timeoutMsOverride: 25}) + + let thrown: NodeTimeoutError | undefined + try { + await sandbox.runInSlot(async ({signal}) => { + await delay(500, signal) + }) + } catch (error) { + thrown = error as NodeTimeoutError + } + + expect(thrown).to.exist + expect(thrown?.slot).to.equal('chunk') + expect(thrown?.timeoutMs).to.equal(25) + }) + }) +}) diff --git a/test/unit/agent/curate-flow/services-adapter.test.ts b/test/unit/agent/curate-flow/services-adapter.test.ts new file mode 100644 index 000000000..9251d51d7 --- /dev/null +++ b/test/unit/agent/curate-flow/services-adapter.test.ts @@ -0,0 +1,132 @@ +/** + * Phase 2.5 R-3 — buildReason envelope (PHASE-2.5-PLAN.md §3.4). + * + * Asserts the Phase 3 UAT-target Reason shape: + * Curated from cur- on via :""; subject "" [] inferred from "". + * + * Critical contract: the envelope MUST always be emitted, even when the + * conflict-decision provides its own `d.reason` (UPDATE case). The + * pre-fix bypass (`if (d.reason) return d.reason`) silently dropped + * provenance for UPDATE rows — exactly the rows where audit trail + * matters most. See PHASE-2.5-PLAN review P2. + */ + +import {expect} from 'chai' + +import {buildReason} from '../../../../src/agent/infra/curation/flow/services-adapter.js' + +const PROVENANCE_TEXT = {name: 'cli-text', type: 'text' as const} +const PROVENANCE_FILE = {name: 'package.json', type: 'file' as const} + +describe('buildReason (R-3 provenance envelope)', () => { + describe('ADD decisions (no d.reason)', () => { + it('emits envelope-only when d.reason is undefined', () => { + const reason = buildReason( + {action: 'add', fact: {category: 'project', statement: 'JWT tokens expire after 24 hours', subject: 'jwt_expiry'}}, + 'task-uuid-1', + 'cur-1777347876578', + PROVENANCE_TEXT, + ) + expect(reason).to.include('cur-1777347876578') + expect(reason).to.include('text:"cli-text"') + expect(reason).to.include('"jwt_expiry"') + expect(reason).to.include('[project]') + expect(reason).to.include('JWT tokens expire after 24 hours') + expect(reason, 'no Decision: appendix when d.reason absent').to.not.include('Decision:') + }) + + it('uses cur- as the correlation id when present (preferred over taskId)', () => { + const reason = buildReason( + {action: 'add', fact: {statement: 's', subject: 'auth'}}, + 'task-uuid-fallback', + 'cur-1777347876578', + PROVENANCE_TEXT, + ) + expect(reason).to.include('cur-1777347876578') + expect(reason, 'taskId UUID must NOT appear when logId is present').to.not.include('task-uuid-fallback') + }) + + it('falls back to taskId when logId is undefined (test fixtures, non-router paths)', () => { + const reason = buildReason( + {action: 'add', fact: {statement: 's', subject: 'auth'}}, + 'task-uuid-fallback', + undefined, + PROVENANCE_TEXT, + ) + expect(reason).to.include('task-uuid-fallback') + }) + + it('handles missing subject and category with sensible placeholders', () => { + const reason = buildReason( + {action: 'add', fact: {statement: 'some content'}}, + 'task-1', + undefined, + PROVENANCE_TEXT, + ) + expect(reason).to.include('') + expect(reason).to.include('[uncategorized]') + }) + + it('uses provenance.type=file when source is a file', () => { + const reason = buildReason( + {action: 'add', fact: {statement: 's', subject: 'a'}}, + 'task-1', + undefined, + PROVENANCE_FILE, + ) + expect(reason).to.include('file:"package.json"') + }) + + it('truncates long statements at 80 chars in the input quote', () => { + const longStatement = 'a'.repeat(200) + const reason = buildReason( + {action: 'add', fact: {statement: longStatement, subject: 'a'}}, + 'task-1', + undefined, + PROVENANCE_TEXT, + ) + // input quote should be at most 80 chars between the surrounding quotes + const quoteMatch = reason.match(/inferred from "([^"]+)"/) + expect(quoteMatch).to.exist + expect(quoteMatch![1].length).to.be.at.most(80) + }) + }) + + describe('UPDATE decisions (d.reason present — appended INSIDE envelope, NOT bypassing it)', () => { + it('emits envelope AND appends d.reason as Decision: ... (P2 fix)', () => { + const reason = buildReason( + { + action: 'update', + fact: {category: 'project', statement: 'JWT tokens stored in httpOnly cookies', subject: 'jwt_storage'}, + reason: 'subject "jwt_storage" already present at project/jwt_token_expiry/jwt_tokens_expire_after_24_hours.md', + }, + 'task-uuid', + 'cur-1777347889041', + PROVENANCE_TEXT, + ) + + // Envelope must still be present (this was the P2 bug: pre-fix UPDATE + // returned d.reason directly, omitting the envelope entirely). + expect(reason, 'envelope must be present for UPDATE').to.include('cur-1777347889041') + expect(reason, 'envelope must be present for UPDATE').to.include('"jwt_storage"') + expect(reason, 'envelope must be present for UPDATE').to.include('[project]') + + // d.reason must be appended as Decision: ... + expect(reason).to.include('Decision: subject "jwt_storage" already present at') + }) + + it('orders envelope FIRST, then Decision rationale', () => { + const reason = buildReason( + {action: 'update', fact: {category: 'project', statement: 's', subject: 'a'}, reason: 'rationale-text'}, + 'task-1', + 'cur-1', + PROVENANCE_TEXT, + ) + const envelopeIdx = reason.indexOf('cur-1') + const decisionIdx = reason.indexOf('Decision:') + expect(envelopeIdx).to.be.greaterThan(-1) + expect(decisionIdx).to.be.greaterThan(-1) + expect(envelopeIdx, 'envelope first').to.be.lessThan(decisionIdx) + }) + }) +}) diff --git a/test/unit/agent/curate-flow/slots/contracts.test.ts b/test/unit/agent/curate-flow/slots/contracts.test.ts new file mode 100644 index 000000000..6e2f4c661 --- /dev/null +++ b/test/unit/agent/curate-flow/slots/contracts.test.ts @@ -0,0 +1,238 @@ +import {expect} from 'chai' + +import type {NodeSlot} from '../../../../../src/agent/core/curation/flow/types.js' + +import {slotContracts} from '../../../../../src/agent/core/curation/flow/slots/contracts.js' +import {NODE_SLOT_ORDER} from '../../../../../src/agent/core/curation/flow/types.js' + +describe('slotContracts', () => { + describe('completeness', () => { + it('defines a contract for every NodeSlot', () => { + for (const slot of NODE_SLOT_ORDER) { + expect(slotContracts[slot], `missing contract for slot "${slot}"`).to.exist + } + }) + + it('every contract has inputSchema, outputSchema, toolAllowlist, timeoutMs', () => { + for (const slot of NODE_SLOT_ORDER) { + const contract = slotContracts[slot] + expect(contract.inputSchema, `${slot}.inputSchema`).to.exist + expect(contract.outputSchema, `${slot}.outputSchema`).to.exist + expect(contract.toolAllowlist, `${slot}.toolAllowlist`).to.be.an('array') + expect(contract.timeoutMs, `${slot}.timeoutMs`).to.be.a('number').greaterThan(0) + } + }) + }) + + describe('schema round-trips', () => { + // Slot inputs now MIRROR the predecessor slot's output shape (so slots + // chain cleanly through the runner). recon's input is the user's + // initial input; the rest each take the preceding slot's output. + const reconOutFixture = { + headPreview: 'sample', + history: {domains: {}, totalProcessed: 0}, + meta: {charCount: 11, lineCount: 1, messageCount: 0}, + suggestedChunkCount: 1, + suggestedMode: 'single-pass' as const, + tailPreview: 'text', + } + + const chunkOutFixture = { + boundaries: [{end: 11, start: 0}], + chunks: ['sample text'], + totalChunks: 1, + } + + const extractOutFixture = { + facts: [{statement: 'JWT expires in 24h', subject: 'auth'}], + failed: 0, + succeeded: 1, + total: 1, + } + + const groupOutFixture = { + grouped: {auth: [{statement: 'JWT expires', subject: 'auth'}]}, + } + + const dedupOutFixture = { + deduped: [{statement: 'JWT expires', subject: 'auth'}], + } + + const conflictOutFixture = { + decisions: [ + {action: 'add' as const, fact: {statement: 'JWT expires in 24h', subject: 'auth'}}, + ], + } + + const writeOutFixture = { + applied: [], + summary: {added: 0, deleted: 0, failed: 0, merged: 0, updated: 0}, + } + + const fixtures: Record = { + chunk: {input: reconOutFixture, output: chunkOutFixture}, + conflict: {input: dedupOutFixture, output: conflictOutFixture}, + dedup: {input: groupOutFixture, output: dedupOutFixture}, + extract: {input: chunkOutFixture, output: extractOutFixture}, + group: {input: extractOutFixture, output: groupOutFixture}, + recon: { + input: {context: 'sample text', history: {}, meta: {}}, + output: reconOutFixture, + }, + write: { + input: { + decisions: [ + {action: 'add', fact: {statement: 'JWT expires', subject: 'auth'}}, + ], + }, + output: writeOutFixture, + }, + } + + for (const slot of NODE_SLOT_ORDER) { + it(`${slot}: inputSchema accepts valid fixture`, () => { + const result = slotContracts[slot].inputSchema.safeParse(fixtures[slot].input) + if (!result.success) { + throw new Error(`${slot} input parse failed: ${JSON.stringify(result.error.issues, null, 2)}`) + } + }) + + it(`${slot}: outputSchema accepts valid fixture`, () => { + const result = slotContracts[slot].outputSchema.safeParse(fixtures[slot].output) + if (!result.success) { + throw new Error(`${slot} output parse failed: ${JSON.stringify(result.error.issues, null, 2)}`) + } + }) + } + }) + + describe('schema rejects invalid input', () => { + it('extract.outputSchema rejects facts missing the statement field', () => { + const result = slotContracts.extract.outputSchema.safeParse({ + facts: [{subject: 'auth'}], + }) + expect(result.success).to.be.false + if (!result.success) { + const paths = result.error.issues.map((issue: {path: ReadonlyArray}) => + issue.path.join('.'), + ) + expect(paths.some((p: string) => p.includes('statement'))).to.be.true + } + }) + + it('recon.outputSchema rejects unknown suggestedMode value', () => { + const result = slotContracts.recon.outputSchema.safeParse({ + headPreview: '', + history: {domains: {}, totalProcessed: 0}, + meta: {charCount: 0, lineCount: 0, messageCount: 0}, + suggestedChunkCount: 1, + suggestedMode: 'whatever', + tailPreview: '', + }) + expect(result.success).to.be.false + }) + + it('write.outputSchema rejects non-array applied field', () => { + const result = slotContracts.write.outputSchema.safeParse({ + applied: 'not-an-array', + summary: {added: 0, deleted: 0, failed: 0, merged: 0, updated: 0}, + }) + expect(result.success).to.be.false + }) + + it('write.outputSchema rejects missing summary field', () => { + const result = slotContracts.write.outputSchema.safeParse({ + applied: [], + }) + expect(result.success).to.be.false + }) + + it('extract.outputSchema requires succeeded/failed/total counts', () => { + const result = slotContracts.extract.outputSchema.safeParse({ + facts: [], + }) + expect(result.success).to.be.false + }) + + it('write.outputSchema accepts a realistic OperationResult shape', () => { + // Mirrors what executeCurate() actually returns + // (curate-tool.ts:403 OperationResult). + const result = slotContracts.write.outputSchema.safeParse({ + applied: [ + { + confidence: 'high', + filePath: '/abs/path/to/file.md', + impact: 'low', + message: 'Created', + needsReview: false, + path: 'auth/jwt.md', + reason: 'Documenting JWT expiry', + status: 'success', + summary: 'JWT tokens expire in 24h', + type: 'ADD', + }, + ], + summary: {added: 1, deleted: 0, failed: 0, merged: 0, updated: 0}, + }) + if (!result.success) { + throw new Error(`expected success, got: ${JSON.stringify(result.error.issues, null, 2)}`) + } + }) + + it('write.outputSchema rejects status values other than success/failed', () => { + const result = slotContracts.write.outputSchema.safeParse({ + applied: [ + { + confidence: 'high', + impact: 'low', + needsReview: false, + path: 'auth/jwt.md', + reason: 'x', + status: 'pending', // not a real status + type: 'ADD', + }, + ], + summary: {added: 0, deleted: 0, failed: 0, merged: 0, updated: 0}, + }) + expect(result.success).to.be.false + }) + + it('write.outputSchema rejects applied op missing required confidence/impact/needsReview/reason', () => { + const result = slotContracts.write.outputSchema.safeParse({ + applied: [ + { + path: 'auth/jwt.md', + status: 'success', + type: 'ADD', + }, + ], + summary: {added: 1, deleted: 0, failed: 0, merged: 0, updated: 0}, + }) + expect(result.success).to.be.false + if (!result.success) { + const missing = new Set( + result.error.issues.map((i: {path: ReadonlyArray}) => i.path.join('.')), + ) + expect(missing.has('applied.0.confidence')).to.be.true + expect(missing.has('applied.0.impact')).to.be.true + expect(missing.has('applied.0.needsReview')).to.be.true + expect(missing.has('applied.0.reason')).to.be.true + } + }) + }) + + describe('tool allowlists', () => { + it('pure-JS slots (chunk, group, dedup) have empty allowlist', () => { + expect(slotContracts.chunk.toolAllowlist).to.deep.equal([]) + expect(slotContracts.group.toolAllowlist).to.deep.equal([]) + }) + + it('extract slot allows tools.curation.mapExtract', () => { + expect(slotContracts.extract.toolAllowlist).to.include('tools.curation.mapExtract') + }) + + it('write slot allows tools.curate', () => { + expect(slotContracts.write.toolAllowlist).to.include('tools.curate') + }) + }) +}) diff --git a/test/unit/agent/knowledge/conflict-detector.test.ts b/test/unit/agent/knowledge/conflict-detector.test.ts index 06c0ccafa..695db6623 100644 --- a/test/unit/agent/knowledge/conflict-detector.test.ts +++ b/test/unit/agent/knowledge/conflict-detector.test.ts @@ -165,16 +165,96 @@ describe('conflict-detector', () => { expect(loss.hasLoss).to.be.false }) }) + + // R-1 hotfix (PHASE-2-UAT.md §5.2): facts/keywords/tags must be tracked + // by the detector so the resolver can merge them back. Without this, + // executeUpdate silently overwrites existing facts when a per-fact UPDATE + // op carries only the new fact (Scenario 4 fact-loss bug). + + describe('facts', () => { + it('should detect lost facts when proposed drops an existing fact statement', () => { + const existing = makeContext({ + facts: [ + {statement: 'JWT tokens expire after 24 hours', subject: 'auth'}, + {statement: 'Rate limit is 100/min per IP', subject: 'rate-limit'}, + ], + }) + const proposed = makeContext({ + facts: [{statement: 'JWT tokens expire after 24 hours', subject: 'auth'}], + }) + + const loss = detectStructuralLoss(existing, proposed) + + expect(loss.hasLoss).to.be.true + expect(loss.lostFacts).to.equal(1) + }) + + it('should use case-insensitive matching for fact statements', () => { + const existing = makeContext({ + facts: [{statement: 'JWT Tokens Expire After 24 Hours'}], + }) + const proposed = makeContext({ + facts: [{statement: 'jwt tokens expire after 24 hours'}], + }) + + const loss = detectStructuralLoss(existing, proposed) + + expect(loss.lostFacts).to.equal(0) + }) + + it('should not flag facts that are present in proposed', () => { + const existing = makeContext({ + facts: [{statement: 'Auth uses JWT', subject: 'auth'}], + }) + const proposed = makeContext({ + facts: [ + {statement: 'Auth uses JWT', subject: 'auth'}, + {statement: 'New fact', subject: 'other'}, + ], + }) + + const loss = detectStructuralLoss(existing, proposed) + + expect(loss.lostFacts).to.equal(0) + }) + }) + + describe('keywords', () => { + it('should detect lost keywords', () => { + const existing = makeContext({keywords: ['jwt', 'auth', 'token']}) + const proposed = makeContext({keywords: ['jwt']}) + + const loss = detectStructuralLoss(existing, proposed) + + expect(loss.hasLoss).to.be.true + expect(loss.lostKeywords).to.equal(2) + }) + }) + + describe('tags', () => { + it('should detect lost tags', () => { + const existing = makeContext({tags: ['security', 'authentication']}) + const proposed = makeContext({tags: ['security']}) + + const loss = detectStructuralLoss(existing, proposed) + + expect(loss.hasLoss).to.be.true + expect(loss.lostTags).to.equal(1) + }) + }) }) describe('deriveImpactFromLoss', () => { const noLoss: StructuralLoss = { hasLoss: false, lostArrayItems: 0, + lostFacts: 0, + lostKeywords: 0, lostNarrativeFields: 0, lostRawConceptFields: 0, lostRelations: 0, lostSnippets: 0, + lostTags: 0, } it('should return "low" when no loss detected', () => { @@ -210,12 +290,30 @@ describe('conflict-detector', () => { const loss: StructuralLoss = { hasLoss: true, lostArrayItems: 3, + lostFacts: 0, + lostKeywords: 0, lostNarrativeFields: 2, lostRawConceptFields: 1, lostRelations: 2, lostSnippets: 1, + lostTags: 0, } expect(deriveImpactFromLoss(loss)).to.equal('high') }) + + it('should return "high" when facts are lost', () => { + const loss: StructuralLoss = {...noLoss, hasLoss: true, lostFacts: 1} + expect(deriveImpactFromLoss(loss)).to.equal('high') + }) + + it('should return "high" when keywords are lost', () => { + const loss: StructuralLoss = {...noLoss, hasLoss: true, lostKeywords: 2} + expect(deriveImpactFromLoss(loss)).to.equal('high') + }) + + it('should return "high" when tags are lost', () => { + const loss: StructuralLoss = {...noLoss, hasLoss: true, lostTags: 1} + expect(deriveImpactFromLoss(loss)).to.equal('high') + }) }) }) diff --git a/test/unit/agent/knowledge/conflict-resolver.test.ts b/test/unit/agent/knowledge/conflict-resolver.test.ts index 92ebfa6c2..84c1574c9 100644 --- a/test/unit/agent/knowledge/conflict-resolver.test.ts +++ b/test/unit/agent/knowledge/conflict-resolver.test.ts @@ -18,19 +18,25 @@ function makeContext(overrides: Partial = {}): ContextData { const noLoss: StructuralLoss = { hasLoss: false, lostArrayItems: 0, + lostFacts: 0, + lostKeywords: 0, lostNarrativeFields: 0, lostRawConceptFields: 0, lostRelations: 0, lostSnippets: 0, + lostTags: 0, } const hasLoss: StructuralLoss = { hasLoss: true, lostArrayItems: 0, + lostFacts: 0, + lostKeywords: 0, lostNarrativeFields: 0, lostRawConceptFields: 0, lostRelations: 0, lostSnippets: 1, + lostTags: 0, } describe('conflict-resolver', () => { @@ -158,15 +164,99 @@ describe('conflict-resolver', () => { }) describe('non-conflict fields pass through unchanged', () => { - it('should keep proposed name, keywords, tags', () => { + // Post-R-1 hotfix (PHASE-2-UAT.md §5.3): when hasLoss is true, + // keywords and tags are now union-merged (existing first, then + // new-only items appended). `name` still passes through from + // proposed since it's a scalar identity field, not array content. + it('should keep proposed name and union-merge keywords + tags', () => { const existing = makeContext({keywords: ['old-kw'], name: 'old name', tags: ['old-tag']}) const proposed = makeContext({keywords: ['new-kw'], name: 'new name', tags: ['new-tag']}) const result = resolveStructuralLoss(existing, proposed, hasLoss) expect(result.name).to.equal('new name') - expect(result.keywords).to.deep.equal(['new-kw']) - expect(result.tags).to.deep.equal(['new-tag']) + expect(result.keywords).to.include('old-kw') + expect(result.keywords).to.include('new-kw') + expect(result.tags).to.include('old-tag') + expect(result.tags).to.include('new-tag') + }) + }) + + // R-1 hotfix (PHASE-2-UAT.md §5.3): facts/keywords/tags must be merged + // back when loss is detected, otherwise UPDATE silently overwrites + // existing facts (Scenario 4 fact-loss bug). + + describe('facts', () => { + it('should merge lost facts back into proposed', () => { + const existing = makeContext({ + facts: [ + {statement: 'JWT tokens expire after 24 hours', subject: 'jwt_expiry'}, + ], + }) + const proposed = makeContext({ + facts: [ + {statement: 'JWT tokens use SameSite=Strict', subject: 'jwt_samesite'}, + ], + }) + + const result = resolveStructuralLoss(existing, proposed, hasLoss) + + const statements = result.facts?.map((f) => f.statement) ?? [] + expect(statements).to.include('JWT tokens expire after 24 hours') + expect(statements).to.include('JWT tokens use SameSite=Strict') + }) + + it('should deduplicate facts by statement (existing wins for richer fields)', () => { + const existing = makeContext({ + facts: [{category: 'project', statement: 'Auth uses JWT', subject: 'auth'}], + }) + const proposed = makeContext({ + facts: [{statement: 'auth uses jwt'}], // same statement, less metadata + }) + + const result = resolveStructuralLoss(existing, proposed, hasLoss) + + expect(result.facts).to.have.length(1) + expect(result.facts?.[0].category).to.equal('project') // existing wins + expect(result.facts?.[0].subject).to.equal('auth') + }) + + it('should preserve existing fact order first then append new facts', () => { + const existing = makeContext({ + facts: [{statement: 'first'}, {statement: 'second'}], + }) + const proposed = makeContext({ + facts: [{statement: 'third'}], + }) + + const result = resolveStructuralLoss(existing, proposed, hasLoss) + + expect(result.facts?.[0].statement).to.equal('first') + expect(result.facts?.[1].statement).to.equal('second') + expect(result.facts?.[2].statement).to.equal('third') + }) + }) + + describe('keywords and tags merge', () => { + it('should union-merge keywords', () => { + const existing = makeContext({keywords: ['jwt', 'auth']}) + const proposed = makeContext({keywords: ['auth', 'security']}) + + const result = resolveStructuralLoss(existing, proposed, hasLoss) + + expect(result.keywords).to.include('jwt') + expect(result.keywords).to.include('auth') + expect(result.keywords).to.include('security') + }) + + it('should union-merge tags', () => { + const existing = makeContext({tags: ['security']}) + const proposed = makeContext({tags: ['authentication']}) + + const result = resolveStructuralLoss(existing, proposed, hasLoss) + + expect(result.tags).to.include('security') + expect(result.tags).to.include('authentication') }) }) }) diff --git a/test/unit/executor/pre-compaction-orphan-session.test.ts b/test/unit/executor/pre-compaction-orphan-session.test.ts new file mode 100644 index 000000000..842c4d82c --- /dev/null +++ b/test/unit/executor/pre-compaction-orphan-session.test.ts @@ -0,0 +1,167 @@ +/** + * Phase 1 Task 1.3 — orphan-session guard. + * + * The async pre-compaction hoist runs `compact()` and `createTaskSession()` + * in parallel. If `compact()` rejects after `createTaskSession()` has already + * resolved, we must delete the orphan session before propagating the error. + */ + +import {expect} from 'chai' +import {restore, stub} from 'sinon' + +import type {ICipherAgent} from '../../../src/agent/core/interfaces/i-cipher-agent.js' + +import {CurateExecutor} from '../../../src/server/infra/executor/curate-executor.js' +import {PreCompactionService} from '../../../src/server/infra/executor/pre-compaction/pre-compaction-service.js' + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +function buildAgent(): { + agent: ICipherAgent + createTaskSessionStub: ReturnType + deleteTaskSessionStub: ReturnType +} { + const createTaskSession = stub().resolves('orphaned-session-id') + const deleteTaskSession = stub().resolves() + + const agent = { + cancel: stub().resolves(false), + createTaskSession, + deleteSandboxVariable: stub(), + deleteSandboxVariableOnSession: stub(), + deleteSession: stub().resolves(true), + deleteTaskSession, + execute: stub().resolves(''), + executeOnSession: stub().resolves(''), + generate: stub().resolves({content: '', toolCalls: [], usage: {inputTokens: 0, outputTokens: 0}}), + getSessionMetadata: stub().resolves(), + getState: stub().returns({ + currentIteration: 0, + executionHistory: [], + executionState: 'idle', + toolCallsExecuted: 0, + }), + listPersistedSessions: stub().resolves([]), + reset: stub(), + setSandboxVariable: stub(), + setSandboxVariableOnSession: stub(), + start: stub().resolves(), + stream: stub().resolves({ + [Symbol.asyncIterator]: () => ({ + next: () => Promise.resolve({done: true, value: undefined}), + }), + }), + } as unknown as ICipherAgent + + return {agent, createTaskSessionStub: createTaskSession, deleteTaskSessionStub: deleteTaskSession} +} + +describe('CurateExecutor — orphan-session guard during hoisted pre-compaction', () => { + afterEach(() => { + restore() + }) + + it('deletes the orphan session when compact rejects after session creation succeeds', async () => { + // compact rejects after a delay so session has time to resolve first + stub(PreCompactionService.prototype, 'compact').callsFake(async () => { + await delay(20) + throw new Error('boom: compaction failed') + }) + + const {agent, createTaskSessionStub, deleteTaskSessionStub} = buildAgent() + const executor = new CurateExecutor() + + let thrown: Error | undefined + try { + await executor.executeWithAgent(agent, { + content: 'test', + taskId: 'task-orphan-1', + }) + } catch (error) { + thrown = error as Error + } + + expect(thrown, 'compaction error propagates').to.exist + expect(thrown?.message).to.include('boom') + + expect(createTaskSessionStub.called, 'createTaskSession was called').to.be.true + expect(deleteTaskSessionStub.called, 'orphan session was deleted').to.be.true + expect(deleteTaskSessionStub.firstCall.args[0]).to.equal('orphaned-session-id') + }) + + it('does not double-delete when both compact and createTaskSession succeed', async () => { + stub(PreCompactionService.prototype, 'compact').callsFake(async (_agent, context) => ({ + context, + originalCharCount: context.length, + preCompacted: false, + })) + + const {agent, deleteTaskSessionStub} = buildAgent() + const executor = new CurateExecutor() + + await executor.executeWithAgent(agent, { + content: 'test', + taskId: 'task-orphan-2', + }) + + // Normal happy-path: deleteTaskSession is called exactly once in the `finally` block + expect(deleteTaskSessionStub.callCount).to.equal(1) + }) + + it('propagates session-creation errors when both arms fail', async () => { + stub(PreCompactionService.prototype, 'compact').callsFake(async () => { + throw new Error('compact-error') + }) + + const createTaskSession = stub().rejects(new Error('session-error')) + const deleteTaskSession = stub().resolves() + const agent = { + cancel: stub().resolves(false), + createTaskSession, + deleteSandboxVariable: stub(), + deleteSandboxVariableOnSession: stub(), + deleteSession: stub().resolves(true), + deleteTaskSession, + execute: stub().resolves(''), + executeOnSession: stub().resolves(''), + generate: stub().resolves({content: '', toolCalls: [], usage: {inputTokens: 0, outputTokens: 0}}), + getSessionMetadata: stub().resolves(), + getState: stub().returns({ + currentIteration: 0, + executionHistory: [], + executionState: 'idle', + toolCallsExecuted: 0, + }), + listPersistedSessions: stub().resolves([]), + reset: stub(), + setSandboxVariable: stub(), + setSandboxVariableOnSession: stub(), + start: stub().resolves(), + stream: stub().resolves({ + [Symbol.asyncIterator]: () => ({ + next: () => Promise.resolve({done: true, value: undefined}), + }), + }), + } as unknown as ICipherAgent + + const executor = new CurateExecutor() + + let thrown: Error | undefined + try { + await executor.executeWithAgent(agent, { + content: 'test', + taskId: 'task-orphan-3', + }) + } catch (error) { + thrown = error as Error + } + + expect(thrown, 'an error propagates').to.exist + // No session was successfully created, so deleteTaskSession should NOT be called + expect(deleteTaskSession.called, 'no orphan to delete').to.be.false + }) +}) diff --git a/test/unit/infra/dispatcher/query-dispatcher.test.ts b/test/unit/infra/dispatcher/query-dispatcher.test.ts new file mode 100644 index 000000000..8c587f73b --- /dev/null +++ b/test/unit/infra/dispatcher/query-dispatcher.test.ts @@ -0,0 +1,339 @@ +/** + * QueryDispatcher tests (Phase 5 Task 5.1). + * + * The dispatcher owns tier 0/1/2 logic extracted from QueryExecutor so both + * brv_query (legacy CLI / MCP) and brv_search (new MCP) can consume the same + * deterministic, LLM-free path. + * + * Contract — discriminated union: + * tier 0 / 1 → status: 'cached_answer' (cachedAnswer field) + * tier 2 → status: 'direct_passages' (passages + directAnswer fields) + * | 'needs_synthesis' (passages field; agent synthesizes) + * | 'no_results' (empty passages) + * + * Critical invariant: dispatcher writes to cache ONLY on direct_passages. + * The legacy not-found cache write (formatNotFoundResponse) stays in the + * executor — it's a human-facing response shape, not the dispatcher's concern. + */ + +import {expect} from 'chai' +import {stub} from 'sinon' + +import type {IFileSystem} from '../../../../src/agent/core/interfaces/i-file-system.js' +import type {ISearchKnowledgeService, SearchKnowledgeResult} from '../../../../src/agent/infra/sandbox/tools-sdk.js' + +import {QueryDispatcher, toBrvSearchResult} from '../../../../src/server/infra/dispatcher/query-dispatcher.js' +import {QueryResultCache} from '../../../../src/server/infra/executor/query-result-cache.js' + +const FINGERPRINT = 'fp-test-001' +const QUERY = 'how does authentication work' + +function makeSearchService(result: SearchKnowledgeResult): ISearchKnowledgeService { + return {search: stub().resolves(result)} as unknown as ISearchKnowledgeService +} + +function makeFileSystem(content = '# Auth\n\nFull document content.'): IFileSystem { + return { + readFile: stub().resolves({content, encoding: 'utf8'}), + } as unknown as IFileSystem +} + +function makeResults(overrides: Partial[] = []): SearchKnowledgeResult { + const results = overrides.map((o, i) => ({ + excerpt: 'excerpt', + path: `topics/doc-${i}.md`, + score: 0.9, + title: `Doc ${i}`, + ...o, + })) + return {message: '', results, totalFound: results.length} +} + +describe('QueryDispatcher', () => { + describe('Tier 0: exact cache hit', () => { + it('returns tier 0 cached_answer when cache.get hits', async () => { + const cache = new QueryResultCache() + cache.set(QUERY, 'cached response', FINGERPRINT) + const dispatcher = new QueryDispatcher({cache}) + + const result = await dispatcher.dispatch({fingerprint: FINGERPRINT, query: QUERY}) + + expect(result.tier).to.equal(0) + expect(result.status).to.equal('cached_answer') + if (result.status !== 'cached_answer') throw new Error('narrowing') + expect(result.cachedAnswer).to.equal('cached response') + expect(result.totalFound).to.equal(0) + expect(result.timingMs).to.be.at.least(0) + }) + + it('skips Tier 0 when fingerprint missing (no key to look up)', async () => { + const cache = new QueryResultCache() + cache.set(QUERY, 'cached', FINGERPRINT) + const searchService = makeSearchService(makeResults([])) + const dispatcher = new QueryDispatcher({cache, searchService}) + + const result = await dispatcher.dispatch({query: QUERY}) + + expect(result.tier).to.equal(2) + expect(result.status).to.equal('no_results') + }) + + it('skips Tier 0 when no cache configured', async () => { + const searchService = makeSearchService(makeResults([])) + const dispatcher = new QueryDispatcher({searchService}) + + const result = await dispatcher.dispatch({fingerprint: FINGERPRINT, query: QUERY}) + + expect(result.tier).to.equal(2) + expect(result.status).to.equal('no_results') + }) + }) + + describe('Tier 1: fuzzy cache hit', () => { + it('returns tier 1 cached_answer when fuzzy match hits after exact miss', async () => { + const cache = new QueryResultCache() + cache.set('authentication security guide overview', 'fuzzy cached response', FINGERPRINT) + const dispatcher = new QueryDispatcher({cache}) + + // Different query, but high token overlap (Jaccard >= threshold) + const result = await dispatcher.dispatch({ + fingerprint: FINGERPRINT, + query: 'authentication security guide detailed', + }) + + expect(result.tier).to.equal(1) + expect(result.status).to.equal('cached_answer') + if (result.status !== 'cached_answer') throw new Error('narrowing') + expect(result.cachedAnswer).to.equal('fuzzy cached response') + }) + }) + + describe('Tier 2: BM25 search', () => { + it('returns no_results when BM25 returns empty', async () => { + const searchService = makeSearchService(makeResults([])) + const dispatcher = new QueryDispatcher({searchService}) + + const result = await dispatcher.dispatch({fingerprint: FINGERPRINT, query: QUERY}) + + expect(result.tier).to.equal(2) + expect(result.status).to.equal('no_results') + if (result.status !== 'no_results') throw new Error('narrowing') + expect(result.passages).to.deep.equal([]) + expect(result.totalFound).to.equal(0) + }) + + it('returns direct_passages when high-confidence direct response succeeds', async () => { + // Two results: one very high (0.95), second much lower (0.4) → dominant gap + const searchResult = makeResults([{score: 0.95}, {score: 0.4}]) + const searchService = makeSearchService(searchResult) + const fileSystem = makeFileSystem() + const dispatcher = new QueryDispatcher({fileSystem, searchService}) + + const result = await dispatcher.dispatch({fingerprint: FINGERPRINT, query: QUERY}) + + expect(result.tier).to.equal(2) + expect(result.status).to.equal('direct_passages') + if (result.status !== 'direct_passages') throw new Error('narrowing') + expect(result.directAnswer).to.be.a('string').and.not.empty + expect(result.passages).to.have.length(2) + expect(result.totalFound).to.equal(2) + }) + + it('returns needs_synthesis when BM25 has results but no direct response (low scores)', async () => { + // All results below direct-response score threshold (0.85) + const searchResult = makeResults([{score: 0.3}, {score: 0.25}, {score: 0.2}]) + const searchService = makeSearchService(searchResult) + const fileSystem = makeFileSystem() + const dispatcher = new QueryDispatcher({fileSystem, searchService}) + + const result = await dispatcher.dispatch({fingerprint: FINGERPRINT, query: QUERY}) + + expect(result.tier).to.equal(2) + expect(result.status).to.equal('needs_synthesis') + if (result.status !== 'needs_synthesis') throw new Error('narrowing') + expect(result.passages).to.have.length(3) + expect(result.totalFound).to.equal(3) + }) + + it('writes to cache on direct_passages', async () => { + const cache = new QueryResultCache() + const setSpy = stub(cache, 'set').callThrough() + const searchResult = makeResults([{score: 0.95}, {score: 0.4}]) + const dispatcher = new QueryDispatcher({ + cache, + fileSystem: makeFileSystem(), + searchService: makeSearchService(searchResult), + }) + + await dispatcher.dispatch({fingerprint: FINGERPRINT, query: QUERY}) + + expect(setSpy.calledOnce).to.equal(true) + expect(setSpy.firstCall.args[0]).to.equal(QUERY) + expect(setSpy.firstCall.args[2]).to.equal(FINGERPRINT) + }) + + it('does NOT write to cache on needs_synthesis', async () => { + const cache = new QueryResultCache() + const setSpy = stub(cache, 'set').callThrough() + const searchResult = makeResults([{score: 0.3}, {score: 0.2}, {score: 0.1}]) + const dispatcher = new QueryDispatcher({ + cache, + fileSystem: makeFileSystem(), + searchService: makeSearchService(searchResult), + }) + + await dispatcher.dispatch({fingerprint: FINGERPRINT, query: QUERY}) + + expect(setSpy.called).to.equal(false) + }) + + it('does NOT write to cache on no_results', async () => { + const cache = new QueryResultCache() + const setSpy = stub(cache, 'set').callThrough() + const dispatcher = new QueryDispatcher({ + cache, + searchService: makeSearchService(makeResults([])), + }) + + await dispatcher.dispatch({fingerprint: FINGERPRINT, query: QUERY}) + + expect(setSpy.called).to.equal(false) + }) + + it('returns no_results when searchService throws (graceful degradation)', async () => { + const searchService = {search: stub().rejects(new Error('search down'))} as unknown as ISearchKnowledgeService + const dispatcher = new QueryDispatcher({searchService}) + + const result = await dispatcher.dispatch({fingerprint: FINGERPRINT, query: QUERY}) + + expect(result.tier).to.equal(2) + expect(result.status).to.equal('no_results') + }) + + it('returns no_results when no searchService configured', async () => { + const dispatcher = new QueryDispatcher({}) + + const result = await dispatcher.dispatch({fingerprint: FINGERPRINT, query: QUERY}) + + expect(result.tier).to.equal(2) + expect(result.status).to.equal('no_results') + }) + }) + + describe('Passage shape', () => { + it('exposes only {path, excerpt, score} — strips internal SearchKnowledgeResult fields', async () => { + const searchResult = makeResults([{excerpt: 'foo', path: 'p.md', score: 0.5, title: 'Title'}]) + const dispatcher = new QueryDispatcher({ + fileSystem: makeFileSystem(), + searchService: makeSearchService(searchResult), + }) + + const result = await dispatcher.dispatch({fingerprint: FINGERPRINT, query: QUERY}) + + if (result.status !== 'needs_synthesis') throw new Error(`expected needs_synthesis, got ${result.status}`) + expect(result.passages).to.have.length(1) + const passage = result.passages[0] + expect(Object.keys(passage).sort()).to.deep.equal(['excerpt', 'path', 'score']) + expect(passage.path).to.equal('p.md') + expect(passage.excerpt).to.equal('foo') + expect(passage.score).to.equal(0.5) + }) + }) + + describe('Pre-computed searchPromise (parallel-search optimization)', () => { + it('uses caller-supplied searchPromise instead of calling searchService.search', async () => { + const searchService = {search: stub().rejects(new Error('should not be called'))} as unknown as ISearchKnowledgeService + const dispatcher = new QueryDispatcher({searchService}) + + const preComputed = Promise.resolve(makeResults([{score: 0.3}])) + const result = await dispatcher.dispatch({ + fingerprint: FINGERPRINT, + query: QUERY, + searchPromise: preComputed, + }) + + expect(result.tier).to.equal(2) + expect(result.status).to.equal('needs_synthesis') + // Stub's .called would have raised if dispatcher had called search() + // (the rejection would've propagated as no_results — assert needs_synthesis instead) + }) + }) + + + describe('toBrvSearchResult — public DTO mapping (PHASE-5-CODE-REVIEW.md F4)', () => { + it('maps cached_answer (tier 0/1) to snake_case + drops internal fields', async () => { + const cache = new QueryResultCache() + cache.set(QUERY, 'cached response', FINGERPRINT) + const dispatcher = new QueryDispatcher({cache}) + const dispatchResult = await dispatcher.dispatch({fingerprint: FINGERPRINT, query: QUERY}) + + const dto = toBrvSearchResult(dispatchResult) + + expect(dto.tier).to.equal(0) + expect(dto.status).to.equal('cached_answer') + expect(dto.cached_answer).to.equal('cached response') + expect(dto.fingerprint).to.equal(FINGERPRINT) + expect(dto).to.have.property('total_found', 0) + expect(dto).to.have.property('timing_ms').that.is.a('number') + // Public DTO must NOT contain internal camelCase fields + expect(dto).to.not.have.property('cachedAnswer') + expect(dto).to.not.have.property('totalFound') + expect(dto).to.not.have.property('timingMs') + expect(dto).to.not.have.property('searchResult') + }) + + it('maps direct_passages: passages exposed; internal searchResult and directAnswer dropped', async () => { + const dispatcher = new QueryDispatcher({ + fileSystem: makeFileSystem(), + searchService: makeSearchService(makeResults([{score: 0.95}, {score: 0.4}])), + }) + const dispatchResult = await dispatcher.dispatch({fingerprint: FINGERPRINT, query: QUERY}) + + const dto = toBrvSearchResult(dispatchResult) + + expect(dto.tier).to.equal(2) + expect(dto.status).to.equal('direct_passages') + expect(dto.passages).to.have.length(2) + // directAnswer was the legacy formatter output — NOT in DESIGN §6.1 + expect(dto).to.not.have.property('direct_answer') + expect(dto).to.not.have.property('directAnswer') + expect(dto).to.not.have.property('searchResult') + expect(dto.total_found).to.equal(2) + }) + + it('maps needs_synthesis: passages exposed; internal searchResult dropped', async () => { + const dispatcher = new QueryDispatcher({ + fileSystem: makeFileSystem(), + searchService: makeSearchService(makeResults([{score: 0.3}, {score: 0.2}, {score: 0.1}])), + }) + const dispatchResult = await dispatcher.dispatch({fingerprint: FINGERPRINT, query: QUERY}) + + const dto = toBrvSearchResult(dispatchResult) + + expect(dto.status).to.equal('needs_synthesis') + expect(dto.passages).to.have.length(3) + expect(dto).to.not.have.property('searchResult') + }) + + it('maps no_results: empty passages, no cached_answer', async () => { + const dispatcher = new QueryDispatcher({searchService: makeSearchService(makeResults([]))}) + const dispatchResult = await dispatcher.dispatch({fingerprint: FINGERPRINT, query: QUERY}) + + const dto = toBrvSearchResult(dispatchResult) + + expect(dto.status).to.equal('no_results') + expect(dto.passages).to.deep.equal([]) + expect(dto.cached_answer).to.be.undefined + }) + + it('omits fingerprint key when undefined (DESIGN §6.1 documents it as always-present, but graceful when caching disabled)', async () => { + const dispatcher = new QueryDispatcher({searchService: makeSearchService(makeResults([{score: 0.3}]))}) + const dispatchResult = await dispatcher.dispatch({query: QUERY}) + + const dto = toBrvSearchResult(dispatchResult) + + expect(dto.fingerprint).to.be.undefined + }) + }) + +}) diff --git a/test/unit/infra/executor/curate-executor.test.ts b/test/unit/infra/executor/curate-executor.test.ts index 95d718075..03304342b 100644 --- a/test/unit/infra/executor/curate-executor.test.ts +++ b/test/unit/infra/executor/curate-executor.test.ts @@ -1,11 +1,13 @@ /** * CurateExecutor regression tests * - * 1. Variable naming: UUID hyphens in sandbox variable names cause - * ReferenceError when LLM writes code using underscores. + * Session-leak fix: createTaskSession must not be called before + * processFileReferences, so a preprocessing failure cannot leak sessions. * - * 2. Session-leak fix: createTaskSession must not be called before - * processFileReferences, so a preprocessing failure cannot leak sessions. + * (UUID-variable-naming tests previously here moved to + * `test/unit/infra/sandbox/local-sandbox-uuid-variable-naming.test.ts` + * after the Phase 1 cutover — those test LocalSandbox directly, not the + * executor, and the executor no longer injects sandbox variables.) */ import {expect} from 'chai' @@ -13,8 +15,6 @@ import {restore, stub} from 'sinon' import type {ICipherAgent} from '../../../../src/agent/core/interfaces/i-cipher-agent.js' -import {LocalSandbox} from '../../../../src/agent/infra/sandbox/local-sandbox.js' - /** * Mock cipher agent used by the runAgentBody / finalize split tests. * Hoisted to module scope (consistent-function-scoping lint rule). @@ -53,56 +53,6 @@ describe('CurateExecutor (regression)', () => { restore() }) - describe('sandbox variable naming', () => { - // Typical UUID taskId with hyphens (as generated by crypto.randomUUID()) - const taskId = '8cd8e2d8-a7fc-4371-89ca-59460687c12d' - // What the LLM would write in code-exec (hyphens → underscores, valid JS identifier) - const llmGeneratedVarName = '__curate_meta_8cd8e2d8_a7fc_4371_89ca_59460687c12d' - - describe('bug: hyphenated taskId causes variable name mismatch', () => { - it('should fail with ReferenceError when variable stored with hyphens but accessed with underscores', async () => { - const sandbox = new LocalSandbox() - - // Old (buggy) behavior: store variable using raw taskId containing hyphens - const buggyMetaVar = `__curate_meta_${taskId}` // "..._8cd8e2d8-a7fc-..." - sandbox.updateContext({[buggyMetaVar]: {charCount: 100, lineCount: 5}}) - - // LLM writes underscores in code (hyphens are not valid JS identifier chars) - const result = await sandbox.execute(llmGeneratedVarName) - - // Variable is in sandbox as hyphen-version, but LLM looks up underscore-version → not found - expect(result.stderr).to.include('ReferenceError') - expect(result.stderr).to.include(llmGeneratedVarName) - }) - }) - - describe('fix: taskIdSafe with underscores eliminates mismatch', () => { - it('should succeed when variable stored with underscores matching LLM output', async () => { - const sandbox = new LocalSandbox() - - // Fixed behavior: replace hyphens before naming sandbox variables - const taskIdSafe = taskId.replaceAll('-', '_') - const fixedMetaVar = `__curate_meta_${taskIdSafe}` // all underscores - sandbox.updateContext({[fixedMetaVar]: {charCount: 100, lineCount: 5}}) - - // LLM writes underscores → now matches sandbox variable - const result = await sandbox.execute(`${llmGeneratedVarName}.charCount`) - - expect(result.stderr).to.equal('') - expect(result.returnValue).to.equal(100) - }) - - it('should correctly transform all UUID segments (4 hyphens replaced)', () => { - const taskIdSafe = taskId.replaceAll('-', '_') - - expect(taskIdSafe).to.not.include('-') - expect(taskIdSafe).to.equal('8cd8e2d8_a7fc_4371_89ca_59460687c12d') - - const metaVar = `__curate_meta_${taskIdSafe}` - expect(metaVar).to.equal(llmGeneratedVarName) - }) - }) - }) describe('session-leak fix', () => { it('should not call createTaskSession when processFileReferences throws', async () => { @@ -187,7 +137,11 @@ describe('CurateExecutor (regression)', () => { taskId: 'task-123', }) - expect(response).to.equal('curated') + // Post-cutover: response is the formatted DAG result, not the agent's + // raw text. The hardcoded `executeOnSession.resolves('curated')` is + // never read by the new path. We assert the lifecycle instead. + expect(response).to.include('Curate completed via typed-slot DAG') + expect(response).to.include('"summary"') expect(drainBackgroundWork.calledOnce).to.be.true expect(deleteTaskSession.calledOnceWithExactly('session-id')).to.be.true expect(drainBackgroundWork.calledBefore(deleteTaskSession)).to.be.true @@ -238,8 +192,10 @@ describe('CurateExecutor (regression)', () => { worktreeRoot: '/projects/monorepo/packages/api', }) - expect(result).to.equal('curation complete') - // Verify the agent was called (session lifecycle is correct) + // Post-cutover: response is the formatted DAG result. We assert the + // lifecycle (session created + cleaned up) rather than the agent's + // raw response, which the new path no longer threads through. + expect(result).to.include('Curate completed via typed-slot DAG') expect(createTaskSession.calledOnce).to.be.true expect(deleteTaskSession.calledOnce).to.be.true }) @@ -274,7 +230,8 @@ describe('CurateExecutor (regression)', () => { taskId: 'task-ws-2', }) - expect(result).to.equal('done') + // Post-cutover: response is the formatted DAG result. + expect(result).to.include('Curate completed via typed-slot DAG') }) }) @@ -337,71 +294,14 @@ describe('CurateExecutor (regression)', () => { }) }) - describe('pre-pipelined recon (ENG-2530)', () => { - it('injects __recon_result_ as a sandbox variable and surfaces it in the prompt', async () => { - const taskId = '7a2b9e10-cdef-4321-8765-0abcdef01234' - const taskIdSafe = taskId.replaceAll('-', '_') - const expectedReconVar = `__recon_result_${taskIdSafe}` - - const setSandboxVariableOnSession = stub() - const executeOnSession = stub().resolves('ok') - - const agent = { - cancel: stub().resolves(false), - createTaskSession: stub().resolves('session-id'), - deleteSandboxVariable: stub(), - deleteSandboxVariableOnSession: stub(), - deleteSession: stub().resolves(true), - deleteTaskSession: stub().resolves(), - execute: stub().resolves(''), - executeOnSession, - generate: stub().resolves({content: '', toolCalls: [], usage: {inputTokens: 0, outputTokens: 0}}), - getSessionMetadata: stub().resolves(), - getState: stub().returns({currentIteration: 0, executionHistory: [], executionState: 'idle', toolCallsExecuted: 0}), - listPersistedSessions: stub().resolves([]), - reset: stub(), - setSandboxVariable: stub(), - setSandboxVariableOnSession, - start: stub().resolves(), - stream: stub().resolves({[Symbol.asyncIterator]: () => ({next: () => Promise.resolve({done: true, value: undefined})})}), - } as unknown as ICipherAgent - - // Stub the post-curate filesystem services so the test stays - // fully in-memory (mirrors the ENG-2485 test above). Without - // these, executeWithAgent attempts real I/O against /projects/myapp. - stub(FileContextTreeSnapshotService.prototype, 'getCurrentState').resolves(new Map()) - stub(DreamStateService.prototype, 'incrementCurationCount').resolves() - - const executor = new CurateExecutor() - await executor.executeWithAgent(agent, { - clientCwd: '/projects/myapp', - content: 'plain text content for the curate to inspect', - taskId, - }) - - // (a) recon result was set on the task session under __recon_result_ - const reconCall = setSandboxVariableOnSession - .getCalls() - .find((c) => c.args[1] === expectedReconVar) - expect(reconCall, `no setSandboxVariableOnSession call with key ${expectedReconVar}`).to.not.equal(undefined) - - const reconValue = reconCall?.args[2] as Record | undefined - expect(reconValue).to.have.property('suggestedMode') - expect(reconValue).to.have.property('suggestedChunkCount') - expect(reconValue).to.have.property('meta') - const meta = reconValue?.meta as Record - expect(meta).to.have.property('charCount') - expect(meta).to.have.property('lineCount') - expect(meta).to.have.property('messageCount') - - // (b) prompt instructs the agent that recon is pre-computed and to skip the call - expect(executeOnSession.calledOnce).to.equal(true) - const promptArg = executeOnSession.firstCall.args[1] as string - expect(promptArg).to.include('Recon already computed in') - expect(promptArg).to.include(expectedReconVar) - expect(promptArg).to.include('Do NOT call tools.curation.recon') - }) - }) + // Note: PR #601 (ENG-2530) added a "pre-pipelined recon" describe block here + // that asserted the executor injects `__recon_result_` as a + // sandbox variable and surfaces it in the agent prompt. PR #578 replaced + // the agent loop entirely with the typed-slot DAG, so the executor no + // longer touches sandbox variables or builds an agent prompt. Recon now + // runs as the first node in the DAG (`recon-node.ts`); coverage for that + // lives in `test/unit/agent/curate-flow/dag-builder.test.ts` and the + // services-adapter integration tests. describe('runAgentBody / finalize split', () => { // runAgentBody must return the response BEFORE Phase 4 runs so the daemon @@ -411,6 +311,9 @@ describe('CurateExecutor (regression)', () => { it('returns the agent response without running Phase 4 first', async () => { const agent = buildSplitTestAgent() + // Post-PR578: the agent body is the typed-slot DAG runner, not executeOnSession. + // Empty content → extract emits no facts → write is a no-op → DAG completes + // and formatResponseString returns the JSON-wrapped empty summary. stub(FileContextTreeSnapshotService.prototype, 'getCurrentState') .onFirstCall() .resolves(new Map()) @@ -433,9 +336,11 @@ describe('CurateExecutor (regression)', () => { }) // Phase 4 must NOT have run yet — response was returned immediately. - expect(response).to.equal('curated') + // Response is the DAG runner's formatResponseString output (post-PR578). + expect(response).to.match(/Curate completed via typed-slot DAG/) expect(enqueueStub.called).to.be.false expect(buildManifestStub.called).to.be.false + expect(propagateStalenessStub.called).to.be.false expect((agent.deleteTaskSession as ReturnType).called).to.be.false // finalize() runs Phase 4: enqueue + manifest rebuild + session cleanup. @@ -449,7 +354,13 @@ describe('CurateExecutor (regression)', () => { it('cleans up the task session if the agent body throws (no finalize returned)', async () => { const agent = buildSplitTestAgent() - ;(agent.executeOnSession as ReturnType).rejects(new Error('agent failed')) + // The DAG runner is fail-soft on per-node errors (extract failures land in + // runResult.failures rather than throwing). To exercise runAgentBody's + // catch-and-cleanup path we stub the runner itself to reject. + const {TopologicalCurationRunner} = await import( + '../../../../src/agent/core/curation/flow/runner.js' + ) + stub(TopologicalCurationRunner.prototype, 'run').rejects(new Error('agent failed')) const executor = new CurateExecutor() try { @@ -483,10 +394,19 @@ describe('CurateExecutor (regression)', () => { taskId: 't3', }) - expect(result).to.equal('curated') + // Response is the DAG runner's formatResponseString output (post-PR578). + expect(result).to.match(/Curate completed via typed-slot DAG/) // Wrapper awaits finalize internally — cascade-defer enqueue ran by the - // time we get here. + // time we get here. Per ENG-2485, propagateStaleness no longer runs on + // the curate path — it's deferred to dream via enqueueStaleSummaryPaths. expect(enqueueStub.calledOnce).to.be.true }) }) + + // Note: the previous "dream-lock coordination in Phase 4" describe block + // tested propagateSummariesUnderLock holding the dream lock around inline + // propagateStaleness + buildManifest. PR #601 (ENG-2485) replaced that with + // propagateAndRebuild — propagation is now deferred to dream itself, so the + // lock dance moved to dream's own write path. The corresponding tests live + // in dream's test suite now. }) diff --git a/test/unit/infra/executor/gather-executor.test.ts b/test/unit/infra/executor/gather-executor.test.ts new file mode 100644 index 000000000..1416811ad --- /dev/null +++ b/test/unit/infra/executor/gather-executor.test.ts @@ -0,0 +1,201 @@ +/** + * GatherExecutor tests (Phase 5 Task 5.3). + * + * GatherExecutor is the daemon-side handler for both `brv_gather` MCP and + * `brv gather` CLI. It assembles a context bundle from BM25 + manifest with + * NO LLM call. The agent (or human) synthesizes the answer from the bundle. + * + * Critical invariants (per DESIGN §6.2): + * - Never invokes the LLM. Stub agent must remain untouched. + * - Returns {prefetched_context, manifest_context?, total_tokens_estimated, + * search_metadata, follow_up_hints?} + * - follow_up_hints fire on low-result-count or low-top-score conditions + * - Token budget caps the bundle when set (default 4000 per DESIGN) + */ + +import {expect} from 'chai' +import {stub} from 'sinon' + +import type {ISearchKnowledgeService, SearchKnowledgeResult} from '../../../../src/agent/infra/sandbox/tools-sdk.js' + +import {GatherExecutor} from '../../../../src/server/infra/executor/gather-executor.js' + +const QUERY = 'how does authentication work' + +function makeSearchService(result: SearchKnowledgeResult): ISearchKnowledgeService { + return {search: stub().resolves(result)} as unknown as ISearchKnowledgeService +} + +function makeResults(overrides: Partial[] = []): SearchKnowledgeResult { + const results = overrides.map((o, i) => ({ + excerpt: `excerpt ${i}`, + path: `topics/doc-${i}.md`, + score: 0.9, + title: `Doc ${i}`, + ...o, + })) + return {message: '', results, totalFound: results.length} +} + +describe('GatherExecutor', () => { + describe('basic bundle assembly', () => { + it('returns prefetched_context built from high-score results', async () => { + const searchService = makeSearchService( + makeResults([ + {excerpt: 'JWT tokens expire after 24h', score: 0.92, title: 'JWT'}, + {excerpt: 'Sessions stored in cookies', score: 0.85, title: 'Sessions'}, + ]), + ) + const executor = new GatherExecutor({searchService}) + + const result = await executor.execute({query: QUERY}) + + expect(result.prefetched_context).to.be.a('string') + expect(result.prefetched_context).to.include('JWT') + expect(result.prefetched_context).to.include('Sessions') + }) + + it('returns search_metadata with result_count, top_score, total_found', async () => { + const searchService = makeSearchService( + makeResults([ + {score: 0.95}, + {score: 0.7}, + {score: 0.6}, + ]), + ) + const executor = new GatherExecutor({searchService}) + + const result = await executor.execute({query: QUERY}) + + expect(result.search_metadata.result_count).to.equal(3) + expect(result.search_metadata.top_score).to.equal(0.95) + expect(result.search_metadata.total_found).to.equal(3) + }) + + it('returns total_tokens_estimated >= 0', async () => { + const executor = new GatherExecutor({ + searchService: makeSearchService(makeResults([{excerpt: 'long body '.repeat(50), score: 0.9}])), + }) + + const result = await executor.execute({query: QUERY}) + + expect(result.total_tokens_estimated).to.be.a('number').and.at.least(0) + }) + + it('returns no prefetched_context when no high-confidence results (all below threshold)', async () => { + const searchService = makeSearchService(makeResults([{score: 0.3}, {score: 0.2}])) + const executor = new GatherExecutor({searchService}) + + const result = await executor.execute({query: QUERY}) + + expect(result.prefetched_context).to.equal('') + expect(result.search_metadata.result_count).to.equal(2) + }) + + it('handles empty BM25 results', async () => { + const executor = new GatherExecutor({searchService: makeSearchService(makeResults([]))}) + + const result = await executor.execute({query: QUERY}) + + expect(result.prefetched_context).to.equal('') + expect(result.search_metadata.result_count).to.equal(0) + expect(result.search_metadata.total_found).to.equal(0) + }) + + it('handles searchService throwing — degrades to empty bundle', async () => { + const searchService = {search: stub().rejects(new Error('BM25 down'))} as unknown as ISearchKnowledgeService + const executor = new GatherExecutor({searchService}) + + const result = await executor.execute({query: QUERY}) + + expect(result.prefetched_context).to.equal('') + expect(result.search_metadata.result_count).to.equal(0) + }) + }) + + describe('follow_up_hints', () => { + it('emits a hint when result count is below threshold (≤2 results)', async () => { + const executor = new GatherExecutor({searchService: makeSearchService(makeResults([{score: 0.95}]))}) + + const result = await executor.execute({query: QUERY}) + + expect(result.follow_up_hints).to.be.an('array').and.not.empty + const joined = result.follow_up_hints!.join(' ') + expect(joined.toLowerCase()).to.match(/few|expand|broaden|refine/) + }) + + it('emits a hint when top score is below 0.5 (low confidence)', async () => { + const executor = new GatherExecutor({ + searchService: makeSearchService(makeResults([{score: 0.3}, {score: 0.25}, {score: 0.2}])), + }) + + const result = await executor.execute({query: QUERY}) + + expect(result.follow_up_hints).to.be.an('array').and.not.empty + const joined = result.follow_up_hints!.join(' ') + expect(joined.toLowerCase()).to.match(/score|low confidence|rephras/) + }) + + it('emits no hints when results are abundant and high-confidence', async () => { + const executor = new GatherExecutor({ + searchService: makeSearchService( + makeResults([ + {score: 0.95}, + {score: 0.9}, + {score: 0.88}, + {score: 0.85}, + {score: 0.82}, + ]), + ), + }) + + const result = await executor.execute({query: QUERY}) + + // Either undefined (no hints) or empty array + const hints = result.follow_up_hints ?? [] + expect(hints).to.have.length(0) + }) + }) + + describe('does not invoke LLM (DESIGN §4.2 invariant)', () => { + it('GatherExecutor has no agent dependency — depends only on searchService', () => { + // Compile-time + structural check: constructor accepts only {searchService} + // (no agent, no taskSession). Future enhancement may add IFileSystem + + // baseDirectory for manifest snippets — must NEVER add ICipherAgent. + const executor = new GatherExecutor({searchService: makeSearchService(makeResults([]))}) + expect(executor).to.be.instanceOf(GatherExecutor) + }) + }) + + describe('input validation', () => { + it('returns empty bundle when query is empty/whitespace', async () => { + const executor = new GatherExecutor({searchService: makeSearchService(makeResults([{score: 0.95}]))}) + + const result = await executor.execute({query: ' '}) + + expect(result.search_metadata.result_count).to.equal(0) + expect(result.prefetched_context).to.equal('') + }) + + it('passes scope to searchService when provided', async () => { + const searchStub = stub().resolves(makeResults([])) + const searchService = {search: searchStub} as unknown as ISearchKnowledgeService + const executor = new GatherExecutor({searchService}) + + await executor.execute({query: QUERY, scope: 'src/auth'}) + + expect(searchStub.firstCall.args[1]).to.deep.include({scope: 'src/auth'}) + }) + + it('caps limit at the SMART_ROUTING_MAX_DOCS default when not provided', async () => { + const searchStub = stub().resolves(makeResults([])) + const searchService = {search: searchStub} as unknown as ISearchKnowledgeService + const executor = new GatherExecutor({searchService}) + + await executor.execute({query: QUERY}) + + const passedLimit = (searchStub.firstCall.args[1] as {limit?: number}).limit + expect(passedLimit).to.be.a('number').and.at.most(50) + }) + }) +}) diff --git a/test/unit/infra/executor/record-answer-executor.test.ts b/test/unit/infra/executor/record-answer-executor.test.ts new file mode 100644 index 000000000..8c9b6e12d --- /dev/null +++ b/test/unit/infra/executor/record-answer-executor.test.ts @@ -0,0 +1,77 @@ +/** + * RecordAnswerExecutor tests (Phase 5 Task 5.4). + * + * Closes the cache loop after agent-side synthesis. Tier-0 hits on the + * SAME query+fingerprint must work after recording. + * + * Critical invariant: when the daemon was started without cache, this + * executor still resolves successfully (recorded: false) — never throws. + * Skill/hook agents shouldn't blow up just because cache is disabled. + */ + +import {expect} from 'chai' +import {stub} from 'sinon' + +import {QueryResultCache} from '../../../../src/server/infra/executor/query-result-cache.js' +import {RecordAnswerExecutor} from '../../../../src/server/infra/executor/record-answer-executor.js' + +const QUERY = 'how does authentication work' +const ANSWER = 'Auth uses JWTs with 24h expiry. Tokens stored in httpOnly cookies.' +const FINGERPRINT = 'fp-test-001' + +describe('RecordAnswerExecutor', () => { + describe('with cache configured', () => { + it('writes the answer into the cache and returns recorded: true', async () => { + const cache = new QueryResultCache() + const executor = new RecordAnswerExecutor({cache}) + + const result = await executor.execute({answer: ANSWER, fingerprint: FINGERPRINT, query: QUERY}) + + expect(result.recorded).to.equal(true) + expect(result.fingerprint).to.equal(FINGERPRINT) + // Cache should now have a tier-0 hit for the same query+fingerprint + expect(cache.get(QUERY, FINGERPRINT)).to.equal(ANSWER) + }) + + it('overwrites a prior cache entry (idempotent refresh per §8 Q2)', async () => { + const cache = new QueryResultCache() + cache.set(QUERY, 'STALE-ANSWER', FINGERPRINT) + const executor = new RecordAnswerExecutor({cache}) + + await executor.execute({answer: ANSWER, fingerprint: FINGERPRINT, query: QUERY}) + + expect(cache.get(QUERY, FINGERPRINT)).to.equal(ANSWER) + }) + + it('isolates cache entries by fingerprint (different fingerprint → no shadow)', async () => { + const cache = new QueryResultCache() + const executor = new RecordAnswerExecutor({cache}) + + await executor.execute({answer: ANSWER, fingerprint: FINGERPRINT, query: QUERY}) + + expect(cache.get(QUERY, 'different-fingerprint')).to.be.undefined + }) + + it('returns recorded: true even when cache.set throws (graceful — telemetry left to caller)', async () => { + const cache = new QueryResultCache() + stub(cache, 'set').throws(new Error('cache full')) + const executor = new RecordAnswerExecutor({cache}) + + const result = await executor.execute({answer: ANSWER, fingerprint: FINGERPRINT, query: QUERY}) + + expect(result.recorded).to.equal(false) + expect(result.fingerprint).to.equal(FINGERPRINT) + }) + }) + + describe('without cache configured', () => { + it('returns recorded: false but does not throw', async () => { + const executor = new RecordAnswerExecutor({}) + + const result = await executor.execute({answer: ANSWER, fingerprint: FINGERPRINT, query: QUERY}) + + expect(result.recorded).to.equal(false) + expect(result.fingerprint).to.equal(FINGERPRINT) + }) + }) +}) diff --git a/test/unit/infra/mcp/tools/brv-gather-tool.test.ts b/test/unit/infra/mcp/tools/brv-gather-tool.test.ts new file mode 100644 index 000000000..b783572c2 --- /dev/null +++ b/test/unit/infra/mcp/tools/brv-gather-tool.test.ts @@ -0,0 +1,253 @@ +/* eslint-disable camelcase -- DESIGN §6.2 specifies snake_case for the gather payload */ +/** + * brv-gather MCP tool tests (Phase 5 Task 5.3). + * + * Verifies: + * - Schema accepts {cwd, query, scope?, limit?, token_budget?} + * - Handler routes through transport with task type 'gather' + * - Handler returns the JSON-stringified GatherResult as a single text block + * - Error paths return isError: true + */ + +import type {ConnectionState, ConnectionStateHandler, ITransportClient} from '@campfirein/brv-transport-client' +import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js' + +import {expect} from 'chai' +import {restore, type SinonStub, stub} from 'sinon' + +import type {McpStartupProjectContext} from '../../../../../src/server/infra/mcp/tools/mcp-project-context.js' + +import {BrvGatherInputSchema, registerBrvGatherTool} from '../../../../../src/server/infra/mcp/tools/brv-gather-tool.js' + +type GatherToolHandler = (input: { + cwd?: string + limit?: number + query: string + scope?: string + token_budget?: number +}) => Promise<{content: Array<{text: string; type: string}>; isError?: boolean}> + +function createMockMcpServer(): {getHandler: (name: string) => GatherToolHandler; server: McpServer} { + const handlers = new Map() + const mock = { + registerTool(name: string, _config: unknown, cb: GatherToolHandler) { + handlers.set(name, cb) + }, + } + return { + getHandler(name: string) { + const h = handlers.get(name) + if (!h) throw new Error(`Handler ${name} not registered`) + return h + }, + server: mock as unknown as McpServer, + } +} + +function createMockClient(options?: {state?: ConnectionState}): { + client: ITransportClient + simulateEvent: (event: string, payload: T) => void +} { + const eventHandlers = new Map void>>() + const stateHandlers = new Set() + + const client: ITransportClient = { + connect: stub().resolves(), + disconnect: stub().resolves(), + getClientId: stub().returns('mock-client-id'), + getState: stub().returns(options?.state ?? 'connected'), + isConnected: stub().resolves(true), + joinRoom: stub().resolves(), + leaveRoom: stub().resolves(), + on(event: string, handler: (data: T) => void) { + if (!eventHandlers.has(event)) eventHandlers.set(event, new Set()) + eventHandlers.get(event)!.add(handler as (data: unknown) => void) + return () => { + eventHandlers.get(event)?.delete(handler as (data: unknown) => void) + } + }, + once: stub(), + onStateChange(handler: ConnectionStateHandler) { + stateHandlers.add(handler) + return () => stateHandlers.delete(handler) + }, + request: stub() as unknown as ITransportClient['request'], + requestWithAck: stub().resolves(), + } + + return { + client, + simulateEvent(event: string, payload: T) { + const handlers = eventHandlers.get(event) + if (handlers) for (const h of handlers) h(payload) + }, + } +} + +function setupHandler(options: { + getClient: () => ITransportClient | undefined + getStartupProjectContext?: () => McpStartupProjectContext | undefined + getWorkingDirectory: () => string | undefined +}): GatherToolHandler { + const {getHandler, server} = createMockMcpServer() + registerBrvGatherTool( + server, + options.getClient, + options.getWorkingDirectory, + options.getStartupProjectContext ?? + (() => { + const wd = options.getWorkingDirectory() + return wd ? {projectRoot: wd, worktreeRoot: wd} : undefined + }), + ) + return getHandler('brv-gather') +} + +describe('brv-gather-tool', () => { + afterEach(() => restore()) + + describe('BrvGatherInputSchema', () => { + it('accepts {query} only', () => { + expect(BrvGatherInputSchema.safeParse({query: 'auth'}).success).to.equal(true) + }) + + it('accepts all optional fields including token_budget', () => { + const r = BrvGatherInputSchema.safeParse({ + cwd: '/p', + limit: 25, + query: 'auth', + scope: 'src/auth', + token_budget: 8000, + }) + expect(r.success).to.equal(true) + }) + + it('rejects missing query', () => { + expect(BrvGatherInputSchema.safeParse({cwd: '/p'}).success).to.equal(false) + }) + + it('rejects token_budget below 100 (sub-payload size)', () => { + expect(BrvGatherInputSchema.safeParse({query: 'auth', token_budget: 50}).success).to.equal(false) + }) + + it('rejects limit > 50', () => { + expect(BrvGatherInputSchema.safeParse({limit: 100, query: 'auth'}).success).to.equal(false) + }) + }) + + describe('handler — task routing', () => { + it('sends task:create with type "gather"', async () => { + const {client, simulateEvent} = createMockClient() + const requestStub = client.requestWithAck as SinonStub + const samplePayload = JSON.stringify({ + prefetched_context: 'JWT info', + search_metadata: {result_count: 1, top_score: 0.9, total_found: 1}, + total_tokens_estimated: 25, + }) + requestStub.callsFake((event: string, data: {taskId?: string}) => { + if (event === 'task:create' && data.taskId) { + simulateEvent('task:completed', {result: samplePayload, taskId: data.taskId}) + } + + return Promise.resolve() + }) + + const handler = setupHandler({getClient: () => client, getWorkingDirectory: () => '/proj'}) + await handler({query: 'auth'}) + + const createCall = requestStub + .getCalls() + .find((c: {args: unknown[]}) => c.args[0] === 'task:create') + expect(createCall).to.exist + const payload = createCall!.args[1] as {content: string; type: string} + expect(payload.type).to.equal('gather') + }) + + it('encodes scope, limit, and token_budget into the content payload', async () => { + const {client, simulateEvent} = createMockClient() + const requestStub = client.requestWithAck as SinonStub + requestStub.callsFake((_event: string, data: {taskId?: string}) => { + if (data.taskId) { + simulateEvent('task:completed', { + result: JSON.stringify({ + prefetched_context: '', + search_metadata: {result_count: 0, top_score: 0, total_found: 0}, + total_tokens_estimated: 0, + }), + taskId: data.taskId, + }) + } + + return Promise.resolve() + }) + + const handler = setupHandler({getClient: () => client, getWorkingDirectory: () => '/proj'}) + await handler({limit: 20, query: 'jwt', scope: 'src/auth', token_budget: 6000}) + + const createCall = requestStub + .getCalls() + .find((c: {args: unknown[]}) => c.args[0] === 'task:create') + const {content} = createCall!.args[1] as {content: string} + const decoded = JSON.parse(content) as {limit?: number; query: string; scope?: string; tokenBudget?: number} + expect(decoded.query).to.equal('jwt') + expect(decoded.scope).to.equal('src/auth') + expect(decoded.limit).to.equal(20) + expect(decoded.tokenBudget).to.equal(6000) + }) + + it('returns the daemon result verbatim as content[0].text (JSON-encoded GatherResult)', async () => { + const samplePayload = JSON.stringify({ + follow_up_hints: ['few results'], + prefetched_context: '### JWT\n**Source**: .brv/context-tree/auth.md\n\nJWT info', + search_metadata: {result_count: 1, top_score: 0.95, total_found: 1}, + total_tokens_estimated: 30, + }) + + const {client, simulateEvent} = createMockClient() + const requestStub = client.requestWithAck as SinonStub + requestStub.callsFake((_event: string, data: {taskId?: string}) => { + if (data.taskId) simulateEvent('task:completed', {result: samplePayload, taskId: data.taskId}) + return Promise.resolve() + }) + + const handler = setupHandler({getClient: () => client, getWorkingDirectory: () => '/proj'}) + const result = await handler({query: 'auth'}) + + expect(result.isError).to.be.undefined + expect(result.content[0].text).to.equal(samplePayload) + const parsed = JSON.parse(result.content[0].text) as {prefetched_context: string} + expect(parsed.prefetched_context).to.include('JWT') + }) + }) + + describe('handler — error paths', () => { + it('returns isError when daemon transport rejects', async () => { + const {client} = createMockClient() + ;(client.requestWithAck as SinonStub).rejects(new Error('Connection refused')) + + const handler = setupHandler({getClient: () => client, getWorkingDirectory: () => '/proj'}) + const result = await handler({query: 'auth'}) + + expect(result.isError).to.equal(true) + expect(result.content[0].text).to.include('Connection refused') + }) + + it('returns isError when task fails with task:error event', async () => { + const {client, simulateEvent} = createMockClient() + const requestStub = client.requestWithAck as SinonStub + requestStub.callsFake((_event: string, data: {taskId?: string}) => { + if (data.taskId) { + simulateEvent('task:error', {error: {message: 'Index missing', name: 'TaskError'}, taskId: data.taskId}) + } + + return Promise.resolve() + }) + + const handler = setupHandler({getClient: () => client, getWorkingDirectory: () => '/proj'}) + const result = await handler({query: 'auth'}) + + expect(result.isError).to.equal(true) + expect(result.content[0].text).to.include('Index missing') + }) + }) +}) diff --git a/test/unit/infra/mcp/tools/brv-query-tool.test.ts b/test/unit/infra/mcp/tools/brv-query-tool.test.ts index 811277482..cf466efef 100644 --- a/test/unit/infra/mcp/tools/brv-query-tool.test.ts +++ b/test/unit/infra/mcp/tools/brv-query-tool.test.ts @@ -2,7 +2,7 @@ import type {ConnectionState, ConnectionStateHandler, ITransportClient} from '@c import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js' import {expect} from 'chai' -import {mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs' +import {existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync} from 'node:fs' import {tmpdir} from 'node:os' import {join} from 'node:path' import {restore, type SinonFakeTimers, type SinonStub, stub, useFakeTimers} from 'sinon' @@ -17,6 +17,7 @@ const ATTRIBUTION_FOOTER = '\n\n---\nSource: ByteRover Knowledge Base' /** Returns undefined — named constant avoids inline `() => undefined` triggering unicorn/no-useless-undefined. */ const noClient = (): ITransportClient | undefined => undefined const noWorkingDirectory = (): string | undefined => undefined +const noStartupProjectContext = (): McpStartupProjectContext | undefined => undefined /** * Handler type captured from server.registerTool(). @@ -29,19 +30,35 @@ type QueryToolHandler = (input: {cwd?: string; query: string}) => Promise<{ /** * Creates a mock McpServer that captures tool handlers on registerTool(). */ +interface ToolRegistrationConfig { + _meta?: Record + annotations?: Record + description?: string + inputSchema?: unknown + title?: string +} + function createMockMcpServer(): { + getConfig: (name: string) => ToolRegistrationConfig getHandler: (name: string) => QueryToolHandler server: McpServer } { const handlers = new Map() + const configs = new Map() const mock = { - registerTool(name: string, _config: unknown, cb: QueryToolHandler) { + registerTool(name: string, config: ToolRegistrationConfig, cb: QueryToolHandler) { handlers.set(name, cb) + configs.set(name, config) }, } return { + getConfig(name: string): ToolRegistrationConfig { + const config = configs.get(name) + if (!config) throw new Error(`Config ${name} not captured`) + return config + }, getHandler(name: string): QueryToolHandler { const handler = handlers.get(name) if (!handler) throw new Error(`Handler ${name} not registered`) @@ -651,4 +668,82 @@ describe('brv-query-tool', () => { expect(result.content[0].text).to.equal('fast result') }) }) + + describe('Phase 5 Task 5.5 — deprecation marker', () => { + it('description starts with "[deprecated]" so MCP `tools/list` advertises the marker', () => { + const {getConfig, server} = createMockMcpServer() + registerBrvQueryTool(server, noClient, () => '/proj', noStartupProjectContext) + + const config = getConfig('brv-query') + expect(config.description).to.match(/^\[deprecated]/i) + }) + + it('_meta includes deprecated:true and replacedBy hints (MCP SDK does not allow custom annotations fields)', () => { + const {getConfig, server} = createMockMcpServer() + registerBrvQueryTool(server, noClient, () => '/proj', noStartupProjectContext) + + const config = getConfig('brv-query') + expect(config._meta).to.exist + expect(config._meta!.deprecated).to.equal(true) + const replacedBy = config._meta!.replacedBy as string[] + expect(replacedBy).to.include('brv-search') + expect(replacedBy).to.include('brv-gather') + expect(replacedBy).to.include('brv-record-answer') + }) + + it('title also flags deprecation so picker UIs surface it', () => { + const {getConfig, server} = createMockMcpServer() + registerBrvQueryTool(server, noClient, () => '/proj', noStartupProjectContext) + + const config = getConfig('brv-query') + expect(config.title).to.match(/deprecat/i) + }) + }) + + describe('Phase 5 Task 5.5 — invocation telemetry', () => { + let originalDataDir: string | undefined + let tempDir: string + + beforeEach(() => { + originalDataDir = process.env.BRV_DATA_DIR + tempDir = mkdtempSync(join(tmpdir(), 'brv-query-tool-telemetry-')) + process.env.BRV_DATA_DIR = tempDir + }) + + afterEach(() => { + if (originalDataDir === undefined) { + delete process.env.BRV_DATA_DIR + } else { + process.env.BRV_DATA_DIR = originalDataDir + } + + rmSync(tempDir, {force: true, recursive: true}) + }) + + it('writes one JSONL telemetry line per invocation, even when daemon is unreachable', async () => { + // Handler invoked with no client → enters the early-return path; telemetry + // must STILL fire (failed legacy calls count as legacy usage). + const handler = setupQueryHandler({ + getClient: noClient, + getWorkingDirectory: () => '/proj', + }) + + const clock = useFakeTimers() + try { + const promise = handler({query: 'test'}) + await clock.tickAsync(61_000) + await promise + } finally { + clock.restore() + } + + const file = join(tempDir, 'telemetry', 'mcp-deprecation.jsonl') + expect(existsSync(file)).to.equal(true) + const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean) + expect(lines.length).to.be.at.least(1) + const entry = JSON.parse(lines[0]) as {counter: string; tool: string} + expect(entry.counter).to.equal('mcp.query.legacy_invocations') + expect(entry.tool).to.equal('brv-query') + }) + }) }) diff --git a/test/unit/infra/mcp/tools/brv-record-answer-tool.test.ts b/test/unit/infra/mcp/tools/brv-record-answer-tool.test.ts new file mode 100644 index 000000000..c0ef0b958 --- /dev/null +++ b/test/unit/infra/mcp/tools/brv-record-answer-tool.test.ts @@ -0,0 +1,218 @@ +/** + * brv-record-answer MCP tool tests (Phase 5 Task 5.4). + * + * Verifies: + * - Schema requires {query, answer, fingerprint} (no defaults — write op) + * - Handler routes through transport with task type 'record-answer' + * - Handler returns the JSON-stringified RecordAnswerResult + * - Error paths return isError: true + */ + +import type {ConnectionState, ConnectionStateHandler, ITransportClient} from '@campfirein/brv-transport-client' +import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js' + +import {expect} from 'chai' +import {restore, type SinonStub, stub} from 'sinon' + +import type {McpStartupProjectContext} from '../../../../../src/server/infra/mcp/tools/mcp-project-context.js' + +import { + BrvRecordAnswerInputSchema, + registerBrvRecordAnswerTool, +} from '../../../../../src/server/infra/mcp/tools/brv-record-answer-tool.js' + +type RecordAnswerHandler = (input: { + answer: string + cwd?: string + fingerprint: string + query: string +}) => Promise<{content: Array<{text: string; type: string}>; isError?: boolean}> + +function createMockMcpServer(): {getHandler: (name: string) => RecordAnswerHandler; server: McpServer} { + const handlers = new Map() + const mock = { + registerTool(name: string, _config: unknown, cb: RecordAnswerHandler) { + handlers.set(name, cb) + }, + } + return { + getHandler(name: string) { + const h = handlers.get(name) + if (!h) throw new Error(`Handler ${name} not registered`) + return h + }, + server: mock as unknown as McpServer, + } +} + +function createMockClient(options?: {state?: ConnectionState}): { + client: ITransportClient + simulateEvent: (event: string, payload: T) => void +} { + const eventHandlers = new Map void>>() + const stateHandlers = new Set() + + const client: ITransportClient = { + connect: stub().resolves(), + disconnect: stub().resolves(), + getClientId: stub().returns('mock-client-id'), + getState: stub().returns(options?.state ?? 'connected'), + isConnected: stub().resolves(true), + joinRoom: stub().resolves(), + leaveRoom: stub().resolves(), + on(event: string, handler: (data: T) => void) { + if (!eventHandlers.has(event)) eventHandlers.set(event, new Set()) + eventHandlers.get(event)!.add(handler as (data: unknown) => void) + return () => { + eventHandlers.get(event)?.delete(handler as (data: unknown) => void) + } + }, + once: stub(), + onStateChange(handler: ConnectionStateHandler) { + stateHandlers.add(handler) + return () => stateHandlers.delete(handler) + }, + request: stub() as unknown as ITransportClient['request'], + requestWithAck: stub().resolves(), + } + + return { + client, + simulateEvent(event: string, payload: T) { + const handlers = eventHandlers.get(event) + if (handlers) for (const h of handlers) h(payload) + }, + } +} + +function setupHandler(options: { + getClient: () => ITransportClient | undefined + getStartupProjectContext?: () => McpStartupProjectContext | undefined + getWorkingDirectory: () => string | undefined +}): RecordAnswerHandler { + const {getHandler, server} = createMockMcpServer() + registerBrvRecordAnswerTool( + server, + options.getClient, + options.getWorkingDirectory, + options.getStartupProjectContext ?? + (() => { + const wd = options.getWorkingDirectory() + return wd ? {projectRoot: wd, worktreeRoot: wd} : undefined + }), + ) + return getHandler('brv-record-answer') +} + +describe('brv-record-answer-tool', () => { + afterEach(() => restore()) + + describe('BrvRecordAnswerInputSchema', () => { + it('accepts {query, answer, fingerprint}', () => { + const r = BrvRecordAnswerInputSchema.safeParse({ + answer: 'Auth uses JWTs', + fingerprint: 'fp-1', + query: 'auth', + }) + expect(r.success).to.equal(true) + }) + + it('rejects missing query', () => { + const r = BrvRecordAnswerInputSchema.safeParse({answer: 'a', fingerprint: 'fp'}) + expect(r.success).to.equal(false) + }) + + it('rejects missing answer (the whole point of this tool)', () => { + const r = BrvRecordAnswerInputSchema.safeParse({fingerprint: 'fp', query: 'q'}) + expect(r.success).to.equal(false) + }) + + it('rejects missing fingerprint (cache key required)', () => { + const r = BrvRecordAnswerInputSchema.safeParse({answer: 'a', query: 'q'}) + expect(r.success).to.equal(false) + }) + + it('rejects empty fingerprint string', () => { + const r = BrvRecordAnswerInputSchema.safeParse({answer: 'a', fingerprint: '', query: 'q'}) + expect(r.success).to.equal(false) + }) + }) + + describe('handler — task routing', () => { + it('sends task:create with type "record-answer" and encoded payload', async () => { + const {client, simulateEvent} = createMockClient() + const requestStub = client.requestWithAck as SinonStub + const samplePayload = JSON.stringify({fingerprint: 'fp-1', recorded: true}) + requestStub.callsFake((event: string, data: {taskId?: string}) => { + if (event === 'task:create' && data.taskId) { + simulateEvent('task:completed', {result: samplePayload, taskId: data.taskId}) + } + + return Promise.resolve() + }) + + const handler = setupHandler({getClient: () => client, getWorkingDirectory: () => '/proj'}) + await handler({answer: 'Auth uses JWTs', fingerprint: 'fp-1', query: 'auth'}) + + const createCall = requestStub.getCalls().find((c: {args: unknown[]}) => c.args[0] === 'task:create') + expect(createCall).to.exist + const payload = createCall!.args[1] as {content: string; type: string} + expect(payload.type).to.equal('record-answer') + + const decoded = JSON.parse(payload.content) as {answer: string; fingerprint: string; query: string} + expect(decoded.query).to.equal('auth') + expect(decoded.answer).to.equal('Auth uses JWTs') + expect(decoded.fingerprint).to.equal('fp-1') + }) + + it('returns the daemon result verbatim as content[0].text (JSON-encoded RecordAnswerResult)', async () => { + const samplePayload = JSON.stringify({fingerprint: 'fp-2', recorded: true}) + + const {client, simulateEvent} = createMockClient() + const requestStub = client.requestWithAck as SinonStub + requestStub.callsFake((_event: string, data: {taskId?: string}) => { + if (data.taskId) simulateEvent('task:completed', {result: samplePayload, taskId: data.taskId}) + return Promise.resolve() + }) + + const handler = setupHandler({getClient: () => client, getWorkingDirectory: () => '/proj'}) + const result = await handler({answer: 'A', fingerprint: 'fp-2', query: 'q'}) + + expect(result.isError).to.be.undefined + expect(result.content[0].text).to.equal(samplePayload) + const parsed = JSON.parse(result.content[0].text) as {recorded: boolean} + expect(parsed.recorded).to.equal(true) + }) + }) + + describe('handler — error paths', () => { + it('returns isError when daemon transport rejects', async () => { + const {client} = createMockClient() + ;(client.requestWithAck as SinonStub).rejects(new Error('Connection refused')) + + const handler = setupHandler({getClient: () => client, getWorkingDirectory: () => '/proj'}) + const result = await handler({answer: 'A', fingerprint: 'fp', query: 'q'}) + + expect(result.isError).to.equal(true) + expect(result.content[0].text).to.include('Connection refused') + }) + + it('returns isError when task fails with task:error event', async () => { + const {client, simulateEvent} = createMockClient() + const requestStub = client.requestWithAck as SinonStub + requestStub.callsFake((_event: string, data: {taskId?: string}) => { + if (data.taskId) { + simulateEvent('task:error', {error: {message: 'Cache full', name: 'TaskError'}, taskId: data.taskId}) + } + + return Promise.resolve() + }) + + const handler = setupHandler({getClient: () => client, getWorkingDirectory: () => '/proj'}) + const result = await handler({answer: 'A', fingerprint: 'fp', query: 'q'}) + + expect(result.isError).to.equal(true) + expect(result.content[0].text).to.include('Cache full') + }) + }) +}) diff --git a/test/unit/infra/mcp/tools/brv-search-tool.test.ts b/test/unit/infra/mcp/tools/brv-search-tool.test.ts new file mode 100644 index 000000000..265d5ab65 --- /dev/null +++ b/test/unit/infra/mcp/tools/brv-search-tool.test.ts @@ -0,0 +1,329 @@ +/** + * brv-search MCP tool tests (Phase 5 Task 5.2). + * + * Verifies: + * - Schema accepts {cwd, query, scope?, limit?} + * - Handler routes through transport with task type 'mcp-search' (NOT 'search'; + * that's the existing CLI BM25 task) + * - Handler returns the JSON-stringified DispatchResult as a single text block + * - Error paths (no daemon, transport reject, task error) return isError: true + * + * Daemon-side dispatch (agent-process.ts 'mcp-search' case → QueryDispatcher) + * is exercised by the round-trip integration test (PHASE-5-PLAN.md §2 #8), + * not here — this file only covers the MCP tool surface. + */ + +import type {ConnectionState, ConnectionStateHandler, ITransportClient} from '@campfirein/brv-transport-client' +import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js' + +import {expect} from 'chai' +import {restore, type SinonStub, stub} from 'sinon' + +import type {McpStartupProjectContext} from '../../../../../src/server/infra/mcp/tools/mcp-project-context.js' + +import {BrvSearchInputSchema, registerBrvSearchTool} from '../../../../../src/server/infra/mcp/tools/brv-search-tool.js' + +type SearchToolHandler = (input: { + cwd?: string + limit?: number + query: string + scope?: string +}) => Promise<{ + _meta?: Record + content: Array<{text: string; type: string}> + isError?: boolean +}> + +function createMockMcpServer(): { + getHandler: (name: string) => SearchToolHandler + server: McpServer +} { + const handlers = new Map() + const mock = { + registerTool(name: string, _config: unknown, cb: SearchToolHandler) { + handlers.set(name, cb) + }, + } + return { + getHandler(name: string): SearchToolHandler { + const h = handlers.get(name) + if (!h) throw new Error(`Handler ${name} not registered`) + return h + }, + server: mock as unknown as McpServer, + } +} + +function createMockClient(options?: {state?: ConnectionState}): { + client: ITransportClient + simulateEvent: (event: string, payload: T) => void +} { + const eventHandlers = new Map void>>() + const stateHandlers = new Set() + + const client: ITransportClient = { + connect: stub().resolves(), + disconnect: stub().resolves(), + getClientId: stub().returns('mock-client-id'), + getState: stub().returns(options?.state ?? 'connected'), + isConnected: stub().resolves(true), + joinRoom: stub().resolves(), + leaveRoom: stub().resolves(), + on(event: string, handler: (data: T) => void) { + if (!eventHandlers.has(event)) { + eventHandlers.set(event, new Set()) + } + + eventHandlers.get(event)!.add(handler as (data: unknown) => void) + return () => { + eventHandlers.get(event)?.delete(handler as (data: unknown) => void) + } + }, + once: stub(), + onStateChange(handler: ConnectionStateHandler) { + stateHandlers.add(handler) + return () => stateHandlers.delete(handler) + }, + request: stub() as unknown as ITransportClient['request'], + requestWithAck: stub().resolves(), + } + + return { + client, + simulateEvent(event: string, payload: T) { + const handlers = eventHandlers.get(event) + if (handlers) { + for (const h of handlers) h(payload) + } + }, + } +} + +function setupHandler(options: { + getClient: () => ITransportClient | undefined + getStartupProjectContext?: () => McpStartupProjectContext | undefined + getWorkingDirectory: () => string | undefined +}): SearchToolHandler { + const {getHandler, server} = createMockMcpServer() + registerBrvSearchTool( + server, + options.getClient, + options.getWorkingDirectory, + options.getStartupProjectContext ?? + (() => { + const wd = options.getWorkingDirectory() + return wd ? {projectRoot: wd, worktreeRoot: wd} : undefined + }), + ) + return getHandler('brv-search') +} + +describe('brv-search-tool', () => { + afterEach(() => { + restore() + }) + + describe('BrvSearchInputSchema', () => { + it('accepts {query} only', () => { + const r = BrvSearchInputSchema.safeParse({query: 'auth'}) + expect(r.success).to.equal(true) + }) + + it('accepts all optional fields', () => { + const r = BrvSearchInputSchema.safeParse({ + cwd: '/p', + limit: 25, + query: 'auth', + scope: 'src/auth', + }) + expect(r.success).to.equal(true) + }) + + it('rejects missing query', () => { + const r = BrvSearchInputSchema.safeParse({cwd: '/p'}) + expect(r.success).to.equal(false) + }) + + it('rejects limit > 50 (DESIGN §6.1 cap)', () => { + const r = BrvSearchInputSchema.safeParse({limit: 100, query: 'auth'}) + expect(r.success).to.equal(false) + }) + + it('rejects limit < 1', () => { + const r = BrvSearchInputSchema.safeParse({limit: 0, query: 'auth'}) + expect(r.success).to.equal(false) + }) + }) + + describe('handler — task routing', () => { + it('sends task:create with type "mcp-search" (NOT "search" — that is the CLI task)', async () => { + const {client, simulateEvent} = createMockClient() + const requestStub = client.requestWithAck as SinonStub + const samplePayload = JSON.stringify({ + fingerprint: 'fp-1', + passages: [], + status: 'no_results', + tier: 2, + // eslint-disable-next-line camelcase -- public DTO is snake_case per DESIGN §6.1 + timing_ms: 5, + // eslint-disable-next-line camelcase + total_found: 0, + }) + requestStub.callsFake((event: string, data: {taskId?: string}) => { + if (event === 'task:create' && data.taskId) { + simulateEvent('task:completed', {result: samplePayload, taskId: data.taskId}) + } + + return Promise.resolve() + }) + + const handler = setupHandler({getClient: () => client, getWorkingDirectory: () => '/proj'}) + await handler({query: 'auth'}) + + const createCall = requestStub + .getCalls() + .find((c: {args: unknown[]}) => c.args[0] === 'task:create') + expect(createCall).to.exist + const payload = createCall!.args[1] as {content: string; type: string} + expect(payload.type).to.equal('mcp-search') + }) + + it('encodes scope and limit into the content payload', async () => { + const {client, simulateEvent} = createMockClient() + const requestStub = client.requestWithAck as SinonStub + requestStub.callsFake((_event: string, data: {taskId?: string}) => { + if (data.taskId) { + simulateEvent('task:completed', { + // eslint-disable-next-line camelcase -- public DTO is snake_case per DESIGN §6.1 + result: JSON.stringify({passages: [], status: 'no_results', tier: 2, timing_ms: 1, total_found: 0}), + taskId: data.taskId, + }) + } + + return Promise.resolve() + }) + + const handler = setupHandler({getClient: () => client, getWorkingDirectory: () => '/proj'}) + await handler({limit: 20, query: 'jwt', scope: 'src/auth'}) + + const createCall = requestStub + .getCalls() + .find((c: {args: unknown[]}) => c.args[0] === 'task:create') + const {content} = (createCall!.args[1] as {content: string}) + const decoded = JSON.parse(content) as {limit?: number; query: string; scope?: string} + expect(decoded.query).to.equal('jwt') + expect(decoded.scope).to.equal('src/auth') + expect(decoded.limit).to.equal(20) + }) + + it('returns the daemon result verbatim as content[0].text (JSON-encoded DispatchResult)', async () => { + const samplePayload = JSON.stringify({ + fingerprint: 'fp-2', + passages: [{excerpt: 'JWT details', path: 'auth.md', score: 0.95}], + status: 'direct_passages', + tier: 2, + // eslint-disable-next-line camelcase -- public DTO is snake_case per DESIGN §6.1 + timing_ms: 50, + // eslint-disable-next-line camelcase + total_found: 1, + }) + + const {client, simulateEvent} = createMockClient() + const requestStub = client.requestWithAck as SinonStub + requestStub.callsFake((_event: string, data: {taskId?: string}) => { + if (data.taskId) simulateEvent('task:completed', {result: samplePayload, taskId: data.taskId}) + return Promise.resolve() + }) + + const handler = setupHandler({getClient: () => client, getWorkingDirectory: () => '/proj'}) + const result = await handler({query: 'auth'}) + + expect(result.isError).to.be.undefined + expect(result.content[0].text).to.equal(samplePayload) + // Sanity: the agent (consumer) can JSON.parse this back to a typed shape + const parsed = JSON.parse(result.content[0].text) as {status: string} + expect(parsed.status).to.equal('direct_passages') + }) + }) + + describe('handler — structured _meta (PHASE-5-CODE-REVIEW.md W2)', () => { + it('returns the typed BrvSearchResult on the _meta channel for tool-aware clients', async () => { + /* eslint-disable camelcase -- DESIGN §6.1 specifies snake_case for the public DTO */ + const samplePayloadObj = { + fingerprint: 'fp-meta', + passages: [{excerpt: 'JWT details', path: 'auth.md', score: 0.95}], + status: 'direct_passages' as const, + tier: 2 as const, + timing_ms: 50, + total_found: 1, + } + /* eslint-enable camelcase */ + const samplePayload = JSON.stringify(samplePayloadObj) + + const {client, simulateEvent} = createMockClient() + const requestStub = client.requestWithAck as SinonStub + requestStub.callsFake((_event: string, data: {taskId?: string}) => { + if (data.taskId) simulateEvent('task:completed', {result: samplePayload, taskId: data.taskId}) + return Promise.resolve() + }) + + const handler = setupHandler({getClient: () => client, getWorkingDirectory: () => '/proj'}) + const result = await handler({query: 'auth'}) + + expect(result.isError).to.be.undefined + // Text content (legacy path) still present and identical + expect(result.content[0].text).to.equal(samplePayload) + // _meta channel mirrors the typed payload — no JSON re-parse needed by client + expect(result._meta).to.exist + expect(result._meta).to.deep.equal(samplePayloadObj) + }) + + it('omits _meta when daemon emits non-JSON (legacy / error fallback)', async () => { + const {client, simulateEvent} = createMockClient() + const requestStub = client.requestWithAck as SinonStub + requestStub.callsFake((_event: string, data: {taskId?: string}) => { + if (data.taskId) simulateEvent('task:completed', {result: 'plain string not json', taskId: data.taskId}) + return Promise.resolve() + }) + + const handler = setupHandler({getClient: () => client, getWorkingDirectory: () => '/proj'}) + const result = await handler({query: 'auth'}) + + expect(result.isError).to.be.undefined + expect(result.content[0].text).to.equal('plain string not json') + // _meta is absent — non-JSON daemon output should not break legacy callers + expect(result._meta).to.be.undefined + }) + }) + + describe('handler — error paths', () => { + it('returns isError when daemon transport rejects', async () => { + const {client} = createMockClient() + ;(client.requestWithAck as SinonStub).rejects(new Error('Connection refused')) + + const handler = setupHandler({getClient: () => client, getWorkingDirectory: () => '/proj'}) + const result = await handler({query: 'auth'}) + + expect(result.isError).to.equal(true) + expect(result.content[0].text).to.include('Connection refused') + }) + + it('returns isError when task fails with task:error event', async () => { + const {client, simulateEvent} = createMockClient() + const requestStub = client.requestWithAck as SinonStub + requestStub.callsFake((_event: string, data: {taskId?: string}) => { + if (data.taskId) { + simulateEvent('task:error', {error: {message: 'BM25 index missing', name: 'TaskError'}, taskId: data.taskId}) + } + + return Promise.resolve() + }) + + const handler = setupHandler({getClient: () => client, getWorkingDirectory: () => '/proj'}) + const result = await handler({query: 'auth'}) + + expect(result.isError).to.equal(true) + expect(result.content[0].text).to.include('BM25 index missing') + }) + }) +}) diff --git a/test/unit/infra/mcp/tools/deprecation-telemetry.test.ts b/test/unit/infra/mcp/tools/deprecation-telemetry.test.ts new file mode 100644 index 000000000..5af535b77 --- /dev/null +++ b/test/unit/infra/mcp/tools/deprecation-telemetry.test.ts @@ -0,0 +1,95 @@ +/** + * Deprecation telemetry tests (Phase 5 Task 5.5). + * + * Each `brv-query` MCP invocation writes one JSONL line to + * `/telemetry/mcp-deprecation.jsonl`. Used to decide when + * MCP-side `brv_query` usage is low enough to remove the path. + * + * Critical invariants: + * - Honors `BRV_DATA_DIR` env override + * - Best-effort: telemetry write failures must NOT throw or block the handler + * - Append-only (one line per invocation) + * - Each line carries timestamp + counter name + tool name (queryable with `jq`) + */ + +import {expect} from 'chai' +import {existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {recordLegacyQueryInvocation} from '../../../../../src/server/infra/mcp/tools/deprecation-telemetry.js' + +describe('deprecation-telemetry', () => { + let originalDataDir: string | undefined + let tempDir: string + + beforeEach(() => { + originalDataDir = process.env.BRV_DATA_DIR + tempDir = mkdtempSync(join(tmpdir(), 'brv-deprecation-telemetry-')) + process.env.BRV_DATA_DIR = tempDir + }) + + afterEach(() => { + if (originalDataDir === undefined) { + delete process.env.BRV_DATA_DIR + } else { + process.env.BRV_DATA_DIR = originalDataDir + } + + rmSync(tempDir, {force: true, recursive: true}) + }) + + describe('recordLegacyQueryInvocation', () => { + it('writes one JSONL line to /telemetry/mcp-deprecation.jsonl', () => { + recordLegacyQueryInvocation() + + const file = join(tempDir, 'telemetry', 'mcp-deprecation.jsonl') + expect(existsSync(file)).to.equal(true) + + const content = readFileSync(file, 'utf8') + const lines = content.split('\n').filter(Boolean) + expect(lines).to.have.length(1) + + const entry = JSON.parse(lines[0]) as {counter: string; tool: string; ts: string} + expect(entry.counter).to.equal('mcp.query.legacy_invocations') + expect(entry.tool).to.equal('brv-query') + expect(entry.ts).to.match(/^\d{4}-\d{2}-\d{2}T/) + }) + + it('appends — multiple invocations produce multiple lines', () => { + recordLegacyQueryInvocation() + recordLegacyQueryInvocation() + recordLegacyQueryInvocation() + + const file = join(tempDir, 'telemetry', 'mcp-deprecation.jsonl') + const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean) + expect(lines).to.have.length(3) + }) + + it('honors BRV_DATA_DIR override (does NOT write to default ~/.brv)', () => { + recordLegacyQueryInvocation() + + // Default location should remain untouched + const file = join(tempDir, 'telemetry', 'mcp-deprecation.jsonl') + expect(existsSync(file)).to.equal(true) + }) + + it('creates the telemetry/ subdirectory if missing', () => { + // tempDir exists but tempDir/telemetry/ does not — exercise mkdir branch + recordLegacyQueryInvocation() + + const dir = join(tempDir, 'telemetry') + expect(existsSync(dir)).to.equal(true) + }) + + it('does NOT throw when the data dir is unwritable (best-effort)', () => { + // Point at a path that exists as a file (so mkdirSync recursive will fail) + const blockingFile = join(tempDir, 'blocking-file') + writeFileSync(blockingFile, 'not a dir') + process.env.BRV_DATA_DIR = blockingFile + + // Must not throw — telemetry failures are silent + expect(() => recordLegacyQueryInvocation()).to.not.throw() + }) + }) +}) diff --git a/test/unit/infra/sandbox/local-sandbox-uuid-variable-naming.test.ts b/test/unit/infra/sandbox/local-sandbox-uuid-variable-naming.test.ts new file mode 100644 index 000000000..8cec270db --- /dev/null +++ b/test/unit/infra/sandbox/local-sandbox-uuid-variable-naming.test.ts @@ -0,0 +1,70 @@ +/** + * LocalSandbox — UUID variable-naming regression. + * + * UUID-shaped task IDs contain hyphens, which are not valid JavaScript + * identifier characters. When LLM-written code references variables by + * task ID (`__curate_meta_`), it naturally writes underscores — + * causing a ReferenceError if the sandbox stored the variable using the + * raw hyphenated form. + * + * Originally lived in `test/unit/infra/executor/curate-executor.test.ts` + * under `describe('CurateExecutor (regression)')`, but post-Phase-1 cutover + * the executor no longer injects sandbox variables — these tests exercise + * `LocalSandbox` directly. Moved here so the describe label matches the + * actual subject under test. + */ + +import {expect} from 'chai' + +import {LocalSandbox} from '../../../../src/agent/infra/sandbox/local-sandbox.js' + +describe('LocalSandbox — UUID variable naming', () => { + // Typical UUID taskId with hyphens (as generated by crypto.randomUUID()) + const taskId = '8cd8e2d8-a7fc-4371-89ca-59460687c12d' + // What the LLM would write in code-exec (hyphens → underscores, valid JS identifier) + const llmGeneratedVarName = '__curate_meta_8cd8e2d8_a7fc_4371_89ca_59460687c12d' + + describe('bug: hyphenated taskId causes variable name mismatch', () => { + it('should fail with ReferenceError when variable stored with hyphens but accessed with underscores', async () => { + const sandbox = new LocalSandbox() + + // Old (buggy) behavior: store variable using raw taskId containing hyphens + const buggyMetaVar = `__curate_meta_${taskId}` // "..._8cd8e2d8-a7fc-..." + sandbox.updateContext({[buggyMetaVar]: {charCount: 100, lineCount: 5}}) + + // LLM writes underscores in code (hyphens are not valid JS identifier chars) + const result = await sandbox.execute(llmGeneratedVarName) + + // Variable is in sandbox as hyphen-version, but LLM looks up underscore-version → not found + expect(result.stderr).to.include('ReferenceError') + expect(result.stderr).to.include(llmGeneratedVarName) + }) + }) + + describe('fix: taskIdSafe with underscores eliminates mismatch', () => { + it('should succeed when variable stored with underscores matching LLM output', async () => { + const sandbox = new LocalSandbox() + + // Fixed behavior: replace hyphens before naming sandbox variables + const taskIdSafe = taskId.replaceAll('-', '_') + const fixedMetaVar = `__curate_meta_${taskIdSafe}` // all underscores + sandbox.updateContext({[fixedMetaVar]: {charCount: 100, lineCount: 5}}) + + // LLM writes underscores → now matches sandbox variable + const result = await sandbox.execute(`${llmGeneratedVarName}.charCount`) + + expect(result.stderr).to.equal('') + expect(result.returnValue).to.equal(100) + }) + + it('should correctly transform all UUID segments (4 hyphens replaced)', () => { + const taskIdSafe = taskId.replaceAll('-', '_') + + expect(taskIdSafe).to.not.include('-') + expect(taskIdSafe).to.equal('8cd8e2d8_a7fc_4371_89ca_59460687c12d') + + const metaVar = `__curate_meta_${taskIdSafe}` + expect(metaVar).to.equal(llmGeneratedVarName) + }) + }) +}) diff --git a/test/unit/server/templates/sections/mcp-workflow-template.test.ts b/test/unit/server/templates/sections/mcp-workflow-template.test.ts new file mode 100644 index 000000000..2bf2de0c5 --- /dev/null +++ b/test/unit/server/templates/sections/mcp-workflow-template.test.ts @@ -0,0 +1,82 @@ +/** + * MCP workflow template tests (Phase 5 — PHASE-5-CODE-REVIEW.md Pass 7 Finding 2). + * + * `RuleTemplateService.generateMcpContent()` loads `sections/mcp-workflow.md` + * and substitutes it into the MCP base template. The connector connectors + * (Cursor, Claude Code, etc.) install the rendered content as the MCP + * agent's primary instructions. If this template still teaches the legacy + * two-tool flow, every connector-installed agent will skip the Phase 5 + * search → gather → record-answer pipeline. + * + * These tests assert the template content directly (mirrors + * skill-template.test.ts) — `template-service.test.ts` already covers the + * substitution machinery with a mock loader, so no point re-testing that. + */ + +import {expect} from 'chai' +import {readFileSync} from 'node:fs' +import {join} from 'node:path' + +describe('sections/mcp-workflow.md template (Phase 5)', () => { + let template: string + + before(() => { + const templatePath = join( + process.cwd(), + 'src', + 'server', + 'templates', + 'sections', + 'mcp-workflow.md', + ) + template = readFileSync(templatePath, 'utf8') + }) + + describe('Tool registrations advertised', () => { + it('mentions all 5 hyphenated MCP tool names that mcp-server.ts registers', () => { + // Hyphenated names match `server.registerTool('brv-search', ...)` etc. + // Codex Pass 7 caught the prior template advertising only the legacy + // brv-query / brv-curate pair. + expect(template).to.include('brv-search') + expect(template).to.include('brv-gather') + expect(template).to.include('brv-record-answer') + expect(template).to.include('brv-curate') + expect(template).to.include('brv-query') + }) + + it('does NOT use underscored aliases (no brv_search etc.) — those tool names are not registered', () => { + expect(template).to.not.match(/\bbrv_search\b/) + expect(template).to.not.match(/\bbrv_gather\b/) + expect(template).to.not.match(/\bbrv_record_answer\b/) + expect(template).to.not.match(/\bbrv_query\b/) + expect(template).to.not.match(/\bbrv_curate\b/) + }) + }) + + describe('Phase 5 workflow guidance', () => { + it('teaches the search → gather → record-answer pipeline', () => { + const lower = template.toLowerCase() + // Pipeline ordering hint — search before gather, gather before record + const searchIdx = lower.indexOf('brv-search') + const gatherIdx = lower.indexOf('brv-gather') + const recordIdx = lower.indexOf('brv-record-answer') + expect(searchIdx).to.be.greaterThan(-1) + expect(gatherIdx).to.be.greaterThan(-1) + expect(recordIdx).to.be.greaterThan(-1) + expect(searchIdx).to.be.lessThan(gatherIdx, 'search should be introduced before gather') + expect(gatherIdx).to.be.lessThan(recordIdx, 'gather should be introduced before record-answer') + }) + + it('marks brv-query as deprecated so connector-installed agents migrate', () => { + expect(template.toLowerCase()).to.match(/deprecat/i) + }) + + it('explains the LLM-free property of the new tools (key Phase 5 invariant)', () => { + expect(template.toLowerCase()).to.match(/llm-free|no llm|never invokes/i) + }) + + it('includes the needs_synthesis escalation rule', () => { + expect(template.toLowerCase()).to.match(/needs_synthesis|status:\s*'needs_synthesis'/i) + }) + }) +}) diff --git a/test/unit/server/templates/skill/skill-template.test.ts b/test/unit/server/templates/skill/skill-template.test.ts new file mode 100644 index 000000000..f4f266c07 --- /dev/null +++ b/test/unit/server/templates/skill/skill-template.test.ts @@ -0,0 +1,155 @@ +/** + * SKILL.md template tests (Phase 5 Task 5.6). + * + * The skill connector ships SKILL.md to skill-driven agents (Claude Code, + * Cursor, Codex, etc.). The template must teach Phase 5's three tools: + * - brv-search (LLM-free tier 0/1/2) + * - brv-gather (LLM-free context bundle) + * - brv-record-answer (cache-write companion) + * + * Both surfaces matter: + * - MCP Workflow section — for clients using MCP-tool calls + * - CLI Commands sections — for skill/hook-driven agents that invoke + * `brv ` from terminal instructions + * + * Critical regression: existing CLI sections (`### 1. Query Knowledge`, + * `### 2. Search Context Tree`, `### 3. Curate Context`, etc.) MUST stay + * intact byte-for-byte — only re-numbered when new sections are inserted. + */ + +import {expect} from 'chai' +import {readFileSync} from 'node:fs' +import {join} from 'node:path' + +describe('SKILL.md template (Phase 5 Task 5.6)', () => { + let template: string + + before(() => { + const templatePath = join( + process.cwd(), + 'src', + 'server', + 'templates', + 'skill', + 'SKILL.md', + ) + template = readFileSync(templatePath, 'utf8') + }) + + describe('Workflow snippet — LLM-free synthesis loop bullet', () => { + it('top-level Workflow section includes the search → gather → record-answer loop', () => { + // Numbered bullet that names all three tools — exact wording flexible + // but the chain must be discoverable for skill-driven agents. + const workflowSection = template.slice(template.indexOf('## Workflow'), template.indexOf('## Commands')) + expect(workflowSection).to.match(/brv search/i) + expect(workflowSection).to.match(/brv gather/i) + expect(workflowSection).to.match(/brv record-answer/i) + }) + }) + + describe('MCP Workflow section', () => { + it('contains a "## MCP Workflow" section before "## Commands"', () => { + const mcpIdx = template.indexOf('## MCP Workflow') + const cmdIdx = template.indexOf('## Commands') + expect(mcpIdx).to.be.greaterThan(-1) + expect(cmdIdx).to.be.greaterThan(-1) + expect(mcpIdx).to.be.lessThan(cmdIdx) + }) + + it('names brv-search, brv-gather, and brv-record-answer in the MCP Workflow section (hyphenated — matches actual MCP registrations)', () => { + const mcpStart = template.indexOf('## MCP Workflow') + const mcpEnd = template.indexOf('## Commands') + const mcpSection = template.slice(mcpStart, mcpEnd) + + // Hyphenated names match the actual `server.registerTool('brv-search', ...)` + // registrations in mcp-server.ts. Codex Pass 7 caught the mismatch where + // SKILL.md taught underscored names that did not exist. + expect(mcpSection).to.include('brv-search') + expect(mcpSection).to.include('brv-gather') + expect(mcpSection).to.include('brv-record-answer') + // Negative regression: ensure no underscored aliases creep back + expect(mcpSection).to.not.match(/\bbrv_search\b/) + expect(mcpSection).to.not.match(/\bbrv_gather\b/) + expect(mcpSection).to.not.match(/\bbrv_record_answer\b/) + }) + + it('teaches the escalation rule (status: needs_synthesis → brv_gather)', () => { + const mcpStart = template.indexOf('## MCP Workflow') + const mcpEnd = template.indexOf('## Commands') + const mcpSection = template.slice(mcpStart, mcpEnd) + + // The four-step escalation rule from DESIGN §7.1 + expect(mcpSection).to.match(/needs_synthesis/i) + expect(mcpSection).to.match(/cached_answer|tier/i) + }) + }) + + describe('CLI sections — Gather Context Bundle', () => { + it('has a Gather Context Bundle section under ## Commands', () => { + const cmdStart = template.indexOf('## Commands') + const commands = template.slice(cmdStart) + + expect(commands).to.match(/###\s+\d+\.\s+Gather Context Bundle/i) + }) + + it('shows the brv gather command with examples', () => { + const cmdStart = template.indexOf('## Commands') + const commands = template.slice(cmdStart) + + expect(commands).to.match(/brv gather "/) + }) + + it('documents when to escalate to brv gather (after brv search has no high-confidence direct answer)', () => { + const cmdStart = template.indexOf('## Commands') + const commands = template.slice(cmdStart) + const gatherIdx = commands.search(/Gather Context Bundle/i) + const gatherSection = commands.slice(gatherIdx, gatherIdx + 2000) + + expect(gatherSection.toLowerCase()).to.match(/synthesi|brv search|no high|llm/) + }) + }) + + describe('CLI sections — Record Synthesized Answer', () => { + it('has a Record Synthesized Answer section under ## Commands', () => { + const cmdStart = template.indexOf('## Commands') + const commands = template.slice(cmdStart) + + expect(commands).to.match(/###\s+\d+\.\s+Record Synthesized Answer/i) + }) + + it('shows the brv record-answer command with --fingerprint flag', () => { + const cmdStart = template.indexOf('## Commands') + const commands = template.slice(cmdStart) + + expect(commands).to.match(/brv record-answer/) + expect(commands).to.match(/--fingerprint/) + }) + + it('documents the cache TTL so agents understand stale entries expire', () => { + const cmdStart = template.indexOf('## Commands') + const commands = template.slice(cmdStart) + const recordIdx = commands.search(/Record Synthesized Answer/i) + const recordSection = commands.slice(recordIdx, recordIdx + 2000) + + expect(recordSection.toLowerCase()).to.match(/ttl|expire|60s|60 second/) + }) + }) + + describe('No regression on existing CLI sections', () => { + it('still has Query Knowledge section', () => { + expect(template).to.match(/###\s+\d+\.\s+Query Knowledge/) + }) + + it('still has Search Context Tree section', () => { + expect(template).to.match(/###\s+\d+\.\s+Search Context Tree/) + }) + + it('still has Curate Context section', () => { + expect(template).to.match(/###\s+\d+\.\s+Curate Context/) + }) + + it('still has Version Control section', () => { + expect(template).to.match(/###\s+\d+\.\s+Version Control/) + }) + }) +})