diff --git a/apps/operator-backend/src/index.ts b/apps/operator-backend/src/index.ts index 8c2291e..f17fbe8 100644 --- a/apps/operator-backend/src/index.ts +++ b/apps/operator-backend/src/index.ts @@ -51,13 +51,7 @@ import { analyzeRedlinePackageWithGemini } from "./vision/gemini_redline_package import { buildEvidencePack } from "./evidence/evidence_pack.js"; import { maybePersistAutoTurnMemory } from "./memory/auto_turn_memory.js"; import { addProjectStandard, readProjectProfile } from "./memory/project_profile.js"; -import { - createRequirement, - listRequirements, - resolveRequirements, - retireRequirement, - reviseRequirement -} from "./memory/requirements_store.js"; +import { handleRequirementsHttpRoute } from "./memory/requirements_http_routes.js"; import { requiresMemoryAuthentication } from "./memory/requirements_route_policy.js"; import { createOpenAiClient, resolveOpenAiApiKey } from "./openai_client.js"; import { getDesktopComputerConfig, relayDesktopComputerResponse } from "./desktop_computer.js"; @@ -2635,100 +2629,10 @@ const server = http.createServer(async (req, res) => { } } - if (req.method === "GET" && url.pathname === "/memory/requirements") { - try { - const scopeKind = url.searchParams.get("scope_kind"); - const scopeId = url.searchParams.get("scope_id"); - const statusRaw = url.searchParams.get("status"); - const status = statusRaw === "retired" || statusRaw === "all" ? statusRaw : "active"; - const limit = Number.parseInt(url.searchParams.get("limit") ?? "200", 10); - const scope = scopeKind && scopeId ? { kind: scopeKind as any, id: scopeId } : undefined; - return writeJson(res, 200, { ok: true, requirements: listRequirements({ scope, status, limit }) }); - } catch (e) { - return writeJson(res, 400, { ok: false, error: e instanceof Error ? e.message : String(e) }); - } - } - - if (req.method === "POST" && url.pathname === "/memory/requirements") { - const body = await readJson(req).catch(() => null); - try { - const parsed = body && typeof body === "object" ? body as any : {}; - const saved = createRequirement({ - scope: parsed.scope, - key: parsed.key, - text: parsed.text, - tags: parsed.tags, - effective_from: parsed.effective_from, - effective_until: parsed.effective_until, - supersedes_requirement_ids: parsed.supersedes_requirement_ids, - source: parsed.source ?? "api", - session_id: parsed.session_id, - actor_id: auth.principal?.user_id ?? null, - evidence: parsed.evidence - }); - appendAuditLine({ type: "requirements.created", ts: new Date().toISOString(), requirement_id: saved.requirement.requirement_id, revision: saved.requirement.revision, principal: auth.principal ?? null }); - return writeJson(res, 201, saved); - } catch (e) { - return writeJson(res, 400, { ok: false, error: e instanceof Error ? e.message : String(e) }); - } - } - - if (req.method === "POST" && url.pathname === "/memory/requirements/revise") { - const body = await readJson(req).catch(() => null); - try { - const parsed = body && typeof body === "object" ? body as any : {}; - const saved = reviseRequirement({ - requirement_id: parsed.requirement_id, - expected_revision: Number(parsed.expected_revision), - scope: parsed.scope, - key: parsed.key, - text: parsed.text, - tags: parsed.tags, - effective_from: parsed.effective_from, - effective_until: parsed.effective_until, - supersedes_requirement_ids: parsed.supersedes_requirement_ids, - source: parsed.source ?? "api.revise", - session_id: parsed.session_id, - actor_id: auth.principal?.user_id ?? null, - evidence: parsed.evidence - }); - appendAuditLine({ type: "requirements.revised", ts: new Date().toISOString(), requirement_id: saved.requirement.requirement_id, revision: saved.requirement.revision, principal: auth.principal ?? null }); - return writeJson(res, 200, saved); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - return writeJson(res, /Revision conflict/.test(message) ? 409 : 400, { ok: false, error: message }); - } - } - - if (req.method === "POST" && url.pathname === "/memory/requirements/retire") { - const body = await readJson(req).catch(() => null); - try { - const parsed = body && typeof body === "object" ? body as any : {}; - const saved = retireRequirement({ - requirement_id: parsed.requirement_id, - expected_revision: Number(parsed.expected_revision), - source: parsed.source ?? "api.retire", - session_id: parsed.session_id, - actor_id: auth.principal?.user_id ?? null, - evidence: parsed.evidence - }); - appendAuditLine({ type: "requirements.retired", ts: new Date().toISOString(), requirement_id: saved.requirement.requirement_id, revision: saved.requirement.revision, principal: auth.principal ?? null }); - return writeJson(res, 200, saved); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - return writeJson(res, /Revision conflict/.test(message) ? 409 : 400, { ok: false, error: message }); - } - } - - if (req.method === "POST" && url.pathname === "/memory/requirements/resolve") { - const body = await readJson(req).catch(() => null); - try { - const parsed = body && typeof body === "object" ? body as any : {}; - const receipt = resolveRequirements({ scope_refs: parsed.scope_refs, query: parsed.query, at: parsed.at, max_results: parsed.max_results }); - return writeJson(res, receipt.status === "resolved" ? 200 : 409, { ok: receipt.status === "resolved", receipt }); - } catch (e) { - return writeJson(res, 400, { ok: false, error: e instanceof Error ? e.message : String(e) }); - } + const requirementsRoute = await handleRequirementsHttpRoute({ method: req.method ?? "", url, actor_id: auth.principal?.user_id ?? null, read_body: () => readJson(req) }); + if (requirementsRoute) { + if (requirementsRoute.audit) appendAuditLine({ ...requirementsRoute.audit, principal: auth.principal ?? null }); + return writeJson(res, requirementsRoute.status, requirementsRoute.body); } if (req.method === "POST" && url.pathname === "/memory/project-profile/standards") { diff --git a/apps/operator-backend/src/memory/requirements_http_routes.ts b/apps/operator-backend/src/memory/requirements_http_routes.ts new file mode 100644 index 0000000..73a3c8f --- /dev/null +++ b/apps/operator-backend/src/memory/requirements_http_routes.ts @@ -0,0 +1,133 @@ +import { + createRequirement, + listRequirements, + resolveRequirements, + retireRequirement, + reviseRequirement +} from "./requirements_store.js"; + +export type RequirementsHttpRouteResult = { + status: number; + body: unknown; + audit?: { type: string; ts: string; requirement_id: string; revision: number }; +}; + +type RequirementsHttpRouteArgs = { + method: string; + url: URL; + actor_id: string | null; + read_body: () => Promise; +}; + +function errorResult(error: unknown, revisionConflict = false): RequirementsHttpRouteResult { + const message = error instanceof Error ? error.message : String(error); + return { status: revisionConflict && /Revision conflict/.test(message) ? 409 : 400, body: { ok: false, error: message } }; +} + +export async function handleRequirementsHttpRoute(args: RequirementsHttpRouteArgs): Promise { + const { method, url, actor_id, read_body } = args; + if (method === "GET" && url.pathname === "/memory/requirements") { + try { + const scopeKind = url.searchParams.get("scope_kind"); + const scopeId = url.searchParams.get("scope_id"); + const statusRaw = url.searchParams.get("status"); + const status = statusRaw === "retired" || statusRaw === "all" ? statusRaw : "active"; + const limit = Number.parseInt(url.searchParams.get("limit") ?? "200", 10); + const scope = scopeKind && scopeId ? { kind: scopeKind as any, id: scopeId } : undefined; + return { status: 200, body: { ok: true, requirements: listRequirements({ scope, status, limit }) } }; + } catch (error) { + return errorResult(error); + } + } + + if (method === "POST" && url.pathname === "/memory/requirements") { + const body = await read_body().catch(() => null); + try { + const parsed = body && typeof body === "object" ? body as any : {}; + const saved = createRequirement({ + scope: parsed.scope, + key: parsed.key, + text: parsed.text, + tags: parsed.tags, + effective_from: parsed.effective_from, + effective_until: parsed.effective_until, + supersedes_requirement_ids: parsed.supersedes_requirement_ids, + source: parsed.source ?? "api", + session_id: parsed.session_id, + actor_id, + evidence: parsed.evidence + }); + return { + status: 201, + body: saved, + audit: { type: "requirements.created", ts: new Date().toISOString(), requirement_id: saved.requirement.requirement_id, revision: saved.requirement.revision } + }; + } catch (error) { + return errorResult(error); + } + } + + if (method === "POST" && url.pathname === "/memory/requirements/revise") { + const body = await read_body().catch(() => null); + try { + const parsed = body && typeof body === "object" ? body as any : {}; + const saved = reviseRequirement({ + requirement_id: parsed.requirement_id, + expected_revision: Number(parsed.expected_revision), + scope: parsed.scope, + key: parsed.key, + text: parsed.text, + tags: parsed.tags, + effective_from: parsed.effective_from, + effective_until: parsed.effective_until, + supersedes_requirement_ids: parsed.supersedes_requirement_ids, + source: parsed.source ?? "api.revise", + session_id: parsed.session_id, + actor_id, + evidence: parsed.evidence + }); + return { + status: 200, + body: saved, + audit: { type: "requirements.revised", ts: new Date().toISOString(), requirement_id: saved.requirement.requirement_id, revision: saved.requirement.revision } + }; + } catch (error) { + return errorResult(error, true); + } + } + + if (method === "POST" && url.pathname === "/memory/requirements/retire") { + const body = await read_body().catch(() => null); + try { + const parsed = body && typeof body === "object" ? body as any : {}; + const saved = retireRequirement({ + requirement_id: parsed.requirement_id, + expected_revision: Number(parsed.expected_revision), + source: parsed.source ?? "api.retire", + session_id: parsed.session_id, + actor_id, + evidence: parsed.evidence + }); + return { + status: 200, + body: saved, + audit: { type: "requirements.retired", ts: new Date().toISOString(), requirement_id: saved.requirement.requirement_id, revision: saved.requirement.revision } + }; + } catch (error) { + return errorResult(error, true); + } + } + + if (method === "POST" && url.pathname === "/memory/requirements/resolve") { + const body = await read_body().catch(() => null); + try { + const parsed = body && typeof body === "object" ? body as any : {}; + const receipt = resolveRequirements({ scope_refs: parsed.scope_refs, query: parsed.query, at: parsed.at, max_results: parsed.max_results }); + return { status: receipt.status === "resolved" ? 200 : 409, body: { ok: receipt.status === "resolved", receipt } }; + } catch (error) { + return errorResult(error); + } + } + + return null; +} diff --git a/apps/operator-backend/test/requirements_store.test.ts b/apps/operator-backend/test/requirements_store.test.ts index b00b7bb..1f021c2 100644 --- a/apps/operator-backend/test/requirements_store.test.ts +++ b/apps/operator-backend/test/requirements_store.test.ts @@ -21,6 +21,7 @@ import { import { runWithRequestContext, type RequestPrincipal } from "../src/request_context.js"; import { requiresMemoryAuthentication } from "../src/memory/requirements_route_policy.js"; import { captureRequirementsResponseGuard, enforceRequirementsResponseGuard, formatRequirementsPromptBlockSafely } from "../src/memory/requirements_response_policy.js"; +import { handleRequirementsHttpRoute } from "../src/memory/requirements_http_routes.js"; import { authenticateRequest } from "../src/auth.js"; import { maybeContinueAecLevelPowerPlanPilot, startAecLevelPowerPlanPilot } from "../src/deterministic/aec_level_power_plan_runtime.js"; import { AEC_SEMANTIC_TASK_V1_SCHEMA, type AecSemanticTaskV1 } from "../src/aec_semantic_task.js"; @@ -398,3 +399,30 @@ test("OpenAI requirements policy formats prompts and blocks a stale action respo assert.match(guarded.assistant_message, /changed while this plan was being prepared/); assert.equal(guarded.requirements_receipt?.applied[0]?.key, "tags.leaders"); }); + +test("requirements HTTP adapter creates, lists, and reports revision conflicts", async () => { + mkWorkspace(); + const created = await handleRequirementsHttpRoute({ + method: "POST", + url: new URL("http://localhost/memory/requirements"), + actor_id: "eli", + read_body: async () => ({ scope: scope("office", "bimtools"), key: "tags.text", text: "Use readable text." }) + }); + assert.equal(created?.status, 201); + assert.equal(created?.audit?.type, "requirements.created"); + const requirement = (created?.body as any).requirement; + const listed = await handleRequirementsHttpRoute({ + method: "GET", + url: new URL("http://localhost/memory/requirements?scope_kind=office&scope_id=bimtools"), + actor_id: "eli", + read_body: async () => null + }); + assert.equal((listed?.body as any).requirements[0].requirement_id, requirement.requirement_id); + const conflict = await handleRequirementsHttpRoute({ + method: "POST", + url: new URL("http://localhost/memory/requirements/revise"), + actor_id: "eli", + read_body: async () => ({ ...requirement, expected_revision: 0, text: "stale" }) + }); + assert.equal(conflict?.status, 409); +}); diff --git a/scripts/backend_module_size_manifest.json b/scripts/backend_module_size_manifest.json index c075bcb..06c1cbf 100644 --- a/scripts/backend_module_size_manifest.json +++ b/scripts/backend_module_size_manifest.json @@ -4,7 +4,7 @@ { "path": "brains/openai_brain.ts", "max_lines": 17625, "reason": "Legacy model orchestration monolith; durable requirements behavior is extracted to requirements_response_policy.ts, leaving five reviewed prompt/response wiring lines while provider and tool-loop seams continue to be extracted incrementally." }, { "path": "benchmark/revit_workflows.ts", "max_lines": 10838, "reason": "Legacy benchmark workflow dispatcher; extract one independently tested workflow family per batch." }, { "path": "benchmark/redline_routing_readiness.ts", "max_lines": 6285, "reason": "Legacy readiness report aggregator awaiting report-section extraction." }, - { "path": "index.ts", "max_lines": 4146, "reason": "Backend composition root; private hosted routes make the integration variant larger than public. Added page-range forwarding for bounded large-PDF analysis." }, + { "path": "index.ts", "max_lines": 4155, "reason": "Backend composition root; private hosted routes make the integration variant larger than public. Durable requirements routes are extracted to requirements_http_routes.ts, leaving nine reviewed authentication, adapter, and audit wiring lines alongside bounded large-PDF page-range forwarding." }, { "path": "redline/redline_analyzer.ts", "max_lines": 3238, "reason": "Sheet classification and pure PDF annotation geometry are extracted; page analysis and image rendering remain coupled pending later bounded splits." }, { "path": "deterministic/mep_route_redline.ts", "max_lines": 2914, "reason": "Deterministic MEP route resolver awaiting parsing and scoring extraction; one policy import aligns its PDF budget." }, { "path": "redline/corpus_classifier.ts", "max_lines": 2876, "reason": "Corpus classification and promotion preparation remain coupled pending a focused split." },