diff --git a/docs/harmonization-status.md b/docs/harmonization-status.md new file mode 100644 index 0000000..c163844 --- /dev/null +++ b/docs/harmonization-status.md @@ -0,0 +1,24 @@ +# Passive harmonization status catalog + +Phase 4 adds a read-only `pi-rogue.harmonization-status.v1` catalog built at the bundle composition boundary. It preserves the existing registration order: + +1. Advisor +2. Router +3. Orchestration +4. Context Broker + +Each entry is a `FeatureStatusV1` snapshot owned by its feature. The catalog is an introspection/reporting projection only: it cannot start or stop work, select a model, mutate policy, dispatch workers, or write state. + +## Privacy boundary + +Status serialization validates the schema and rejects unknown/prohibited fields, including prompts, transcripts, payloads, paths, credentials, raw content, and user identifiers. Adapters expose bounded categorical state only; they do not expose state paths, model responses, or user content. + +The checked-in implementation provides read-only adapters for Advisor, Router, Orchestration, and Context Broker. Adapter failures become an explicit `error` entry without forwarding exception text. + +## Deterministic local report + +```sh +npm run harmonization:status -- --output /tmp/pi-rogue-harmonization-status.v1.json +``` + +The command uses synthetic, representative status fixtures and is a schema/process check—not a production usage, spend, quality, or savings claim. Runtime collection remains local and passive. Representative runtime evidence must be sampled and reviewed separately before any consolidation or deletion decision. diff --git a/package.json b/package.json index e10035c..a899512 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "board:eval": "tsx scripts/select-board-fixtures.ts", "budget-board:smoke": "tsx scripts/smoke-budget-board-profile.ts", "harmonization:measure": "tsx scripts/harmonization-measurement.ts", - "harmonization:evidence": "tsx scripts/harmonization-evidence.ts" + "harmonization:evidence": "tsx scripts/harmonization-evidence.ts", + "harmonization:status": "tsx scripts/harmonization-status.ts" }, "engines": { "node": ">=22.19.0" diff --git a/packages/advisor/src/index.ts b/packages/advisor/src/index.ts index 7bbc0eb..b48d67e 100644 --- a/packages/advisor/src/index.ts +++ b/packages/advisor/src/index.ts @@ -14,4 +14,5 @@ export type { SubagentReturnSummary, } from "./board.js"; export { reviewWorkerResult } from "./worker-review.js"; +export { advisorFeatureStatus, serializeAdvisorFeatureStatus } from "./status.js"; export type { WorkerReviewInput, WorkerReviewResult } from "./worker-review.js"; diff --git a/packages/advisor/src/status.test.ts b/packages/advisor/src/status.test.ts new file mode 100644 index 0000000..73b6367 --- /dev/null +++ b/packages/advisor/src/status.test.ts @@ -0,0 +1,45 @@ +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("advisorFeatureStatus", () => { + it("reports built-in defaults without creating state", async () => { + const home = mkdtempSync(join(mkdtempSync(join("/tmp", "pi-rogue-advisor-status-")), "home-")); + vi.stubEnv("HOME", home); + vi.resetModules(); + const { advisorFeatureStatus } = await import("./status.js"); + + expect(advisorFeatureStatus()).toMatchObject({ + schema: "FeatureStatusV1", + feature: "advisor", + health: "unconfigured", + enabled: true, + mode: "auto", + }); + expect(existsSync(join(home, ".pi", "agent", "pi-rogue"))).toBe(false); + }); + + it("reports disabled and malformed config without exposing paths", async () => { + const home = mkdtempSync(join("/tmp", "pi-rogue-advisor-status-")); + vi.stubEnv("HOME", home); + const dir = join(home, ".pi", "agent", "pi-rogue", "advisor"); + mkdirSync(dir, { recursive: true }); + const config = join(dir, "config.json"); + writeFileSync(config, JSON.stringify({ mode: "off" }), "utf8"); + vi.resetModules(); + const { advisorFeatureStatus } = await import("./status.js"); + expect(advisorFeatureStatus()).toMatchObject({ health: "disabled", enabled: false, mode: "off" }); + expect(JSON.stringify(advisorFeatureStatus())).not.toContain(home); + + writeFileSync(config, "{not-json", "utf8"); + expect(advisorFeatureStatus().health).toBe("error"); + + writeFileSync(config, JSON.stringify({ mode: "../unsafe" }), "utf8"); + expect(advisorFeatureStatus()).toMatchObject({ health: "error", mode: "auto" }); + }); +}); diff --git a/packages/advisor/src/status.ts b/packages/advisor/src/status.ts new file mode 100644 index 0000000..5e41e71 --- /dev/null +++ b/packages/advisor/src/status.ts @@ -0,0 +1,48 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createFeatureStatusV1, serializeFeatureStatusV1, type FeatureStatusV1 } from "@fiale-plus/pi-core"; + +function configPath(): string { + return join(homedir(), ".pi", "agent", "pi-rogue", "advisor", "config.json"); +} + +const MODES = new Set(["auto", "manual", "off"]); + +function readAdvisorConfig(): { present: boolean; valid: boolean; mode?: string } { + const path = configPath(); + if (!existsSync(path)) return { present: false, valid: true }; + try { + const value: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!value || typeof value !== "object" || Array.isArray(value)) return { present: true, valid: false }; + const mode = (value as Record).mode; + const valid = mode === undefined || (typeof mode === "string" && MODES.has(mode)); + return { present: true, valid, mode: valid && typeof mode === "string" ? mode : undefined }; + } catch { + return { present: true, valid: false }; + } +} + +/** Read-only Advisor status adapter. It never creates, migrates, or writes state. */ +export function advisorFeatureStatus(): FeatureStatusV1 { + const config = readAdvisorConfig(); + const mode = config.mode ?? "auto"; + const enabled = mode !== "off"; + const health = !config.present ? "unconfigured" : !config.valid ? "error" : !enabled ? "disabled" : "ready"; + return createFeatureStatusV1({ + feature: "advisor", + owner: "advisor", + health, + enabled, + mode, + summary: !config.present ? "advisor uses built-in defaults" : enabled ? "advisor is available" : "advisor is disabled", + diagnostics: { + configPresent: config.present, + configValid: config.valid, + }, + }); +} + +export function serializeAdvisorFeatureStatus(): string { + return serializeFeatureStatusV1(advisorFeatureStatus()); +} diff --git a/packages/bundle/src/context-broker-default.ts b/packages/bundle/src/context-broker-default.ts index 73bf465..4a08b34 100644 --- a/packages/bundle/src/context-broker-default.ts +++ b/packages/bundle/src/context-broker-default.ts @@ -9,8 +9,11 @@ function contextBrokerEnabled(): boolean { } export async function registerDefaultContextBroker(pi: ExtensionAPI): Promise { - if (!contextBrokerEnabled()) return; const p = pi as any; + if (!contextBrokerEnabled()) { + p.__piRogueContextBrokerStatus = { enabled: false, registered: false }; + return; + } try { const { registerContextBrokerBeta } = await import("@fiale-plus/pi-rogue-context-broker/extension"); const durableEnv = String(process.env.PI_CONTEXT_BROKER_DURABLE ?? "").trim().toLowerCase(); @@ -20,8 +23,11 @@ export async function registerDefaultContextBroker(pi: ExtensionAPI): Promise { "pi-rogue-context", ]); expect(["advisor", "router"].some((name) => commands.has(name))).toBe(false); + expect(typeof (pi as any).__piRogueFeatureStatusCatalog).toBe("function"); + expect((pi as any).__piRogueFeatureStatusCatalog({}).features.map((status: any) => status.feature)).toEqual([ + "advisor", "router", "orchestration", "context-broker", + ]); }); it("honors canonical durability, store, and backend environment precedence", async () => { diff --git a/packages/bundle/src/extension.ts b/packages/bundle/src/extension.ts index 9903dec..1469785 100644 --- a/packages/bundle/src/extension.ts +++ b/packages/bundle/src/extension.ts @@ -3,6 +3,7 @@ import { registerAdvisor } from "@fiale-plus/pi-rogue-advisor"; import { registerOrchestration } from "@fiale-plus/pi-rogue-orchestration"; import { registerRouter } from "@fiale-plus/pi-rogue-router/extension"; import { registerDefaultContextBroker } from "./context-broker-default.js"; +import { createHarmonizationStatusCatalog } from "./status-catalog.js"; export async function registerBundle(pi: ExtensionAPI): Promise { const p = pi as any; @@ -14,6 +15,9 @@ export async function registerBundle(pi: ExtensionAPI): Promise { registerOrchestration(pi); await registerDefaultContextBroker(pi); + p.__piRogueFeatureStatusCatalog = (ctx: unknown) => createHarmonizationStatusCatalog(ctx, { + contextBroker: () => p.__piRogueContextBrokerStatus ?? { enabled: false, registered: false }, + }); } export default function bundleExtension(pi: ExtensionAPI): Promise { diff --git a/packages/bundle/src/index.ts b/packages/bundle/src/index.ts index b8120e3..661e31d 100644 --- a/packages/bundle/src/index.ts +++ b/packages/bundle/src/index.ts @@ -1 +1,8 @@ export { default, registerBundle } from "./extension.js"; +export { + createHarmonizationStatusCatalog, + serializeHarmonizationStatusCatalog, + HARMONIZATION_STATUS_CATALOG_SCHEMA, + FEATURE_ORDER, +} from "./status-catalog.js"; +export type { FeatureStatusProvider, HarmonizationStatusCatalogV1, HarmonizationStatusSources } from "./status-catalog.js"; diff --git a/packages/bundle/src/status-catalog.test.ts b/packages/bundle/src/status-catalog.test.ts new file mode 100644 index 0000000..83f6626 --- /dev/null +++ b/packages/bundle/src/status-catalog.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { createFeatureStatusV1 } from "@fiale-plus/pi-core"; +import { + createHarmonizationStatusCatalog, + HARMONIZATION_STATUS_CATALOG_SCHEMA, + serializeHarmonizationStatusCatalog, +} from "./status-catalog.js"; + +const status = (feature: string, health: "idle" | "ready" = "idle") => createFeatureStatusV1({ + feature, + owner: feature, + health, + enabled: true, + mode: health, +}); + +describe("harmonization status catalog", () => { + it("keeps explicit registration order and uses only passive providers", () => { + const calls: string[] = []; + const catalog = createHarmonizationStatusCatalog({}, { + advisor: () => { calls.push("advisor"); return status("advisor"); }, + router: () => { calls.push("router"); return status("router", "ready"); }, + orchestration: () => { calls.push("orchestration"); return status("orchestration"); }, + contextBroker: () => { calls.push("context-broker"); return { enabled: true, registered: true, durable: false, backend: "memory" }; }, + }); + + expect(calls).toEqual(["advisor", "router", "orchestration", "context-broker"]); + expect(catalog).toMatchObject({ + schema: HARMONIZATION_STATUS_CATALOG_SCHEMA, + features: [ + { feature: "advisor" }, + { feature: "router", health: "ready" }, + { feature: "orchestration" }, + { feature: "context-broker", health: "ready", mode: "memory" }, + ], + }); + }); + + it("converts adapter failures into an explicit error status", () => { + const catalog = createHarmonizationStatusCatalog({}, { + advisor: () => { throw new Error("raw failure must not escape"); }, + }); + expect(catalog.features[0]).toMatchObject({ feature: "advisor", health: "error", enabled: false, diagnostics: { adapterError: true } }); + expect(JSON.stringify(catalog)).not.toContain("raw failure"); + }); + + it("serializes only the canonical, privacy-checked catalog", () => { + const catalog = createHarmonizationStatusCatalog({}, { + advisor: () => status("advisor"), + router: () => status("router"), + orchestration: () => status("orchestration"), + contextBroker: () => ({ enabled: false, registered: false }), + }); + const first = serializeHarmonizationStatusCatalog(catalog); + expect(first).toBe(serializeHarmonizationStatusCatalog(JSON.parse(first))); + expect(first).not.toContain("/"); + expect(() => serializeHarmonizationStatusCatalog({ ...catalog, features: [{ ...catalog.features[0], diagnostics: { prompt: "secret" } }, ...catalog.features.slice(1)] })).toThrow(/prohibited field/); + }); +}); diff --git a/packages/bundle/src/status-catalog.ts b/packages/bundle/src/status-catalog.ts new file mode 100644 index 0000000..7eeb8e1 --- /dev/null +++ b/packages/bundle/src/status-catalog.ts @@ -0,0 +1,85 @@ +import { + createFeatureStatusV1, + serializeFeatureStatusV1, + validateFeatureStatusV1, + type FeatureStatusV1, +} from "@fiale-plus/pi-core"; +import { advisorFeatureStatus } from "@fiale-plus/pi-rogue-advisor"; +import { contextBrokerFeatureStatus, type ContextBrokerStatusSource } from "@fiale-plus/pi-rogue-context-broker"; +import { orchestrationFeatureStatus } from "@fiale-plus/pi-rogue-orchestration"; +import { routerFeatureStatus } from "@fiale-plus/pi-rogue-router"; + +export const HARMONIZATION_STATUS_CATALOG_SCHEMA = "pi-rogue.harmonization-status.v1" as const; + +export interface HarmonizationStatusCatalogV1 { + schema: typeof HARMONIZATION_STATUS_CATALOG_SCHEMA; + features: FeatureStatusV1[]; +} + +export type FeatureStatusProvider = (ctx: unknown) => FeatureStatusV1; + +export interface HarmonizationStatusSources { + advisor?: FeatureStatusProvider; + router?: FeatureStatusProvider; + orchestration?: FeatureStatusProvider; + contextBroker?: (ctx: unknown) => ContextBrokerStatusSource; +} + +const FEATURE_ORDER = ["advisor", "router", "orchestration", "context-broker"] as const; + +function errorStatus(feature: string, owner: string): FeatureStatusV1 { + return createFeatureStatusV1({ + feature, + owner, + health: "error", + enabled: false, + mode: "unavailable", + summary: "status adapter failed", + diagnostics: { adapterError: true }, + }); +} + +function callStatus(feature: string, owner: string, provider: FeatureStatusProvider | undefined, ctx: unknown): FeatureStatusV1 { + if (!provider) return createFeatureStatusV1({ feature, owner, health: "unavailable", enabled: false, mode: "unavailable", summary: "status adapter unavailable" }); + try { + const status = provider(ctx); + validateFeatureStatusV1(status); + if (status.feature !== feature || status.owner !== owner) throw new Error("status identity mismatch"); + return status; + } catch { + return errorStatus(feature, owner); + } +} + +/** Build an ordered, read-only status catalog. Providers never receive lifecycle/control capabilities. */ +export function createHarmonizationStatusCatalog(ctx: unknown, sources: HarmonizationStatusSources = {}): HarmonizationStatusCatalogV1 { + const contextStatus = (): FeatureStatusV1 => { + let contextSource: ContextBrokerStatusSource = { enabled: false, registered: false }; + try { + contextSource = sources.contextBroker?.(ctx) ?? contextSource; + } catch { + contextSource = { enabled: true, registered: false, error: true }; + } + return contextBrokerFeatureStatus(contextSource); + }; + const statuses = [ + callStatus("advisor", "advisor", sources.advisor ?? (() => advisorFeatureStatus()), ctx), + callStatus("router", "router", sources.router ?? ((value) => routerFeatureStatus(value)), ctx), + callStatus("orchestration", "orchestration", sources.orchestration ?? ((value) => orchestrationFeatureStatus(value)), ctx), + callStatus("context-broker", "context-broker", contextStatus, ctx), + ]; + return { schema: HARMONIZATION_STATUS_CATALOG_SCHEMA, features: statuses }; +} + +/** Stable, privacy-checked representation for local reports and passive evidence. */ +export function serializeHarmonizationStatusCatalog(catalog: HarmonizationStatusCatalogV1): string { + if (catalog.schema !== HARMONIZATION_STATUS_CATALOG_SCHEMA) throw new Error("catalog.schema: invalid schema"); + if (!Array.isArray(catalog.features) || catalog.features.length !== FEATURE_ORDER.length) throw new Error("catalog.features: invalid feature count"); + catalog.features.forEach((status, index) => { + validateFeatureStatusV1(status); + if (status.feature !== FEATURE_ORDER[index]) throw new Error("catalog.features: non-canonical order"); + }); + return JSON.stringify({ schema: catalog.schema, features: catalog.features.map((status) => JSON.parse(serializeFeatureStatusV1(status))) }); +} + +export { FEATURE_ORDER }; diff --git a/packages/context-broker/src/extension.ts b/packages/context-broker/src/extension.ts index ffde050..49170cb 100644 --- a/packages/context-broker/src/extension.ts +++ b/packages/context-broker/src/extension.ts @@ -666,6 +666,11 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte } else { broker = createInMemoryContextBroker(brokerOptions); } + p.__piRogueContextBrokerEffective = { + backend: effectiveBackend, + durable: durable && effectiveBackend !== "memory" && effectiveBackend !== "memory(degraded)", + degraded: effectiveBackend === "memory(degraded)", + }; const seenSourceIds = new Set(); const sourceHandles = new Map(); diff --git a/packages/context-broker/src/index.ts b/packages/context-broker/src/index.ts index 433ea6b..3253d0e 100644 --- a/packages/context-broker/src/index.ts +++ b/packages/context-broker/src/index.ts @@ -55,6 +55,9 @@ export function sourceIdFor(input: ContextArtifactInput): string | undefined { return sourceId; } +export { contextBrokerFeatureStatus, serializeContextBrokerFeatureStatus } from "./status.js"; +export type { ContextBrokerBackend, ContextBrokerStatusSource } from "./status.js"; + export function sourceKey(sessionId: string, sourceId: string): string { return `${sessionId}\u0000${sourceId}`; } diff --git a/packages/context-broker/src/status.test.ts b/packages/context-broker/src/status.test.ts new file mode 100644 index 0000000..a53d293 --- /dev/null +++ b/packages/context-broker/src/status.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { contextBrokerFeatureStatus, serializeContextBrokerFeatureStatus } from "./status.js"; + +describe("contextBrokerFeatureStatus", () => { + it("distinguishes disabled, unavailable, error, and ready states", () => { + expect(contextBrokerFeatureStatus({ enabled: false, registered: false }).health).toBe("disabled"); + expect(contextBrokerFeatureStatus({ enabled: true, registered: false }).health).toBe("unavailable"); + expect(contextBrokerFeatureStatus({ enabled: true, registered: false, error: true }).health).toBe("error"); + expect(contextBrokerFeatureStatus({ enabled: true, registered: true, durable: true, backend: "sqlite" })).toMatchObject({ health: "ready", mode: "sqlite" }); + expect(contextBrokerFeatureStatus({ enabled: true, registered: true, durable: false, backend: "memory(degraded)" })).toMatchObject({ health: "degraded", summary: "context broker is degraded" }); + expect(contextBrokerFeatureStatus({ enabled: true, registered: true, backend: "/private/user/secret" }).health).toBe("error"); + }); + + it("serializes only bounded status metadata", () => { + const serialized = serializeContextBrokerFeatureStatus({ enabled: true, registered: true, durable: false, backend: "memory" }); + expect(serialized).toContain('"feature":"context-broker"'); + expect(serialized).not.toContain("/"); + expect(serialized).not.toContain("path"); + }); +}); diff --git a/packages/context-broker/src/status.ts b/packages/context-broker/src/status.ts new file mode 100644 index 0000000..d01e5a4 --- /dev/null +++ b/packages/context-broker/src/status.ts @@ -0,0 +1,39 @@ +import { createFeatureStatusV1, serializeFeatureStatusV1, type FeatureStatusV1 } from "@fiale-plus/pi-core"; + +export type ContextBrokerBackend = "memory" | "memory(degraded)" | "sqlite" | "jsonl"; + +export interface ContextBrokerStatusSource { + enabled: boolean; + registered: boolean; + durable?: boolean; + backend?: ContextBrokerBackend | string; + error?: boolean; +} + +/** Read-only Context Broker status adapter. It reports a supplied runtime marker and never touches storage. */ +export function contextBrokerFeatureStatus(source: ContextBrokerStatusSource): FeatureStatusV1 { + const knownBackend = source.backend === "memory" || source.backend === "memory(degraded)" || source.backend === "sqlite" || source.backend === "jsonl" + ? source.backend + : source.backend === undefined ? (source.durable === false ? "memory" : undefined) : undefined; + const backendInvalid = source.backend !== undefined && knownBackend === undefined; + const degraded = knownBackend === "memory(degraded)"; + const health = !source.enabled ? "disabled" : source.error || backendInvalid ? "error" : !source.registered ? "unavailable" : degraded ? "degraded" : "ready"; + const mode = source.registered && knownBackend ? knownBackend : "unavailable"; + return createFeatureStatusV1({ + feature: "context-broker", + owner: "context-broker", + health, + enabled: source.enabled, + mode, + summary: !source.enabled ? "context broker is disabled" : source.error || backendInvalid ? "context broker registration failed" : degraded ? "context broker is degraded" : source.registered ? "context broker is available" : "context broker is unavailable", + diagnostics: { + registered: source.registered, + durable: source.durable ?? null, + backend: knownBackend ?? null, + }, + }); +} + +export function serializeContextBrokerFeatureStatus(source: ContextBrokerStatusSource): string { + return serializeFeatureStatusV1(contextBrokerFeatureStatus(source)); +} diff --git a/packages/core/src/feature-status.test.ts b/packages/core/src/feature-status.test.ts index ce303cd..709bdd5 100644 --- a/packages/core/src/feature-status.test.ts +++ b/packages/core/src/feature-status.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createFeatureStatusV1, serializeFeatureStatusV1 } from "./feature-status.js"; +import { createFeatureStatusV1, serializeFeatureStatusV1, validateFeatureStatusV1 } from "./feature-status.js"; describe("FeatureStatusV1", () => { it("creates a versioned passive snapshot", () => { @@ -46,5 +46,19 @@ describe("FeatureStatusV1", () => { diagnostics: { futureField: "ignored by older consumers" }, }); expect(status.diagnostics?.futureField).toBe("ignored by older consumers"); + expect(() => validateFeatureStatusV1(status)).not.toThrow(); + }); + + it("fails closed for prohibited diagnostics and unknown top-level fields", () => { + const status = createFeatureStatusV1({ feature: "router", owner: "router", health: "idle", enabled: true }); + expect(() => serializeFeatureStatusV1({ ...status, diagnostics: { prompt: "no" } })).toThrow(/prohibited field/); + expect(() => serializeFeatureStatusV1({ ...status, diagnostics: { detail: "/private/user/secret" } })).toThrow(/unsafe or unbounded text/); + expect(() => validateFeatureStatusV1({ ...status, unknown: true } as typeof status)).toThrow(/unknown field/); + const inherited = Object.create({ health: "ready" }); + Object.assign(inherited, { schema: "FeatureStatusV1", feature: "router", owner: "router", enabled: true }); + expect(() => validateFeatureStatusV1(inherited)).toThrow(/expected a plain object/); + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(() => serializeFeatureStatusV1({ ...status, diagnostics: cyclic })).toThrow(/cyclic diagnostics/); }); }); diff --git a/packages/core/src/feature-status.ts b/packages/core/src/feature-status.ts index 902da8f..bf8eb19 100644 --- a/packages/core/src/feature-status.ts +++ b/packages/core/src/feature-status.ts @@ -16,8 +16,85 @@ export interface FeatureStatusV1 { export type FeatureStatusV1Input = Omit; +const HEALTH_VALUES = new Set(["unavailable", "disabled", "unconfigured", "idle", "ready", "degraded", "error"]); +const PROHIBITED_KEY = /prompt|transcript|payload|path|secret|credential|password|email|raw|content|filesystem|user/i; +const MAX_DIAGNOSTIC_DEPTH = 5; +const MAX_DIAGNOSTIC_ENTRIES = 64; +const MAX_DIAGNOSTIC_ITEMS = 32; + export function createFeatureStatusV1(input: FeatureStatusV1Input): FeatureStatusV1 { - return { ...input, schema: FEATURE_STATUS_V1 }; + const status = { ...input, schema: FEATURE_STATUS_V1 } as FeatureStatusV1; + validateFeatureStatusV1(status); + return status; +} + +function isPlainRecord(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function isSafeStatusText(value: string): boolean { + return value.length <= 160 && /^[A-Za-z0-9 _.,:;()=\-]+$/.test(value); +} + +function validateDiagnosticValue(value: unknown, path: string, depth = 0, active = new WeakSet()): void { + if (value === null || typeof value === "boolean") return; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error(`${path}: non-finite number`); + return; + } + if (typeof value === "string") { + if (!isSafeStatusText(value)) throw new Error(`${path}: unsafe or unbounded text`); + return; + } + if (depth > MAX_DIAGNOSTIC_DEPTH) throw new Error(`${path}: diagnostic depth exceeds limit`); + if (!value || typeof value !== "object") throw new Error(`${path}: unsupported diagnostic value`); + if (active.has(value)) throw new Error(`${path}: cyclic diagnostics are not allowed`); + active.add(value); + try { + if (Array.isArray(value)) { + if (value.length > MAX_DIAGNOSTIC_ITEMS) throw new Error(`${path}: diagnostic array exceeds limit`); + value.forEach((item, index) => validateDiagnosticValue(item, `${path}[${index}]`, depth + 1, active)); + return; + } + if (!isPlainRecord(value)) throw new Error(`${path}: expected a JSON object`); + const keys = Reflect.ownKeys(value); + if (keys.length > MAX_DIAGNOSTIC_ENTRIES) throw new Error(`${path}: diagnostic object exceeds limit`); + for (const key of keys) { + if (typeof key !== "string") throw new Error(`${path}: symbol field is not allowed`); + if (PROHIBITED_KEY.test(key)) throw new Error(`${path}.${key}: prohibited field`); + validateDiagnosticValue(value[key], `${path}.${key}`, depth + 1, active); + } + } finally { + active.delete(value); + } +} + +/** Validate the public, passive status shape before it crosses a package boundary. */ +export function validateFeatureStatusV1(status: FeatureStatusV1): void { + if (!isPlainRecord(status)) throw new Error("status: expected a plain object"); + const hasOwn = (key: string): boolean => Object.prototype.hasOwnProperty.call(status, key); + for (const key of ["schema", "feature", "owner", "health", "enabled"]) { + if (!hasOwn(key)) throw new Error(`status.${key}: required field missing`); + } + if (status.schema !== FEATURE_STATUS_V1) throw new Error("status.schema: invalid schema"); + if (typeof status.feature !== "string" || !/^[a-z0-9-]+$/.test(status.feature)) throw new Error("status.feature: invalid feature"); + if (typeof status.owner !== "string" || !/^[a-z0-9-]+$/.test(status.owner)) throw new Error("status.owner: invalid owner"); + if (!HEALTH_VALUES.has(status.health)) throw new Error("status.health: invalid health"); + if (typeof status.enabled !== "boolean") throw new Error("status.enabled: expected boolean"); + if (status.mode !== undefined && (typeof status.mode !== "string" || !/^[a-z0-9][a-z0-9_.:()\-]{0,63}$/.test(status.mode))) throw new Error("status.mode: unsafe or invalid mode"); + if (status.summary !== undefined && (typeof status.summary !== "string" || !isSafeStatusText(status.summary))) throw new Error("status.summary: unsafe or unbounded text"); + if (status.diagnostics !== undefined) { + if (!isPlainRecord(status.diagnostics)) throw new Error("status.diagnostics: expected a plain object"); + validateDiagnosticValue(status.diagnostics, "status.diagnostics"); + } + for (const key of Reflect.ownKeys(status)) { + if (typeof key !== "string") throw new Error("status: symbol field is not allowed"); + if (!["schema", "feature", "owner", "health", "enabled", "mode", "summary", "diagnostics"].includes(key)) { + throw new Error(`status.${key}: unknown field`); + } + } } function canonicalize(value: unknown): unknown { @@ -32,5 +109,6 @@ function canonicalize(value: unknown): unknown { /** Stable JSON representation for logs, snapshots, and passive status catalogs. */ export function serializeFeatureStatusV1(status: FeatureStatusV1): string { + validateFeatureStatusV1(status); return JSON.stringify(canonicalize(status)); } diff --git a/packages/router/src/status.ts b/packages/router/src/status.ts index 966fad2..57644e2 100644 --- a/packages/router/src/status.ts +++ b/packages/router/src/status.ts @@ -90,12 +90,10 @@ export function routerFeatureStatus(ctx: any): FeatureStatusV1 { health, enabled: config.enabled, mode: config.mode, - summary: config.enabled ? `profile=${config.activeProfile} worker=${profile.worker}` : "router is disabled", + summary: config.enabled ? "router is enabled" : "router is disabled", diagnostics: { - configPath, configSource: configRead.present ? "global" : "built-in-defaults", configValid, - statePath, statePresent: stateRead.present, stateValid: stateRead.valid && stateShapeValid, sessionScoped: hasSessionIdentity, diff --git a/scripts/harmonization-status.ts b/scripts/harmonization-status.ts new file mode 100644 index 0000000..901497c --- /dev/null +++ b/scripts/harmonization-status.ts @@ -0,0 +1,43 @@ +import { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { createFeatureStatusV1, type FeatureStatusV1 } from "../packages/core/src/feature-status.js"; +import { + createHarmonizationStatusCatalog, + serializeHarmonizationStatusCatalog, + type HarmonizationStatusCatalogV1, +} from "../packages/bundle/src/status-catalog.js"; + +export const HARMONIZATION_STATUS_FIXTURES: readonly FeatureStatusV1[] = [ + createFeatureStatusV1({ feature: "advisor", owner: "advisor", health: "ready", enabled: true, mode: "auto" }), + createFeatureStatusV1({ feature: "router", owner: "router", health: "idle", enabled: true, mode: "observe" }), + createFeatureStatusV1({ feature: "orchestration", owner: "orchestration", health: "disabled", enabled: false, mode: "idle" }), + createFeatureStatusV1({ feature: "context-broker", owner: "context-broker", health: "ready", enabled: true, mode: "sqlite", diagnostics: { registered: true, durable: true, backend: "sqlite" } }), +]; + +export function runHarmonizationStatusReport(): HarmonizationStatusCatalogV1 { + return createHarmonizationStatusCatalog({}, { + advisor: () => HARMONIZATION_STATUS_FIXTURES[0], + router: () => HARMONIZATION_STATUS_FIXTURES[1], + orchestration: () => HARMONIZATION_STATUS_FIXTURES[2], + contextBroker: () => ({ enabled: true, registered: true, durable: true, backend: "sqlite" }), + }); +} + +export function serializeHarmonizationStatusReport(report: HarmonizationStatusCatalogV1): string { + return serializeHarmonizationStatusCatalog(report); +} + +function cliValue(argv: readonly string[], flag: string): string | undefined { + const index = argv.indexOf(flag); + if (index < 0) return undefined; + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value`); + return value; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const output = cliValue(process.argv.slice(2), "--output"); + const serialized = serializeHarmonizationStatusReport(runHarmonizationStatusReport()); + if (output) writeFileSync(output, `${serialized}\n`, "utf8"); + else process.stdout.write(`${serialized}\n`); +}