diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 0856d752..18e3ad6c 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -327,8 +327,9 @@ jobs: exit 1 fi git fetch --no-tags origin "+refs/heads/${REQUIRED_PRIVATE_BRANCH}:refs/remotes/origin/${REQUIRED_PRIVATE_BRANCH}" - if ! git merge-base --is-ancestor "$RELEASE_PRIVATE_SHA" "origin/$REQUIRED_PRIVATE_BRANCH"; then - echo "Private revision must be promoted through role-model-internal/$REQUIRED_PRIVATE_BRANCH before $ROLE_MODEL_BUILD_CHANNEL packaging." + required_private_head="$(git rev-parse "origin/$REQUIRED_PRIVATE_BRANCH")" + if [[ "$RELEASE_PRIVATE_SHA" != "$required_private_head" ]]; then + echo "Paired private revision must equal the current role-model-internal/$REQUIRED_PRIVATE_BRANCH head before $ROLE_MODEL_BUILD_CHANNEL packaging." exit 1 fi diff --git a/role-model-router/apps/runtime-host-bridge/src/cli.ts b/role-model-router/apps/runtime-host-bridge/src/cli.ts index 05bf1709..9ef0138c 100644 --- a/role-model-router/apps/runtime-host-bridge/src/cli.ts +++ b/role-model-router/apps/runtime-host-bridge/src/cli.ts @@ -1012,10 +1012,9 @@ export async function main(): Promise { // available at runtime. const currentPostObservationOperations = (): ReturnType | null => postObservationOperations; - const drainPostObservationOutbox = async ( - runtime: Awaited>, - ) => - postObservationOutbox.drain((observation) => { + const postObservationHandler = + (runtime: Awaited>) => + (observation: Parameters[1]) => { const processingInput = { scope: options.scopeId, channel: packagedProfile?.channel ?? "development", @@ -1036,7 +1035,10 @@ export async function main(): Promise { (aggregate) => operations.recordContributionAggregate(aggregate), ) : runTrackBPostObservation(runtime, observation, processingInput); - }); + }; + const drainPostObservationOutbox = async ( + runtime: Awaited>, + ) => postObservationOutbox.drain(postObservationHandler(runtime)); const createBackend = async ( trackBOperationsEndpoint?: string, trackBOperationsToken?: string, @@ -1097,14 +1099,17 @@ export async function main(): Promise { readTrackBExtensionReadback: async (body) => { const requestId = String(body.requestId ?? "").trim(); if (!requestId) throw new Error("Track B extension readback requestId is required"); - const receipt = await postObservationOutbox.readReceipt(requestId); + const runtime = extensionRuntimeRef.current; + if (!runtime) throw new Error("Track B extension runtime is unavailable"); + const receipt = await postObservationOutbox.drainUntilReceipt( + requestId, + postObservationHandler(runtime), + ); if (!receipt) throw new Error(`Track B observation receipt not found: ${requestId}`); const result = receipt.result as Record; const closure = result.extensionClosure as TrackBExtensionClosure | undefined; if (!closure) throw new Error(`Track B observation has no extension closure: ${requestId}`); - const runtime = extensionRuntimeRef.current; - if (!runtime) throw new Error("Track B extension runtime is unavailable"); return verifyTrackBExtensionClosureAfterRestart(runtime, closure, { channel: packagedProfile?.channel ?? "development", scope: options.scopeId, diff --git a/role-model-router/apps/runtime-host-bridge/src/index.ts b/role-model-router/apps/runtime-host-bridge/src/index.ts index 83e5d7fd..11d05d6a 100644 --- a/role-model-router/apps/runtime-host-bridge/src/index.ts +++ b/role-model-router/apps/runtime-host-bridge/src/index.ts @@ -24070,6 +24070,7 @@ export async function createRuntimeBridgeBackend( | { readonly scopeId: string; readonly artifactId: string; readonly contentHash: string } | undefined; let routeCapture: Record | undefined; + let routeCaptureDegradationReason: string | undefined; try { if (localGraphStore) { const content = JSON.stringify(bundle); @@ -24116,10 +24117,25 @@ export async function createRuntimeBridgeBackend( }; } } - } catch { + } catch (error) { // Capture remains non-routing-critical before graph-primary cutover. // Run 94 (SP2): without a graph artifact reference the SQLite row must still be // bounded — persist the compact degradation stub instead of the full bundle. + // Keep the operator receipt actionable without copying a private-boundary + // message, which may include untrusted capture content. + const status = + error && + typeof error === "object" && + "status" in error && + typeof error.status === "number" && + Number.isInteger(error.status) + ? error.status + : undefined; + routeCaptureDegradationReason = + status && status >= 100 && status <= 599 + ? `track-b-capture-boundary-http-${status}` + : "track-b-capture-boundary-unavailable"; + console.error("Track B route capture failed", routeCaptureDegradationReason); } const graphEvidence = routeCapture ? { @@ -24160,9 +24176,11 @@ export async function createRuntimeBridgeBackend( schemaVersion: "role-model.degradation-receipt.v1", degraded: true, capability: "runtime-observation-persist", - reason: String( - error instanceof Error ? error.message : "runtime observation persist failed", - ).slice(0, 256), + reason: + routeCaptureDegradationReason ?? + String( + error instanceof Error ? error.message : "runtime observation persist failed", + ).slice(0, 256), routingContinues: true, atMs: Date.now(), }; @@ -29191,10 +29209,6 @@ export function resolveBridgeServerOptions(input: { const packagedProfile = readPackagedRuntimeProfile(input.executablePath); const profile = packagedProfile ?? resolveRuntimeChannelProfile("production"); const statePath = resolveBridgePathApi([input.localAppData], process.env.LOCALAPPDATA); - const runtimeStatePath = resolveBridgePathApi( - [input.runtimeStateRoot, input.localAppData], - process.env.LOCALAPPDATA, - ); const inferredRepoRoot = input.executablePath ? (() => { const executableDir = repoPath.dirname(repoPath.resolve(input.executablePath)); @@ -29226,6 +29240,13 @@ export function resolveBridgeServerOptions(input: { statePath.join(os.homedir(), ".local", "state"); const runtimeStateRoot = input.runtimeStateRoot?.trim() || statePath.join(platformStateBase, profile.state_root_name); + const explicitUnifiedRuntimeConfigPath = input.unifiedRuntimeConfigPath?.trim(); + const stateRootUnifiedRuntimeConfigPath = statePath.join(runtimeStateRoot, "runtime-config.yaml"); + const legacyUnifiedRuntimeConfigPath = statePath.join( + runtimeStateRoot, + "state", + "runtime-config.yaml", + ); return { host: input.host?.trim() || profile.host, @@ -29240,7 +29261,9 @@ export function resolveBridgeServerOptions(input: { preferRepoRootBuild: Boolean(input.repoRoot?.trim()) || Boolean(packagedProfile), }), unifiedRuntimeConfigPath: - input.unifiedRuntimeConfigPath?.trim() || - runtimeStatePath.join(runtimeStateRoot, "state", "runtime-config.yaml"), + explicitUnifiedRuntimeConfigPath || + (existsSync(stateRootUnifiedRuntimeConfigPath) + ? stateRootUnifiedRuntimeConfigPath + : legacyUnifiedRuntimeConfigPath), }; } diff --git a/role-model-router/apps/runtime-host-bridge/src/track-b-operations.ts b/role-model-router/apps/runtime-host-bridge/src/track-b-operations.ts index 3f975dff..b5c158c4 100644 --- a/role-model-router/apps/runtime-host-bridge/src/track-b-operations.ts +++ b/role-model-router/apps/runtime-host-bridge/src/track-b-operations.ts @@ -661,6 +661,10 @@ const privateRetentionRequest = async ( token: string | undefined, route: string, init: { readonly method?: string; readonly body?: Record } = {}, + // Route captures may perform bounded durable CAS and SQLite commits after the + // provider response. Five seconds aborts healthy local captures on mature + // runtimes; retain a finite budget while allowing that proven completion path. + timeoutMs = 8_000, ): Promise => { if (!endpoint) return null; if (!token || token.trim().length < 24) { @@ -668,14 +672,26 @@ const privateRetentionRequest = async ( "Track B private operations boundary requires a launcher-issued authentication token", ); } - const response = await fetch(new URL(route, endpoint.endsWith("/") ? endpoint : `${endpoint}/`), { - method: init.method ?? "GET", - headers: { - ...(init.body ? { "content-type": "application/json" } : {}), - authorization: `Bearer ${token}`, - }, - ...(init.body ? { body: JSON.stringify(init.body) } : {}), - }); + let response: Response; + try { + response = await fetch(new URL(route, endpoint.endsWith("/") ? endpoint : `${endpoint}/`), { + method: init.method ?? "GET", + headers: { + ...(init.body ? { "content-type": "application/json" } : {}), + authorization: `Bearer ${token}`, + }, + ...(init.body ? { body: JSON.stringify(init.body) } : {}), + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (error) { + if (error instanceof Error && error.name === "TimeoutError") { + throw new TrackBPrivateOperationError( + 504, + `private Track B operation timed out after ${timeoutMs}ms`, + ); + } + throw error; + } const result = (await response.json().catch(() => ({}))) as { readonly error?: unknown }; if (!response.ok) throw new TrackBPrivateOperationError( @@ -1060,6 +1076,7 @@ export function createTrackBOperations({ runtimeChannel = "development", operationsEndpoint = process.env.ROLE_MODEL_TRACK_B_OPERATIONS_URL?.trim(), operationsToken = process.env.ROLE_MODEL_TRACK_B_OPERATIONS_TOKEN, + operationsTimeoutMs = 8_000, extensionRuntime, }: { readonly statePath: string; @@ -1067,6 +1084,8 @@ export function createTrackBOperations({ readonly runtimeChannel?: "development" | "stage" | "production"; readonly operationsEndpoint?: string; readonly operationsToken?: string; + /** Bounds a private sidecar operation so a dashboard request cannot wait forever. */ + readonly operationsTimeoutMs?: number; readonly extensionRuntime?: { listExtensions(): readonly unknown[] | Promise; mutateExtension(input: Record): unknown | Promise; @@ -1075,7 +1094,8 @@ export function createTrackBOperations({ const requestPrivate = ( route: string, init?: { readonly method?: string; readonly body?: Record }, - ) => privateRetentionRequest(operationsEndpoint, operationsToken, route, init); + ) => + privateRetentionRequest(operationsEndpoint, operationsToken, route, init, operationsTimeoutMs); return { async readGraphMigration(): Promise { const remote = await requestPrivate("graph-migration"); diff --git a/role-model-router/apps/runtime-host-bridge/src/track-b-runtime.ts b/role-model-router/apps/runtime-host-bridge/src/track-b-runtime.ts index 6b60497d..4feb2e13 100644 --- a/role-model-router/apps/runtime-host-bridge/src/track-b-runtime.ts +++ b/role-model-router/apps/runtime-host-bridge/src/track-b-runtime.ts @@ -1792,6 +1792,15 @@ export function createTrackBPostObservationOutbox({ } }); }, + async drainUntilReceipt( + requestId: string, + handler: (observation: TrackBPostObservationWorkItem) => Promise, + ): Promise { + const existing = await this.readReceipt(requestId); + if (existing) return existing; + await this.drain(handler); + return this.readReceipt(requestId); + }, async read(): Promise<{ readonly pendingCount: number; readonly receiptCount: number; diff --git a/role-model-router/apps/runtime-host-bridge/test/index.test.ts b/role-model-router/apps/runtime-host-bridge/test/index.test.ts index 7b10bda0..a8273036 100644 --- a/role-model-router/apps/runtime-host-bridge/test/index.test.ts +++ b/role-model-router/apps/runtime-host-bridge/test/index.test.ts @@ -23616,10 +23616,37 @@ describe("runtime-host-bridge", () => { runtimeStateRoot: "C:\\runtime-state", scopeId: "standalone-runtime", staticRoot: path.join(repoRoot, "role-model-router", "apps", "runtime-ui", "build", "client"), - unifiedRuntimeConfigPath: "C:\\runtime-state\\state\\runtime-config.yaml", + unifiedRuntimeConfigPath: path.join("C:\\runtime-state", "state", "runtime-config.yaml"), }); }); + test("prefers an existing state-root runtime config when a packaged launch omits the flag", async () => { + const runtimeStateRoot = await mkdtemp( + path.join(os.tmpdir(), "role-model-runtime-config-default-"), + ); + try { + await writeFile( + path.join(runtimeStateRoot, "runtime-config.yaml"), + 'version: "1.0"\n', + "utf8", + ); + const result = ( + bridge as { + resolveBridgeServerOptions: (value: { + repoRoot?: string; + runtimeStateRoot?: string; + }) => { unifiedRuntimeConfigPath: string }; + } + ).resolveBridgeServerOptions({ repoRoot, runtimeStateRoot }); + + expect(result.unifiedRuntimeConfigPath).toBe( + path.join(runtimeStateRoot, "runtime-config.yaml"), + ); + } finally { + await rm(runtimeStateRoot, { recursive: true, force: true }); + } + }); + test("keeps repoRoot-derived static paths stable when runtimeStateRoot uses a different path dialect", () => { const result = ( bridge as { @@ -23654,7 +23681,7 @@ describe("runtime-host-bridge", () => { scopeId: "standalone-runtime", staticRoot: "/home/runner/work/role-model/role-model/role-model-router/apps/runtime-ui/build/client", - unifiedRuntimeConfigPath: "C:\\runtime-state\\state\\runtime-config.yaml", + unifiedRuntimeConfigPath: path.join("C:\\runtime-state", "state", "runtime-config.yaml"), }); }); diff --git a/role-model-router/apps/runtime-host-bridge/test/run94-sp5-sp10.test.ts b/role-model-router/apps/runtime-host-bridge/test/run94-sp5-sp10.test.ts index eab26344..a863f156 100644 --- a/role-model-router/apps/runtime-host-bridge/test/run94-sp5-sp10.test.ts +++ b/role-model-router/apps/runtime-host-bridge/test/run94-sp5-sp10.test.ts @@ -76,6 +76,39 @@ test("GREEN: post-observation outbox is a normalized SQLite authority with bound afterDrain.close(); }); +test("GREEN: a readback-driven retry drains a transiently failed pending observation without another routed request", async () => { + const root = await import("node:fs/promises").then(({ mkdtemp }) => + mkdtemp(path.join(os.tmpdir(), "run95-readback-retry-")), + ); + roots.push(root); + const outbox = createTrackBPostObservationOutbox({ + filePath: path.join(root, "post-observation-outbox.sqlite"), + maxItems: 8, + }); + await outbox.enqueue(identity("retry-without-next-route")); + await expect( + outbox.drain(async () => { + throw new Error("temporary private operation timeout"); + }), + ).rejects.toThrow(/temporary private operation timeout/); + expect(await outbox.read()).toMatchObject({ pendingCount: 1, receiptCount: 0 }); + + const recovered = await ( + outbox as unknown as { + drainUntilReceipt( + requestId: string, + handler: (item: Record) => Promise, + ): Promise<{ requestId: string } | null>; + } + ).drainUntilReceipt("retry-without-next-route", async (item) => ({ + status: "recovered", + extensionClosure: { requestId: item.requestId }, + })); + + expect(recovered).toMatchObject({ requestId: "retry-without-next-route" }); + expect(await outbox.read()).toMatchObject({ pendingCount: 0, receiptCount: 1 }); +}); + test("GREEN: imports N-1 JSON once, classifies every legacy row, and quarantines malformed rows", async () => { const root = await import("node:fs/promises").then(({ mkdtemp }) => mkdtemp(path.join(os.tmpdir(), "run94-sp5-legacy-")), diff --git a/role-model-router/apps/runtime-host-bridge/test/track-b-operations-api.test.ts b/role-model-router/apps/runtime-host-bridge/test/track-b-operations-api.test.ts index db5f19f0..9681288e 100644 --- a/role-model-router/apps/runtime-host-bridge/test/track-b-operations-api.test.ts +++ b/role-model-router/apps/runtime-host-bridge/test/track-b-operations-api.test.ts @@ -1205,6 +1205,80 @@ describe("Track B operations APIs", () => { } }); + test("reports a bounded private capture failure instead of masking it as a graph writer error", async () => { + const runtimeStateRoot = path.join(os.tmpdir(), `track-b-capture-failure-${Date.now()}`); + roots.push(runtimeStateRoot); + const scopeId = "track-b-capture-failure"; + const operations = createServer(async (request, response) => { + for await (const _chunk of request) { + // Drain the request without retaining potentially rich capture content. + } + if (request.url === "/capture/route") { + response.writeHead(503, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "capture service unavailable" })); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ status: "accepted" })); + }); + await new Promise((resolve, reject) => { + operations.once("error", reject); + operations.listen(0, "127.0.0.1", resolve); + }); + const address = operations.address(); + if (!address || typeof address === "string") throw new Error("operations server did not bind"); + const backend = await createRuntimeBridgeBackend({ + repoRoot, + fixtureRoot, + runtimeStateRoot, + scopeId, + trackBOperationsEndpoint: `http://127.0.0.1:${address.port}`, + trackBOperationsToken: "c".repeat(64), + }); + try { + const databasePath = resolveSqliteMemoryLocation({ runtimeStateRoot, scopeId }); + const migration = new LegacySqliteMigration({ + databasePath, + backupPath: path.join(runtimeStateRoot, "legacy-backup.sqlite"), + artifactWriter: ({ contentHash }) => ({ + artifactId: contentHash, + artifactPath: `artifact://${contentHash}`, + contentHash, + }), + routerRoot: path.join(repoRoot, "role-model-router"), + }); + migration.backfill({ scopeId: `tenant:${scopeId}`, batchSize: 10 }); + migration.enterShadowMirror({ deadlineMs: Date.now() + 10_000 }); + migration.verifyParity({ + backupVerified: true, + restoreVerified: true, + consumersVerified: true, + }); + migration.cutover(); + + const result = await backend.executeChatCompletions( + { + model: "deepseek/chat-capture-v1", + messages: [{ role: "user", content: "verify bounded capture failure" }], + }, + "req-track-b-capture-failure-001", + ); + + expect(result.persistenceDegradation).toMatchObject({ + capability: "runtime-observation-persist", + reason: "track-b-capture-boundary-http-503", + }); + expect( + readRuntimeTelemetryRecord({ databasePath, requestId: "req-track-b-capture-failure-001" }), + ).toBeNull(); + } finally { + await backend.shutdown(); + await new Promise((resolve, reject) => + operations.close((error) => (error ? reject(error) : resolve())), + ); + } + }); + test("legacy failure recovery retries extension closure after graph commit and propagates sidecar auth failures", async () => { const runtimeStateRoot = path.join(os.tmpdir(), `track-b-recovery-retry-${Date.now()}`); roots.push(runtimeStateRoot); @@ -2061,6 +2135,77 @@ describe("Track B operations APIs", () => { } }); + test("run95 bounds a stalled private storage summary instead of leaving the operator read pending", async () => { + const operations = createServer((_request, _response) => { + // Deliberately never respond: this models a stalled private sidecar. + }); + await new Promise((resolve, reject) => { + operations.once("error", reject); + operations.listen(0, "127.0.0.1", resolve); + }); + try { + const address = operations.address(); + if (!address || typeof address === "string") + throw new Error("operations server did not bind"); + const api = createTrackBOperations({ + statePath: path.join(os.tmpdir(), `run95-stalled-storage-${Date.now()}.json`), + catalog: [], + operationsEndpoint: `http://127.0.0.1:${address.port}`, + operationsToken: "run95-stalled-storage-token-0001", + operationsTimeoutMs: 25, + }); + const startedAt = Date.now(); + await expect(api.readStorageRetention()).rejects.toThrow(/timed out/i); + expect(Date.now() - startedAt).toBeLessThan(1_000); + } finally { + await new Promise((resolve, reject) => + operations.close((error) => (error ? reject(error) : resolve())), + ); + } + }); + + test("run95 allows a bounded local route capture to complete beyond the legacy five-second budget", async () => { + const operations = createServer((request, response) => { + if (request.method !== "POST" || request.url !== "/capture/route") { + response.writeHead(404).end(); + return; + } + setTimeout(() => { + response.writeHead(200, { "content-type": "application/json" }).end( + JSON.stringify({ + status: "captured", + scope: "runtime:test", + rootArtifactId: "artifact:test", + rootArtifactDigest: "sha256:test", + }), + ); + }, 5_500); + }); + await new Promise((resolve, reject) => { + operations.once("error", reject); + operations.listen(0, "127.0.0.1", resolve); + }); + try { + const address = operations.address(); + if (!address || typeof address === "string") + throw new Error("operations server did not bind"); + const api = createTrackBOperations({ + statePath: path.join(os.tmpdir(), `run95-capture-budget-${Date.now()}.json`), + catalog: [], + operationsEndpoint: `http://127.0.0.1:${address.port}`, + operationsToken: "run95-capture-budget-token-0001", + }); + await expect(api.recordLocalRouteCapture({ requestId: "request-1" })).resolves.toMatchObject({ + status: "captured", + rootArtifactId: "artifact:test", + }); + } finally { + await new Promise((resolve, reject) => + operations.close((error) => (error ? reject(error) : resolve())), + ); + } + }); + test("run79 mutateExtension enables disables and sets mode with audit receipts", async () => { const runtimeStateRoot = path.join(os.tmpdir(), `track-b-run79-mutate-${Date.now()}`); roots.push(runtimeStateRoot); diff --git a/role-model-router/packages/core/src/taxonomy/index.test.ts b/role-model-router/packages/core/src/taxonomy/index.test.ts new file mode 100644 index 00000000..cf3851dc --- /dev/null +++ b/role-model-router/packages/core/src/taxonomy/index.test.ts @@ -0,0 +1,14 @@ +import path from "node:path"; +import { describe, expect, test } from "vitest"; + +import { taxonomyDataRootCandidates } from "./index.js"; + +describe("taxonomyDataRootCandidates", () => { + test("R95 resolves the taxonomy staged next to a packaged executable", () => { + const executablePath = path.join("D:", "release", "role-model-dev.exe"); + + expect(taxonomyDataRootCandidates(executablePath)).toContain( + path.join("D:", "release", "role-model-router", "packages", "core", "data", "taxonomy"), + ); + }); +}); diff --git a/role-model-router/packages/core/src/taxonomy/index.ts b/role-model-router/packages/core/src/taxonomy/index.ts index 2d969e62..6ae72f43 100644 --- a/role-model-router/packages/core/src/taxonomy/index.ts +++ b/role-model-router/packages/core/src/taxonomy/index.ts @@ -152,11 +152,21 @@ function readArgValue(name: string): string | undefined { return index >= 0 ? process.argv[index + 1] : undefined; } -const taxonomyDataRootCandidates = (): string[] => { +export const taxonomyDataRootCandidates = (executablePath = process.execPath): string[] => { const explicitRoot = process.env.ROLE_MODEL_TAXONOMY_DATA_ROOT; const repoRoot = readArgValue("--repo-root") ?? process.env.ROLE_MODEL_REPO_ROOT; return [ ...(explicitRoot ? [explicitRoot] : []), + // A SEA executable has no source checkout to resolve from. Packaging stages this + // directory beside the executable, so prefer that self-contained release path. + path.join( + path.dirname(executablePath), + "role-model-router", + "packages", + "core", + "data", + "taxonomy", + ), ...(repoRoot ? [path.join(repoRoot, "role-model-router", "packages", "core", "data", "taxonomy")] : []), diff --git a/scripts/build-binaries-workflow.test.mjs b/scripts/build-binaries-workflow.test.mjs index f35cabed..630380c1 100644 --- a/scripts/build-binaries-workflow.test.mjs +++ b/scripts/build-binaries-workflow.test.mjs @@ -54,7 +54,11 @@ test("the public release orchestrator enforces paired private promotion", () => assert.match(workflow, /fetch-depth: 0/); assert.match(workflow, /Verify private revision passed paired promotion branch/); assert.match(workflow, /REQUIRED_PRIVATE_BRANCH:[\s\S]*?'main'[\s\S]*?'stage'/); - assert.match(workflow, /git merge-base --is-ancestor/); + assert.match( + workflow, + /required_private_head="\$\(git rev-parse "origin\/\$REQUIRED_PRIVATE_BRANCH"\)"/, + ); + assert.match(workflow, /\[\[ "\$RELEASE_PRIVATE_SHA" != "\$required_private_head" \]\]/); }); test("paired private checkout never dirties the public package provenance worktree", () => {