diff --git a/.github/workflows/mirror-runtime-ci.yml b/.github/workflows/mirror-runtime-ci.yml index 376adebfd4..237bdec555 100644 --- a/.github/workflows/mirror-runtime-ci.yml +++ b/.github/workflows/mirror-runtime-ci.yml @@ -14,10 +14,13 @@ on: - "tsdown.mirror.config.ts" - "vitest.mirror.config.ts" - "scripts/ci-mirror-smoke.ts" + - "scripts/test-mirror-runtime-boundary-smoke.mjs" - "scripts/assemble-mirror-runtime-dist.ts" - "scripts/copy-mirror-runtime-assets.ts" - "scripts/verify-mirror-runtime-dist.ts" - "packaging/mirror-runtime/**" + - "src/compat/openclaw/shim-boundary.test.ts" + - "src/runtime/compat-legacy-boundary.test.ts" - "test/mirror-package-boundary.test.ts" - "docs/debug/mirror-runtime-canonical-entrypoints.md" - ".github/workflows/mirror-runtime-ci.yml" @@ -79,6 +82,9 @@ jobs: with: install-bun: "false" + - name: Run Mirror runtime boundary smoke target + run: pnpm test:mirror:smoke + - name: Download Mirror runtime dist artifact uses: actions/download-artifact@v4 with: @@ -94,5 +100,8 @@ jobs: - name: Smoke packaged Mirror CLI entry run: /tmp/mirror-runtime-smoke/mirror-runtime-linux/rootfs/opt/mirror-runtime/bin/mirror help + - name: Smoke packaged Mirror runtime service + run: node --import tsx scripts/ci-mirror-smoke.ts --runtime-root /tmp/mirror-runtime-smoke/mirror-runtime-linux/rootfs/opt/mirror-runtime + - name: Smoke built Mirror runtime service run: pnpm smoke:mirror && pnpm verify:mirror-runtime-dist diff --git a/docs/architecture/mirror-runtime-split-readiness-checklist.md b/docs/architecture/mirror-runtime-split-readiness-checklist.md index c3aa74eba6..2f3554c719 100644 --- a/docs/architecture/mirror-runtime-split-readiness-checklist.md +++ b/docs/architecture/mirror-runtime-split-readiness-checklist.md @@ -59,36 +59,42 @@ Current score: `yellow` Current reality: - Service runtime state is real and daemon-backed. -- Console and CLI parity improved materially, but should still be treated as a seam until one runtime truth is clearly universal. +- CLI parity improved materially for status, sync summary, and key operator-truth flows. +- Daemon-side runtime, debug, and health websocket truth is now covered with focused tests. +- Console execution and a few remaining compatibility edges should still be treated as the next ownership seam until one runtime truth is clearly universal. ### 2. Service and operator surfaces Current score: `yellow` -- [ ] Canonical read-only operator surfaces are present and stable. -- [ ] Health, runtime, debug, actions, providers, and sync surfaces are consistent in style and ownership. +- [x] Canonical read-only operator surfaces are present and stable. +- [x] Health, runtime, debug, actions, providers, and sync surfaces are consistent in style and ownership. - [ ] Operator-facing surfaces do not depend on legacy `src/runtime/**` wrappers. -- [ ] Additive runtime/status endpoints are covered by service-level tests. +- [x] Additive runtime/status endpoints are covered by service-level tests. Current reality: - `/mirror/health`, `/mirror/status`, `/mirror/runtime`, `/mirror/runtime/debug`, `/mirror/actions`, `/mirror/providers`, and `/mirror/sync` are real. -- This area is close, but operator truth is still uneven in CLI status and verify-lore flows. +- `mirror status` is daemon-backed and limited to runtime truth only on the canonical Mirror path. +- Websocket transport/control/summary truth is now materially covered at both service and daemon state layers. +- This area is materially improved, but should remain yellow until compatibility-only wrappers stop competing with the canonical operator path and console/runtime ownership is tighter. ### 3. Sync and runtime state visibility Current score: `yellow` -- [ ] Sync peer state is available on a canonical read-only runtime surface. -- [ ] `/mirror-sync/peers` and `/mirror-sync/updates` have focused service-level regression coverage. +- [x] Sync peer state is available on a canonical read-only runtime surface. +- [x] `/mirror-sync/peers` and `/mirror-sync/updates` have focused service-level regression coverage. - [ ] Runtime event surfaces expose enough state to understand sync activity without reading internal modules. -- [ ] Sync state is summarized consistently between operator surfaces and runtime state. +- [x] Sync state is summarized consistently between operator surfaces and runtime state. Current reality: - Sync read surfaces are real. - `/mirror/sync` now exposes read-only peer state from the live registry. +- CLI status parity now covers the daemon-backed sync summary. - Sync is still a seam because execution state is not fully daemon-owned beyond the current registry and event stream summaries. +- The remaining small gap here is narrower now: understanding sync activity from daemon-owned inspection without reading internal execution modules. ### 4. CLI and service parity @@ -96,12 +102,15 @@ Current score: `yellow` - [ ] `mirror` CLI commands execute through the same canonical runtime plane used by service ingress. - [ ] Read operations and mutable operations report the same runtime truth regardless of surface. -- [ ] Focused parity tests exist for status, sync, and key tool flows. +- [x] Focused parity tests exist for status, sync, and key tool flows. - [ ] Operator commands do not silently fall back to stale or compatibility-only logic. Current reality: -- CLI coverage is much better than before, but this should not be treated as fully green until parity is explicit for all critical operator surfaces. +- `mirror status` and sync summary parity are now explicit and tested. +- The canonical `mirror verify-lore` path is aligned on `MIRROR_LORE_DIR`. +- CLI/operator-truth seams are materially improved and now included in the dedicated Mirror smoke lane. +- This should not be treated as fully green until parity is explicit for all critical operator surfaces and compatibility-only entrypoints are no longer misleading. ### 5. Observability ownership @@ -115,61 +124,65 @@ Current score: `red` Current reality: - Observability surfaces exist and are useful. -- Ownership is still process-global enough that this remains a pre-split blocker. +- Ownership is still process-global enough that this remains the clearest technical pre-split blocker. ### 6. Compatibility quarantine -Current score: `red` +Current score: `yellow` -- [ ] `src/runtime/server.ts`, `src/runtime/brain-chat.ts`, `src/runtime/health.ts`, and `src/cli/mirror-cli.ts` are either quarantined clearly or removed. -- [ ] Mirror-owned runtime modules do not read `OPENCLAW_*` env vars except in explicit compatibility files. -- [ ] Canonical operator docs and entrypoints point to Mirror-native paths first. +- [x] `src/runtime/server.ts`, `src/runtime/brain-chat.ts`, `src/runtime/health.ts`, and `src/cli/mirror-cli.ts` are clearly marked as compatibility shims. +- [x] Mirror-owned runtime modules do not read `OPENCLAW_*` env vars except in explicit compatibility files. +- [x] Focused guardrail coverage exists for the main shims and the remaining legacy runtime entrypoints. +- [x] Canonical operator docs and entrypoints point to Mirror-native paths first. - [ ] Compatibility code is not the hidden owner of any required runtime behavior. Current reality: -- Compatibility edges are still materially present at entrypoint and env-boundary level. -- This is still one of the clearest blockers to a clean split. +- Canonical Mirror-owned source no longer reads `OPENCLAW_*` directly outside tests and explicit compatibility paths. +- Canonical entrypoint, operator, and JSON automation docs now point to Mirror-native paths first and describe `openclaw mirror ...` as compatibility-only. +- Compatibility edges are still materially present at entrypoint and wrapper level, but they are now better quarantined and guarded. +- This should remain yellow until the remaining compat entrypoints stop competing with canonical ownership in practice and compatibility code stops owning any required behavior. ### 7. Packaging and build boundary -Current score: `red` +Current score: `yellow` -- [ ] Mirror has a first-class package and build boundary inside the repo. -- [ ] Mirror artifacts can be built and tested without treating OpenClaw as the primary product identity. -- [ ] Release and packaging paths for Mirror are explicit enough to survive a repo split. +- [x] Mirror has a first-class package and build boundary inside the repo. +- [x] Mirror artifacts can be built and tested without treating OpenClaw as the primary product identity. +- [x] Release and packaging paths for Mirror are explicit enough to survive a repo split. Current reality: -- Mirror runtime behavior is increasingly standalone. -- Package, bin, and release identity are still shared enough that splitting now would be premature. +- Mirror now has an explicit package boundary, standalone Linux runtime artifact, extracted-artifact smoke, dist verification, and bootstrap verification. +- Package, bin, and release identity are materially more explicit and guarded than they were before the recent split-readiness PRs. +- This area should remain yellow because the package/build boundary is now real, but the remaining blockers are runtime ownership and observability rather than packaging viability. ### 8. CI gates before split -Current score: `red` +Current score: `yellow` -- [ ] A dedicated Mirror runtime smoke lane exists. -- [ ] A boundary gate prevents new OpenClaw-specific env/config coupling inside Mirror-owned runtime modules. -- [ ] Split-critical runtime tests are visible without depending on the full repo matrix. +- [x] A dedicated Mirror runtime smoke lane exists. +- [x] Split-critical runtime-boundary and daemon-truth tests are visible without depending on the full repo matrix. +- [x] Boundary gates prevent new OpenClaw-specific env/config and import/package coupling inside Mirror-owned runtime modules. +- [x] Mirror-specific checks are isolated enough to serve as a true split gate rather than an early smoke lane. Current reality: -- Runtime tests exist. -- Dedicated split-readiness gates are still missing. +- A dedicated Mirror-owned smoke target now exists and is wired into the dedicated Mirror runtime workflow. +- Runtime-boundary, daemon-truth, websocket, CLI/operator-truth, compatibility-quarantine, packaged-runtime truth, and OpenClaw env/import boundary seams are now visible in a narrow CI lane. +- Dedicated split-readiness gates now include first-class boundary enforcement for new OpenClaw-specific env/config and import/package coupling inside Mirror-owned modules. +- macOS shard-order instability should be treated as a CI lane, not as a product seam in this checklist. ## Current known gaps The next small, high-signal gaps remain: -1. Make any remaining console and CLI execution seams explicitly daemon-backed where they are still partial. -2. Continue enriching daemon-owned runtime events where operator/runtime inspection is still thin. -3. Move observability ownership under daemon/runtime control. -4. Remove OpenClaw env usage from canonical Mirror runtime modules. -5. Fix operator truth for `mirror status`. -6. Fix operator truth for `mirror verify-lore`. -7. Quarantine or retire compatibility-only runtime wrappers. -8. Create a Mirror-native package/build boundary. -9. Add dedicated Mirror runtime CI gates. +1. Move observability ownership under daemon/runtime control. +2. Make any remaining console execution seams explicitly daemon-backed where they are still partial. +3. Continue enriching daemon-owned runtime inspection only where sync/runtime understanding is still thin. +4. Keep parity coverage growing only where a canonical operator/runtime seam is still weak. +5. Continue shrinking compatibility entrypoints from “guarded” to “non-owning in practice.” +6. Keep package/release verification narrow and trustworthy while the remaining runtime seams are closed. ## Do Not Split Before diff --git a/package.json b/package.json index 7d3c4f0ca4..12989bab25 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "test:live": "OPENCLAW_LIVE_TEST=1 CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts", "test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=serial node scripts/test-parallel.mjs", "test:mirror": "vitest run --config vitest.mirror.config.ts", + "test:mirror:smoke": "node scripts/test-mirror-runtime-boundary-smoke.mjs", "test:ui": "pnpm lint:ui:no-raw-window-open && pnpm --dir ui test", "test:voicecall:closedloop": "vitest run extensions/voice-call/src/manager.test.ts extensions/voice-call/src/media-stream.test.ts src/plugins/voice-call.plugin.test.ts --maxWorkers=1", "test:watch": "vitest", diff --git a/packaging/mirror-runtime/README.md b/packaging/mirror-runtime/README.md index 5e30f1b6ca..107d1a5f94 100644 --- a/packaging/mirror-runtime/README.md +++ b/packaging/mirror-runtime/README.md @@ -37,7 +37,7 @@ The package boundary intentionally excludes repository-only content: ## Linux-first Distribution Shape -The generated distribution tree is: +The generated distribution tree inside `mirror-runtime-linux.tar.gz` is: ```text dist/mirror-runtime-linux/ diff --git a/scripts/ci-mirror-smoke.ts b/scripts/ci-mirror-smoke.ts index 37e9cb3c85..4fcd373f60 100644 --- a/scripts/ci-mirror-smoke.ts +++ b/scripts/ci-mirror-smoke.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; type MirrorService = { app: { handle: (req: unknown, res: unknown) => void }; @@ -17,6 +18,36 @@ type MirrorPackageModule = { ) => Promise; }; +type SmokeOptions = { + runtimeRoot?: string; +}; + +function parseArgs(argv: string[]): SmokeOptions { + const options: SmokeOptions = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--runtime-root") { + const value = argv[index + 1]; + if (!value) { + throw new Error("Missing value for --runtime-root"); + } + options.runtimeRoot = path.resolve(value); + index += 1; + continue; + } + throw new Error(`Unsupported argument: ${arg}`); + } + return options; +} + +async function loadMirrorPackageModule(runtimeRoot?: string): Promise { + if (!runtimeRoot) { + return (await import("../dist/mirror-package.js")) as MirrorPackageModule; + } + const modulePath = path.join(runtimeRoot, "dist", "mirror-package.js"); + return (await import(pathToFileURL(modulePath).href)) as MirrorPackageModule; +} + async function seedLoreCorpus(loreDir: string): Promise { const indexDir = path.join(loreDir, "_index"); await fs.mkdir(indexDir, { recursive: true }); @@ -116,6 +147,7 @@ async function requestJsonFromApp( } async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "mirror-ci-smoke-")); const loreDir = path.join(tempRoot, "lore-scrolls"); const memoryDbPath = path.join(tempRoot, "mirror-memory.sqlite"); @@ -126,7 +158,7 @@ async function main(): Promise { process.env.MIRROR_LORE_DIR = loreDir; process.env.MIRROR_MEMORY_DB_PATH = memoryDbPath; - const mirrorPackage = (await import("../dist/mirror-package.js")) as MirrorPackageModule; + const mirrorPackage = await loadMirrorPackageModule(options.runtimeRoot); const fetchImpl: typeof fetch = async (_url, _init) => ({ ok: true, diff --git a/scripts/test-mirror-runtime-boundary-smoke.mjs b/scripts/test-mirror-runtime-boundary-smoke.mjs new file mode 100644 index 0000000000..e7a595ab2d --- /dev/null +++ b/scripts/test-mirror-runtime-boundary-smoke.mjs @@ -0,0 +1,87 @@ +import { spawnSync } from "node:child_process"; + +const steps = [ + { + name: "daemon/runtime-state truth", + command: "pnpm", + args: [ + "vitest", + "run", + "src/mirrordaemon/mirrordaemon.test.ts", + "src/mirrordaemon/runtime_state.test.ts", + ], + }, + { + name: "Mirror-owned OpenClaw env/import boundary gates", + command: "pnpm", + args: [ + "vitest", + "run", + "src/mirror/openclaw-env-boundary.test.ts", + "src/mirror/openclaw-import-boundary.test.ts", + ], + }, + { + name: "compatibility-quarantine guardrails", + command: "pnpm", + args: [ + "vitest", + "run", + "src/compat/openclaw/shim-boundary.test.ts", + "src/runtime/compat-legacy-boundary.test.ts", + ], + }, + { + name: "websocket transport/control/summary truth", + command: "pnpm", + args: [ + "vitest", + "run", + "src/mirror-service/mirror_service.test.ts", + "-t", + [ + "exposes canonical runtime state and debug endpoints", + "emits daemon runtime events for chat, tool, provider, and sync lifecycle", + "streams /mirror/runtime/ws with backlog, live events, and protocol messages", + "surfaces runtime websocket connect and disconnect events to live subscribers", + "replays prior websocket transport events from backlog to reconnecting subscribers", + "replays backlog only when explicitly requested by subscribe control messages", + "returns websocket error envelopes for unsupported control messages", + "returns websocket error envelopes for invalid control payloads", + "reflects websocket connection counts on the runtime summary as sockets connect and disconnect", + "keeps service, console, daemon, observability, and status surfaces in sync", + ].join("|"), + ], + }, + { + name: "Mirror CLI/operator truth", + command: "pnpm", + args: [ + "vitest", + "run", + "src/mirror-cli/mirror_cli.test.ts", + "-t", + [ + "supports standalone status and verify-lore commands", + "uses MIRROR_LORE_DIR defaults for verify-lore against the current lore root", + "supports sync commands in human-readable mode", + "returns stable JSON shapes for sync commands", + "reports CLI status from the same daemon-backed runtime truth after command execution", + "keeps mirror status limited to canonical runtime truth after sync announce", + ].join("|"), + ], + }, +]; + +for (const step of steps) { + process.stdout.write(`\n[mirror-smoke] ${step.name}\n`); + const result = spawnSync(step.command, step.args, { + stdio: "inherit", + shell: process.platform === "win32", + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +process.stdout.write(`\n[mirror-smoke] completed: ${steps.map((step) => step.name).join(" | ")}\n`); diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index f9781588b8..da2b270c42 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -78,7 +78,7 @@ const entries: SubCliEntry[] = [ description: "Mirror compatibility commands (standalone runtime ships as `mirror`)", hasSubcommands: true, register: async (program) => { - const mod = await import("../../mirror/telemetry_tail/cli.js"); + const mod = await import("../../mirror/telemetry_tail/index.js"); mod.registerMirrorTelemetryCli(program); }, }, diff --git a/src/compat/openclaw/cli/mirror-cli.ts b/src/compat/openclaw/cli/mirror-cli.ts index b2b9b6efc3..3a657cad48 100644 --- a/src/compat/openclaw/cli/mirror-cli.ts +++ b/src/compat/openclaw/cli/mirror-cli.ts @@ -5,7 +5,7 @@ */ import type { Command } from "commander"; -import { runMirrorTelemetryTailCli } from "../../../mirror/telemetry_tail/cli.js"; +import { runMirrorTelemetryTailCli } from "../../../mirror/telemetry_tail/index.js"; function parseLimit(raw: string): number { const value = Number.parseInt(raw, 10); diff --git a/src/compat/openclaw/runtime/health.ts b/src/compat/openclaw/runtime/health.ts index 8ab70a292a..6b855d9a0a 100644 --- a/src/compat/openclaw/runtime/health.ts +++ b/src/compat/openclaw/runtime/health.ts @@ -4,7 +4,8 @@ * Canonical Mirror service health lives under `/mirror/health`. */ -import type { RuntimeEnv } from "../../../runtime.js"; +import type { MirrorRuntimeHost } from "../../../mirror-service/index.js"; +import { getMirrordaemonRuntimeState } from "../../../mirrordaemon/index.js"; interface HealthResponse { ok: boolean; @@ -21,19 +22,26 @@ interface HealthResponse { }; } +function hasConfiguredValue(value: string | null | undefined): boolean { + return typeof value === "string" && value.trim().length > 0; +} + export async function handleHealthEndpoint( - _env: RuntimeEnv, - brainUrl: string | undefined, - authToken: string | undefined, + runtimeHost: MirrorRuntimeHost, ): Promise { const mode = process.env.MIRROR_RUNTIME_MODE || "lan"; - const version = process.env.MIRROR_RUNTIME_VERSION || "unknown"; + const runtime = getMirrordaemonRuntimeState(runtimeHost.daemon, { + port: runtimeHost.config.port, + baseUrl: runtimeHost.syncManager.getLocalBaseUrl(), + }); const commit = process.env.MIRROR_RUNTIME_COMMIT || "unknown"; + const brainConfigured = hasConfiguredValue(runtimeHost.config.providerUrl); + const authConfigured = hasConfiguredValue(runtimeHost.config.providerAuthToken); const features: string[] = []; - if (brainUrl) { + if (brainConfigured) { features.push("brain"); } - if (authToken) { + if (authConfigured) { features.push("auth"); } @@ -41,14 +49,14 @@ export async function handleHealthEndpoint( ok: true, time: new Date().toISOString(), mode: mode as "lan" | "intranet", - version, + version: runtime.version, commit, features, brain: { - configured: !!brainUrl, + configured: brainConfigured, }, auth: { - configured: !!authToken, + configured: authConfigured, }, }; } diff --git a/src/compat/openclaw/runtime/server.test.ts b/src/compat/openclaw/runtime/server.test.ts index 3e74593acd..41fc86886e 100644 --- a/src/compat/openclaw/runtime/server.test.ts +++ b/src/compat/openclaw/runtime/server.test.ts @@ -215,6 +215,41 @@ async function requestResponseFromApp( } describe("compat runtime server", () => { + it("derives /health provider truth from runtimeHost instead of raw wrapper args", async () => { + process.env.MIRROR_ENABLE_RUNTIME = "true"; + const loreDir = await createTempLoreDir(); + await seedLoreCorpus(loreDir); + process.env.MIRROR_LORE_DIR = loreDir; + process.env.MIRROR_MEMORY_DB_PATH = await createTempMemoryDbPath(); + + const runtimeHost = await createMirrorRuntimeHost({ + loreDir, + providerUrl: "http://brain.local/v1/chat/completions", + providerAuthToken: "token", + }); + + try { + const app = await startRuntimeServer(createNonExitingRuntime(), undefined, undefined, { + runtimeHost, + }); + const health = (await requestJsonFromApp(app, "GET", "/health")) as { + ok: boolean; + version: string; + features: string[]; + brain: { configured: boolean }; + auth: { configured: boolean }; + }; + + expect(health.ok).toBe(true); + expect(health.version.length).toBeGreaterThan(0); + expect(health.features).toEqual(expect.arrayContaining(["brain", "auth"])); + expect(health.brain.configured).toBe(true); + expect(health.auth.configured).toBe(true); + } finally { + await runtimeHost.shutdown(); + } + }); + it("routes /api/brain/chat through runtimeHost.executeAdapterRequest and preserves the raw response shape", async () => { process.env.MIRROR_ENABLE_RUNTIME = "true"; const loreDir = await createTempLoreDir(); diff --git a/src/compat/openclaw/runtime/server.ts b/src/compat/openclaw/runtime/server.ts index 174ee37d93..c69b7a8d02 100644 --- a/src/compat/openclaw/runtime/server.ts +++ b/src/compat/openclaw/runtime/server.ts @@ -56,7 +56,7 @@ export async function startRuntimeServer( app.get("/health", async (_req, res) => { try { - const health = await handleHealthEndpoint(env, brainUrl, authToken); + const health = await handleHealthEndpoint(runtimeHost); res.json(health); } catch (err) { res.status(500).json({ ok: false, error: String(err) }); diff --git a/src/compat/openclaw/shim-boundary.test.ts b/src/compat/openclaw/shim-boundary.test.ts new file mode 100644 index 0000000000..cb90d5e93d --- /dev/null +++ b/src/compat/openclaw/shim-boundary.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import * as shimMirrorCli from "../../cli/mirror-cli.js"; +import * as canonicalMirrorCli from "../../mirror-cli/index.js"; +import * as canonicalMirrorService from "../../mirror-service/index.js"; +import * as shimRuntimeServer from "../../runtime/server.js"; +import * as compatMirrorCli from "./cli/mirror-cli.js"; +import * as compatRuntimeServer from "./runtime/server.js"; + +describe("compatibility shim boundaries", () => { + it("keeps the runtime server shim as a thin forwarder to the compat runtime wrapper", () => { + expect(Object.keys(shimRuntimeServer)).toEqual(["startRuntimeServer"]); + expect(shimRuntimeServer.startRuntimeServer).toBe(compatRuntimeServer.startRuntimeServer); + expect("startRuntimeServer" in canonicalMirrorService).toBe(false); + expect("startMirrorService" in canonicalMirrorService).toBe(true); + }); + + it("keeps the mirror CLI shim as a thin forwarder to the compat CLI wrapper", () => { + expect(Object.keys(shimMirrorCli)).toEqual(["registerMirrorCli"]); + expect(shimMirrorCli.registerMirrorCli).toBe(compatMirrorCli.registerMirrorCli); + expect("registerMirrorCli" in canonicalMirrorCli).toBe(false); + expect("runMirrorCli" in canonicalMirrorCli).toBe(true); + }); +}); diff --git a/src/infra/agent-events.test.ts b/src/infra/agent-events.test.ts index f86425894f..0e556f20db 100644 --- a/src/infra/agent-events.test.ts +++ b/src/infra/agent-events.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { clearAgentRunContext, emitAgentEvent, @@ -9,8 +9,11 @@ import { } from "./agent-events.js"; describe("agent-events sequencing", () => { - test("stores and clears run context", async () => { + beforeEach(() => { resetAgentRunContextForTest(); + }); + + test("stores and clears run context", async () => { registerAgentRunContext("run-1", { sessionKey: "main" }); expect(getAgentRunContext("run-1")?.sessionKey).toBe("main"); clearAgentRunContext("run-1"); @@ -61,4 +64,14 @@ describe("agent-events sequencing", () => { expect(phases).toEqual(["start", "end"]); }); + + test("clears subscribed listeners via the test reset helper", async () => { + const leakedListener = vi.fn(); + onAgentEvent(leakedListener); + + resetAgentRunContextForTest(); + emitAgentEvent({ runId: "run-reset", stream: "lifecycle", data: { phase: "start" } }); + + expect(leakedListener).not.toHaveBeenCalled(); + }); }); diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts index 23557cdda6..3a8eb67f4f 100644 --- a/src/infra/agent-events.ts +++ b/src/infra/agent-events.ts @@ -51,6 +51,8 @@ export function clearAgentRunContext(runId: string) { } export function resetAgentRunContextForTest() { + seqByRun.clear(); + listeners.clear(); runContextById.clear(); } diff --git a/src/mirror-cli/mirror_cli.test.ts b/src/mirror-cli/mirror_cli.test.ts index 755ff91878..aa2cb6b8a3 100644 --- a/src/mirror-cli/mirror_cli.test.ts +++ b/src/mirror-cli/mirror_cli.test.ts @@ -590,13 +590,13 @@ describe("mirror cli", () => { const statusOutput = JSON.parse(await runMirrorCli(["mirror", "status", "--json"])) as { ok: boolean; command: string; - status: { runtime: object; service: object; observability: object }; + status: { runtime: object; service: object; sync: object }; }; expect(statusOutput.ok).toBe(true); expect(statusOutput.command).toBe("status"); expect(typeof statusOutput.status.runtime).toBe("object"); expect(typeof statusOutput.status.service).toBe("object"); - expect(typeof statusOutput.status.observability).toBe("object"); + expect(typeof statusOutput.status.sync).toBe("object"); const verifyOutput = JSON.parse( await runMirrorCli([ @@ -1056,15 +1056,8 @@ describe("mirror cli", () => { command: string; status: { runtime: { node_id: string; sessions: { total: number; open: number } }; - sync: { node_id: string }; - observability: { - metrics: { - counters: { - tool_executions: number; - }; - }; - diagnostics_events: number; - }; + sync: { node_id: string; peers_known: number }; + service: { lore_dir: string; operator_auth_configured: boolean }; }; }; @@ -1074,16 +1067,78 @@ describe("mirror cli", () => { runtimeHost.daemon.getBootSnapshot().config.node_id, ); expect(output.status.sync.node_id).toBe(runtimeHost.daemon.getBootSnapshot().config.node_id); + expect(output.status.sync.peers_known).toBe(runtimeHost.syncManager.listPeers().length); + expect(output.status.service.lore_dir).toBe( + runtimeHost.daemon.getBootSnapshot().config.lore_dir, + ); + expect(output.status.service.operator_auth_configured).toBe( + runtimeHost.daemon.getBootSnapshot().config.operator_auth_configured, + ); expect(output.status.runtime.sessions.total).toBe(runtimeHost.daemon.listSessions().length); expect(output.status.runtime.sessions.open).toBe( runtimeHost.daemon.listSessions().filter((session) => session.status === "open").length, ); - expect(output.status.observability.metrics.counters.tool_executions).toBe( - runtimeHost.daemon.getObservability().getMetrics().counters.tool_executions, + } finally { + await runtimeHost.shutdown(); + } + }); + + it("keeps mirror status limited to canonical runtime truth after sync announce", async () => { + const loreDir = await createTempLoreDir(); + await seedLoreCorpus(loreDir); + process.env.MIRROR_LORE_DIR = loreDir; + process.env.MIRROR_MEMORY_DB_PATH = await createTempMemoryDbPath(); + + const runtimeHost = await createMirrorRuntimeHost({ loreDir }); + + try { + await runMirrorCli( + [ + "mirror", + "sync", + "announce", + "--peer-id", + "peer-alpha", + "--base-url", + "https://peer.example.test", + ], + { runtimeHost }, + ); + + const statusJsonText = await runMirrorCli(["mirror", "status", "--json"], { runtimeHost }); + const statusJson = JSON.parse(statusJsonText) as { + ok: boolean; + command: string; + status: { + runtime: { node_id: string }; + service: { lore_dir: string }; + sync: { node_id: string; peers_known: number }; + ts?: string; + cwd?: string; + observability?: unknown; + }; + }; + + expect(statusJson.ok).toBe(true); + expect(statusJson.command).toBe("status"); + expect(statusJson.status.sync.peers_known).toBe(runtimeHost.syncManager.listPeers().length); + expect(statusJson.status.sync.peers_known).toBe(1); + expect(statusJson.status.sync.node_id).toBe( + runtimeHost.daemon.getBootSnapshot().config.node_id, ); - expect(output.status.observability.diagnostics_events).toBe( - runtimeHost.daemon.getObservability().getDiagnostics().events.length, + expect(statusJson.status.service.lore_dir).toBe( + runtimeHost.daemon.getBootSnapshot().config.lore_dir, ); + expect(statusJson.status).not.toHaveProperty("ts"); + expect(statusJson.status).not.toHaveProperty("cwd"); + expect(statusJson.status).not.toHaveProperty("observability"); + + const human = await runMirrorCli(["mirror", "status"], { runtimeHost }); + expect(human).toContain("sync:"); + expect(human).toContain("- peersKnown: 1"); + expect(human).not.toContain("ts:"); + expect(human).not.toContain("cwd:"); + expect(human).not.toContain("observability:"); } finally { await runtimeHost.shutdown(); } diff --git a/src/mirror-console/console_routes.ts b/src/mirror-console/console_routes.ts index 6e60295af9..bb5c0a815a 100644 --- a/src/mirror-console/console_routes.ts +++ b/src/mirror-console/console_routes.ts @@ -7,8 +7,10 @@ import { findScrollsSharingSymbols, findSupersessionChains, } from "../mirror-lore-graph/index.js"; -import { incrementMetric, logMirrorEvent } from "../mirror-observability/index.js"; -import type { MirrorObservabilityHandlers } from "../mirror-observability/index.js"; +import type { + MirrorObservabilityContext, + MirrorObservabilityHandlers, +} from "../mirror-observability/index.js"; import type { MirrorSyncHandlers } from "../mirror-sync/index.js"; import { renderMirrorConsoleHtml } from "./console_static.js"; @@ -33,6 +35,7 @@ export function createMirrorConsoleHandlers( gatewayHandlers: MirrorGatewayHandlers, deps: { syncHandlers: MirrorSyncHandlers; + observability: MirrorObservabilityContext; observabilityHandlers: MirrorObservabilityHandlers; health: (req: express.Request, res: express.Response) => void; }, @@ -51,29 +54,29 @@ export function createMirrorConsoleHandlers( diagnostics: deps.observabilityHandlers.diagnostics, health: deps.health, async relatedScrolls(req, res) { - incrementMetric("graph_query_frequency"); - logMirrorEvent("graph.query", { type: "related" }); + deps.observability.incrementMetric("graph_query_frequency"); + deps.observability.logEvent("graph.query", { type: "related" }); const graph = await buildLoreGraph(); const scroll = typeof req.query.scroll === "string" ? req.query.scroll : ""; res.json({ related_scrolls: findRelatedScrolls(graph, scroll) }); }, async symbolClusters(req, res) { - incrementMetric("graph_query_frequency"); - logMirrorEvent("graph.query", { type: "symbols" }); + deps.observability.incrementMetric("graph_query_frequency"); + deps.observability.logEvent("graph.query", { type: "symbols" }); const graph = await buildLoreGraph(); const symbol = typeof req.query.symbol === "string" ? req.query.symbol : ""; res.json({ scrolls: findScrollsSharingSymbols(graph, symbol) }); }, async supersessionChains(req, res) { - incrementMetric("graph_query_frequency"); - logMirrorEvent("graph.query", { type: "supersession" }); + deps.observability.incrementMetric("graph_query_frequency"); + deps.observability.logEvent("graph.query", { type: "supersession" }); const graph = await buildLoreGraph(); const scroll = typeof req.query.scroll === "string" ? req.query.scroll : ""; res.json({ chain: findSupersessionChains(graph, scroll) }); }, async conceptClusters(_req, res) { - incrementMetric("graph_query_frequency"); - logMirrorEvent("graph.query", { type: "clusters" }); + deps.observability.incrementMetric("graph_query_frequency"); + deps.observability.logEvent("graph.query", { type: "clusters" }); const graph = await buildLoreGraph(); res.json({ clusters: findConceptClusters(graph) }); }, diff --git a/src/mirror-observability/observability.test.ts b/src/mirror-observability/observability.test.ts index 48d546bf19..6dbb0650ea 100644 --- a/src/mirror-observability/observability.test.ts +++ b/src/mirror-observability/observability.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createMirrorConsoleHandlers } from "../mirror-console/console_routes.js"; +import { createMirrorConsoleHandlers } from "../mirror-console/index.js"; import { createMirrorGateway, createMirrorGatewayHandlers } from "../mirror-gateway/index.js"; import { closeMirrorMemoryDb } from "../mirror-memory/db.js"; import { reviewDraftForCanon } from "../mirror-review/index.js"; @@ -207,46 +207,8 @@ describe("mirror observability", () => { return await gateway.executeAdapterRequest(envelope); }), }); - await gatewayHandlers.executeChat( - { - body: { - model: "mirror-default", - messages: [{ role: "user", content: "What happened to the patience vault?" }], - }, - } as never, - createMockResponse() as never, - ); - await gatewayHandlers.executeTool( - { - params: { tool_name: "mirror.find-scroll" }, - body: { query: "patience vault" }, - header: () => undefined, - } as never, - createMockResponse() as never, - ); - process.env.MIRROR_USER_WORKSPACE_DIR = await fs.mkdtemp( - path.join(os.tmpdir(), "mirror-observe-users-"), - ); - tempDirs.push(process.env.MIRROR_USER_WORKSPACE_DIR); - await gatewayHandlers.executeTool( - { - params: { tool_name: "mirror.task.create" }, - body: { user_id: "alice", title: "Review open work" }, - header: (name: string) => - name.toLowerCase() === "x-mirror-operator-token" ? "secret" : undefined, - } as never, - createMockResponse() as never, - ); - await gatewayHandlers.executeTool( - { - params: { tool_name: "mirror.monk.context" }, - body: { user_id: "alice" }, - header: () => undefined, - } as never, - createMockResponse() as never, - ); - - const observabilityHandlers = createMirrorObservabilityHandlers(); + const observability = createMirrorObservabilityContext(); + const observabilityHandlers = createMirrorObservabilityHandlers(observability); const syncHandlers = createMirrorSyncHandlers( createMirrorSyncManager({ nodeId: "observe-node", @@ -255,17 +217,58 @@ describe("mirror observability", () => { ); const consoleHandlers = createMirrorConsoleHandlers(gatewayHandlers, { syncHandlers, + observability, observabilityHandlers, health: (_req, res) => res.json({ ok: true }), }); - await consoleHandlers.relatedScrolls( - { query: { scroll: "TOBY_L1219" } } as never, - createMockResponse() as never, - ); + await runWithMirrorObservabilityContext(observability, async () => { + await gatewayHandlers.executeChat( + { + body: { + model: "mirror-default", + messages: [{ role: "user", content: "What happened to the patience vault?" }], + }, + } as never, + createMockResponse() as never, + ); + await gatewayHandlers.executeTool( + { + params: { tool_name: "mirror.find-scroll" }, + body: { query: "patience vault" }, + header: () => undefined, + } as never, + createMockResponse() as never, + ); + process.env.MIRROR_USER_WORKSPACE_DIR = await fs.mkdtemp( + path.join(os.tmpdir(), "mirror-observe-users-"), + ); + tempDirs.push(process.env.MIRROR_USER_WORKSPACE_DIR); + await gatewayHandlers.executeTool( + { + params: { tool_name: "mirror.task.create" }, + body: { user_id: "alice", title: "Review open work" }, + header: (name: string) => + name.toLowerCase() === "x-mirror-operator-token" ? "secret" : undefined, + } as never, + createMockResponse() as never, + ); + await gatewayHandlers.executeTool( + { + params: { tool_name: "mirror.monk.context" }, + body: { user_id: "alice" }, + header: () => undefined, + } as never, + createMockResponse() as never, + ); + await consoleHandlers.relatedScrolls( + { query: { scroll: "TOBY_L1219" } } as never, + createMockResponse() as never, + ); - await reviewDraftForCanon({ - loreDir, - draftContent: validDraft("The Patience Vault was not cancelled."), + await reviewDraftForCanon({ + loreDir, + draftContent: validDraft("The Patience Vault was not cancelled."), + }); }); const metricsRes = createMockResponse(); diff --git a/src/mirror-observability/observability_server.ts b/src/mirror-observability/observability_server.ts index 84c75ecfec..655fa33c01 100644 --- a/src/mirror-observability/observability_server.ts +++ b/src/mirror-observability/observability_server.ts @@ -1,8 +1,5 @@ import express from "express"; -import { - getDefaultMirrorObservabilityContext, - type MirrorObservabilityContext, -} from "./context.js"; +import { type MirrorObservabilityContext } from "./context.js"; export type MirrorObservabilityHandlers = { metrics: (req: express.Request, res: express.Response) => void; @@ -10,7 +7,7 @@ export type MirrorObservabilityHandlers = { }; export function createMirrorObservabilityHandlers( - observability: MirrorObservabilityContext = getDefaultMirrorObservabilityContext(), + observability: MirrorObservabilityContext, ): MirrorObservabilityHandlers { return { metrics: (_req, res) => { @@ -23,7 +20,7 @@ export function createMirrorObservabilityHandlers( } export function createMirrorObservabilityRouter( - handlers = createMirrorObservabilityHandlers(), + handlers: MirrorObservabilityHandlers, ): express.Router { const router = express.Router(); router.get("/mirror/metrics", handlers.metrics); diff --git a/src/mirror-service/mirror_service.test.ts b/src/mirror-service/mirror_service.test.ts index da8f56cc27..4ee282d007 100644 --- a/src/mirror-service/mirror_service.test.ts +++ b/src/mirror-service/mirror_service.test.ts @@ -1407,6 +1407,7 @@ describe("mirror service", () => { daemon_session_id: string; uptime_ms: number; service: { node_id: string; port: number }; + sync: { peers_known: number }; event_stream: { sse_available: boolean; ws_available: boolean }; correlation: { trace_id: boolean; session_id: boolean }; }; @@ -1416,6 +1417,7 @@ describe("mirror service", () => { version: string; daemon_session_id: string; service: { node_id: string; port: number }; + sync: { peers_known: number }; }; expect(health.ok).toBe(true); @@ -1425,6 +1427,7 @@ describe("mirror service", () => { expect(health.uptime_ms).toBeGreaterThanOrEqual(0); expect(health.service.node_id).toBe("health-node"); expect(health.service.port).toBe(service.port); + expect(health.sync.peers_known).toBe(0); expect(health.event_stream.sse_available).toBe(true); expect(health.event_stream.ws_available).toBe(true); expect(health.correlation.trace_id).toBe(true); @@ -1435,6 +1438,7 @@ describe("mirror service", () => { expect(status.daemon_session_id).toBe(health.daemon_session_id); expect(status.service.node_id).toBe(health.service.node_id); expect(status.service.port).toBe(health.service.port); + expect(status.sync.peers_known).toBe(health.sync.peers_known); } finally { await service.shutdown(); } @@ -1595,6 +1599,11 @@ describe("mirror service", () => { }, }); + const health = (await requestJsonFromApp(service.app, "GET", "/mirror/health")) as { + ok: boolean; + sync: { peers_known: number }; + }; + const sync = (await requestJsonFromApp(service.app, "GET", "/mirror/sync")) as { ok: boolean; daemon_session_id: string; @@ -1609,6 +1618,8 @@ describe("mirror service", () => { }>; }; + expect(health.ok).toBe(true); + expect(health.sync.peers_known).toBe(1); expect(sync.ok).toBe(true); expect(sync.daemon_session_id.length).toBeGreaterThan(0); expect(sync.peers_known).toBe(1); @@ -1627,6 +1638,93 @@ describe("mirror service", () => { } }); + it("reflects websocket connection counts on the runtime summary as sockets connect and disconnect", async () => { + const loreDir = await createTempLoreDir(); + await seedLoreCorpus(loreDir); + process.env.MIRROR_MEMORY_DB_PATH = await createTempMemoryDbPath(); + process.env.MIRROR_OPERATOR_TOKEN = "secret"; + + const service = await startMirrorService({ + port: 0, + loreDir, + providerUrl: "http://brain.local/v1/chat/completions", + providerAuthToken: "token", + nodeId: "runtime-ws-count-node", + }); + + try { + const initialRuntime = (await requestJsonFromApp(service.app, "GET", "/mirror/runtime")) as { + event_stream: { ws_connections: number }; + }; + expect(initialRuntime.event_stream.ws_connections).toBe(0); + + let firstSocket; + let secondSocket; + try { + firstSocket = await openRuntimeWebSocket(service.port, "?backlog=0"); + await firstSocket.waitFor("hello"); + await firstSocket.waitFor("subscribed"); + + const oneConnectionRuntime = (await requestJsonFromApp( + service.app, + "GET", + "/mirror/runtime", + )) as { + event_stream: { ws_connections: number }; + }; + expect(oneConnectionRuntime.event_stream.ws_connections).toBe(1); + + secondSocket = await openRuntimeWebSocket(service.port, "?backlog=0"); + await secondSocket.waitFor("hello"); + await secondSocket.waitFor("subscribed"); + + const twoConnectionRuntime = (await requestJsonFromApp( + service.app, + "GET", + "/mirror/runtime", + )) as { + event_stream: { ws_connections: number }; + }; + expect(twoConnectionRuntime.event_stream.ws_connections).toBe(2); + } catch (error) { + if (isLoopbackSocketPermissionError(error)) { + return; + } + throw error; + } + + secondSocket.socket.close(); + await new Promise((resolve) => { + secondSocket.socket.once("close", () => resolve()); + }); + + const backToOneRuntime = (await requestJsonFromApp( + service.app, + "GET", + "/mirror/runtime", + )) as { + event_stream: { ws_connections: number }; + }; + expect(backToOneRuntime.event_stream.ws_connections).toBe(1); + + firstSocket.socket.close(); + await new Promise((resolve) => { + firstSocket.socket.once("close", () => resolve()); + }); + + const backToZeroRuntime = (await requestJsonFromApp( + service.app, + "GET", + "/mirror/runtime", + )) as { + event_stream: { ws_connections: number }; + }; + expect(backToZeroRuntime.event_stream.ws_connections).toBe(0); + } finally { + await service.shutdown(); + } + }); + it("reports active actions while an action execution is in flight", async () => { const loreDir = await createTempLoreDir(); await seedLoreCorpus(loreDir); @@ -2146,6 +2244,326 @@ describe("mirror service", () => { } }); + it("surfaces runtime websocket connect and disconnect events to live subscribers", async () => { + const loreDir = await createTempLoreDir(); + await seedLoreCorpus(loreDir); + process.env.MIRROR_MEMORY_DB_PATH = await createTempMemoryDbPath(); + process.env.MIRROR_OPERATOR_TOKEN = "secret"; + + const service = await startMirrorService({ + port: 0, + loreDir, + providerUrl: "http://brain.local/v1/chat/completions", + providerAuthToken: "token", + nodeId: "events-ws-live-node", + }); + + try { + let observer; + let subject; + try { + observer = await openRuntimeWebSocket(service.port, "?backlog=0"); + subject = await openRuntimeWebSocket(service.port, "?backlog=0"); + } catch (error) { + if (isLoopbackSocketPermissionError(error)) { + return; + } + throw error; + } + + await observer.waitFor("hello"); + const observerSubscribed = await observer.waitFor("subscribed"); + expect(observerSubscribed.backlog_sent).toBe(0); + + const subjectHello = await subject.waitFor("hello"); + const subjectSubscribed = await subject.waitFor("subscribed"); + expect(subjectSubscribed.backlog_sent).toBe(0); + + const connected = await observer.waitFor( + "runtime.event", + (message) => + message.event.type === "runtime.ws.connected" && + message.event.payload.connection_id === subjectHello.connection_id, + ); + expect(connected.event.payload).toEqual( + expect.objectContaining({ + connection_id: subjectHello.connection_id, + path: MIRROR_RUNTIME_WS_PATH, + }), + ); + + subject.socket.close(); + await new Promise((resolve) => { + subject.socket.once("close", () => resolve()); + }); + + const disconnected = await observer.waitFor( + "runtime.event", + (message) => + message.event.type === "runtime.ws.disconnected" && + message.event.payload.connection_id === subjectHello.connection_id, + ); + expect(disconnected.event.payload).toEqual( + expect.objectContaining({ + connection_id: subjectHello.connection_id, + path: MIRROR_RUNTIME_WS_PATH, + }), + ); + + observer.socket.close(); + await new Promise((resolve) => { + observer.socket.once("close", () => resolve()); + }); + } finally { + await service.shutdown(); + } + }); + + it("replays prior websocket transport events from backlog to reconnecting subscribers", async () => { + const loreDir = await createTempLoreDir(); + await seedLoreCorpus(loreDir); + process.env.MIRROR_MEMORY_DB_PATH = await createTempMemoryDbPath(); + process.env.MIRROR_OPERATOR_TOKEN = "secret"; + + const service = await startMirrorService({ + port: 0, + loreDir, + providerUrl: "http://brain.local/v1/chat/completions", + providerAuthToken: "token", + nodeId: "events-ws-replay-node", + }); + + try { + let firstSocket; + let reconnectingSocket; + try { + firstSocket = await openRuntimeWebSocket(service.port, "?backlog=0"); + } catch (error) { + if (isLoopbackSocketPermissionError(error)) { + return; + } + throw error; + } + + const firstHello = await firstSocket.waitFor("hello"); + const firstSubscribed = await firstSocket.waitFor("subscribed"); + expect(firstSubscribed.backlog_sent).toBe(0); + + firstSocket.socket.close(); + await new Promise((resolve) => { + firstSocket.socket.once("close", () => resolve()); + }); + + try { + reconnectingSocket = await openRuntimeWebSocket(service.port); + } catch (error) { + if (isLoopbackSocketPermissionError(error)) { + return; + } + throw error; + } + + await reconnectingSocket.waitFor("hello"); + const reconnectSubscribed = await reconnectingSocket.waitFor("subscribed"); + expect(reconnectSubscribed.backlog_sent).toBeGreaterThan(0); + + const backlogTransportEvents = reconnectingSocket.messages.filter( + (message): message is Extract => + message.type === "runtime.event" && + (message.event.type === "runtime.ws.connected" || + message.event.type === "runtime.ws.disconnected"), + ); + + expect( + backlogTransportEvents.some( + (message) => + message.event.type === "runtime.ws.connected" && + message.event.payload.connection_id === firstHello.connection_id, + ), + ).toBe(true); + expect( + backlogTransportEvents.some( + (message) => + message.event.type === "runtime.ws.disconnected" && + message.event.payload.connection_id === firstHello.connection_id, + ), + ).toBe(true); + + reconnectingSocket.socket.close(); + await new Promise((resolve) => { + reconnectingSocket.socket.once("close", () => resolve()); + }); + } finally { + await service.shutdown(); + } + }); + + it("replays backlog only when explicitly requested by subscribe control messages", async () => { + const loreDir = await createTempLoreDir(); + await seedLoreCorpus(loreDir); + process.env.MIRROR_MEMORY_DB_PATH = await createTempMemoryDbPath(); + process.env.MIRROR_OPERATOR_TOKEN = "secret"; + + const service = await startMirrorService({ + port: 0, + loreDir, + providerUrl: "http://brain.local/v1/chat/completions", + providerAuthToken: "token", + nodeId: "events-ws-subscribe-node", + }); + + try { + let ws; + try { + ws = await openRuntimeWebSocket(service.port, "?backlog=0"); + } catch (error) { + if (isLoopbackSocketPermissionError(error)) { + return; + } + throw error; + } + + const hello = await ws.waitFor("hello"); + const initialSubscribed = await ws.waitFor("subscribed"); + expect(initialSubscribed.backlog_sent).toBe(0); + + service.daemon.publishRuntimeEvent("operator.inspect.snapshot", { + trace_id: "trace-subscribe-1", + session_id: "session-subscribe-1", + }); + await ws.waitFor( + "runtime.event", + (message) => message.event.type === "operator.inspect.snapshot", + ); + + ws.socket.send(JSON.stringify({ type: "subscribe", backlog: false })); + const noReplaySubscribed = await ws.waitFor( + "subscribed", + (message) => message !== initialSubscribed && message.backlog_sent === 0, + ); + expect(noReplaySubscribed.connection_id).toBe(hello.connection_id); + + const messageCountBeforeReplay = ws.messages.length; + ws.socket.send(JSON.stringify({ type: "subscribe", backlog: true })); + const replaySubscribed = await ws.waitFor( + "subscribed", + (message) => message !== initialSubscribed && message.backlog_sent > 0, + ); + expect(replaySubscribed.connection_id).toBe(hello.connection_id); + + const replayedMessages = ws.messages.slice(messageCountBeforeReplay); + const replayedRuntimeEvents = replayedMessages.filter( + (message): message is Extract => + message.type === "runtime.event", + ); + + expect(replayedRuntimeEvents).toHaveLength(replaySubscribed.backlog_sent); + expect( + replayedRuntimeEvents.some( + (message) => + message.event.type === "operator.inspect.snapshot" && + message.event.correlation?.trace_id === "trace-subscribe-1", + ), + ).toBe(true); + expect( + replayedRuntimeEvents.some((message) => message.event.type === "runtime.started"), + ).toBe(true); + + ws.socket.close(); + await new Promise((resolve) => { + ws.socket.once("close", () => resolve()); + }); + } finally { + await service.shutdown(); + } + }); + + it("returns websocket error envelopes for unsupported control messages", async () => { + const loreDir = await createTempLoreDir(); + await seedLoreCorpus(loreDir); + process.env.MIRROR_MEMORY_DB_PATH = await createTempMemoryDbPath(); + process.env.MIRROR_OPERATOR_TOKEN = "secret"; + + const service = await startMirrorService({ + port: 0, + loreDir, + providerUrl: "http://brain.local/v1/chat/completions", + providerAuthToken: "token", + nodeId: "events-ws-unsupported-node", + }); + + try { + let ws; + try { + ws = await openRuntimeWebSocket(service.port, "?backlog=0"); + } catch (error) { + if (isLoopbackSocketPermissionError(error)) { + return; + } + throw error; + } + + const hello = await ws.waitFor("hello"); + await ws.waitFor("subscribed"); + + ws.socket.send(JSON.stringify({ type: "unknown-control-message" })); + const errorEnvelope = await ws.waitFor( + "error", + (message) => message.code === "unsupported_message", + ); + expect(errorEnvelope.connection_id).toBe(hello.connection_id); + expect(errorEnvelope.message).toBe("Unsupported Mirror runtime websocket message"); + + ws.socket.close(); + await new Promise((resolve) => { + ws.socket.once("close", () => resolve()); + }); + } finally { + await service.shutdown(); + } + }); + + it("returns websocket error envelopes for invalid control payloads", async () => { + const loreDir = await createTempLoreDir(); + await seedLoreCorpus(loreDir); + process.env.MIRROR_MEMORY_DB_PATH = await createTempMemoryDbPath(); + process.env.MIRROR_OPERATOR_TOKEN = "secret"; + + const service = await startMirrorService({ + port: 0, + loreDir, + providerUrl: "http://brain.local/v1/chat/completions", + providerAuthToken: "token", + nodeId: "events-ws-invalid-node", + }); + + try { + let ws; + try { + ws = await openRuntimeWebSocket(service.port, "?backlog=0"); + } catch (error) { + if (isLoopbackSocketPermissionError(error)) { + return; + } + throw error; + } + + const hello = await ws.waitFor("hello"); + await ws.waitFor("subscribed"); + + ws.socket.send("not-json"); + const errorEnvelope = await ws.waitFor("error", (message) => message.code === "invalid_json"); + expect(errorEnvelope.connection_id).toBe(hello.connection_id); + expect(errorEnvelope.message).toBe("Invalid Mirror runtime websocket payload"); + + ws.socket.close(); + await new Promise((resolve) => { + ws.socket.once("close", () => resolve()); + }); + } finally { + await service.shutdown(); + } + }); it("keeps service, console, daemon, observability, and status surfaces in sync", async () => { const loreDir = await createTempLoreDir(); await seedLoreCorpus(loreDir); diff --git a/src/mirror-service/mirror_service.ts b/src/mirror-service/mirror_service.ts index 1852c9b02e..8951968094 100644 --- a/src/mirror-service/mirror_service.ts +++ b/src/mirror-service/mirror_service.ts @@ -126,7 +126,7 @@ export async function startMirrorService( wsConnections: runtimeWebSocket.getConnectionCount(), sseAvailable: true, wsAvailable: true, - peersKnown: observability.getMetrics().gauges.peers_known || syncManager.listPeers().length, + peers: syncManager.listPeers(), }); daemon.publishRuntimeEvent("runtime.health.requested", { path: "/mirror/health", @@ -135,6 +135,7 @@ export async function startMirrorService( }; const consoleHandlers = createMirrorConsoleHandlers(handlers, { syncHandlers, + observability, observabilityHandlers, health: healthHandler, }); @@ -149,7 +150,7 @@ export async function startMirrorService( getMirrordaemonHealthState(daemon, { port: boundPort, baseUrl: syncManager.getLocalBaseUrl(), - peersKnown: observability.getMetrics().gauges.peers_known || syncManager.listPeers().length, + peers: syncManager.listPeers(), }), getBaseUrl: () => syncManager.getLocalBaseUrl(), }); @@ -179,7 +180,6 @@ export async function startMirrorService( getMirrordaemonDebugState(daemon, { port: boundPort, baseUrl: syncManager.getLocalBaseUrl(), - peersKnown: observability.getMetrics().gauges.peers_known || syncManager.listPeers().length, }), ); }); diff --git a/src/mirror-service/runtime_events_ws.ts b/src/mirror-service/runtime_events_ws.ts index a91aab36a0..ba1b985ba1 100644 --- a/src/mirror-service/runtime_events_ws.ts +++ b/src/mirror-service/runtime_events_ws.ts @@ -3,7 +3,11 @@ import { randomUUID } from "node:crypto"; import type http from "node:http"; import type { Duplex } from "node:stream"; import { WebSocketServer, type WebSocket } from "ws"; -import type { Mirrordaemon, MirrordaemonRuntimeEvent } from "../mirrordaemon/index.js"; +import { + getMirrordaemonRuntimeState, + type Mirrordaemon, + type MirrordaemonRuntimeEvent, +} from "../mirrordaemon/index.js"; export const MIRROR_RUNTIME_WS_PROTOCOL = "mirror.runtime.ws.v1"; export const MIRROR_RUNTIME_WS_PATH = "/mirror/runtime/ws"; @@ -121,7 +125,7 @@ export function createMirrorRuntimeWebSocketServer(params: { wss.on("connection", (ws, req) => { sockets.add(ws); const connectionId = randomUUID(); - const boot = params.daemon.getBootSnapshot(); + const runtime = getMirrordaemonRuntimeState(params.daemon); const url = new URL(req.url ?? MIRROR_RUNTIME_WS_PATH, "http://127.0.0.1"); params.daemon.publishRuntimeEvent("runtime.ws.connected", { @@ -133,8 +137,8 @@ export function createMirrorRuntimeWebSocketServer(params: { protocol: MIRROR_RUNTIME_WS_PROTOCOL, type: "hello", connection_id: connectionId, - node_id: boot.config.node_id, - runtime_started_at: boot.runtime_started_at, + node_id: runtime.node_id, + runtime_started_at: runtime.runtime_started_at, stream: RUNTIME_EVENT_STREAM, }); diff --git a/src/mirror-service/runtime_host.ts b/src/mirror-service/runtime_host.ts index e4b95266f3..cefab49be7 100644 --- a/src/mirror-service/runtime_host.ts +++ b/src/mirror-service/runtime_host.ts @@ -123,6 +123,7 @@ export async function createMirrorRuntimeHost( baseUrl: config.baseUrl, fetchImpl: deps.fetchImpl, onRuntimeEvent: daemon.publishRuntimeEvent, + observability: daemon.getObservability(), }); return { diff --git a/src/mirror-sync/canon_sync.ts b/src/mirror-sync/canon_sync.ts index 31123009bb..3d23d5fdeb 100644 --- a/src/mirror-sync/canon_sync.ts +++ b/src/mirror-sync/canon_sync.ts @@ -1,7 +1,6 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import { incrementMetric } from "../mirror-observability/index.js"; import { ensureScrollIndexUpToDate } from "../mirror/lore_sources/index.js"; import { validateLoreDraftInCorpusContext } from "../mirror/lore_validation/index.js"; import type { @@ -10,6 +9,11 @@ import type { MirrorSyncConflict, } from "./sync_types.js"; +type MirrorCanonSyncMetricHooks = { + onConflictWarning?: () => void; + onUpdatesPulled?: (count: number) => void; +}; + async function listCanonicalMarkdownFiles(loreDir: string): Promise { const files: string[] = []; @@ -131,6 +135,7 @@ export async function applyRemoteCanonUpdates(params: { local: MirrorCanonUpdatesSnapshot; remote: MirrorCanonUpdatesSnapshot; remoteContents: Record; + metrics?: MirrorCanonSyncMetricHooks; }): Promise<{ pulledFiles: string[]; skippedFiles: Array<{ path: string; reason: string }>; @@ -145,7 +150,7 @@ export async function applyRemoteCanonUpdates(params: { for (const remoteFile of remoteByPath.values()) { const safePath = resolveSafeCanonPath(params.loreDir, remoteFile.path); if (!safePath) { - incrementMetric("conflict_warnings"); + params.metrics?.onConflictWarning?.(); conflicts.push({ path: remoteFile.path, reason: "unsafe_path", @@ -161,7 +166,7 @@ export async function applyRemoteCanonUpdates(params: { } if (localFile && localFile.updated_at_ms > remoteFile.updated_at_ms) { - incrementMetric("conflict_warnings"); + params.metrics?.onConflictWarning?.(); conflicts.push({ path: remoteFile.path, reason: "local_newer", @@ -175,7 +180,7 @@ export async function applyRemoteCanonUpdates(params: { localFile.updated_at_ms === remoteFile.updated_at_ms && localFile.sha256 !== remoteFile.sha256 ) { - incrementMetric("conflict_warnings"); + params.metrics?.onConflictWarning?.(); conflicts.push({ path: remoteFile.path, reason: "same_timestamp_different_content", @@ -189,7 +194,7 @@ export async function applyRemoteCanonUpdates(params: { params.remote.index_version < params.local.index_version && remoteFile.updated_at_ms <= localFile.updated_at_ms ) { - incrementMetric("conflict_warnings"); + params.metrics?.onConflictWarning?.(); conflicts.push({ path: remoteFile.path, reason: "remote_older_index", @@ -210,7 +215,7 @@ export async function applyRemoteCanonUpdates(params: { draftContent: content, }); if (validation.warningCount > 0) { - incrementMetric("conflict_warnings"); + params.metrics?.onConflictWarning?.(); conflicts.push({ path: remoteFile.path, reason: "invalid_remote_canon", @@ -225,7 +230,7 @@ export async function applyRemoteCanonUpdates(params: { if (pulledFiles.length > 0) { await ensureScrollIndexUpToDate(params.loreDir); - incrementMetric("updates_pulled", pulledFiles.length); + params.metrics?.onUpdatesPulled?.(pulledFiles.length); } return { diff --git a/src/mirror-sync/mirror_sync.test.ts b/src/mirror-sync/mirror_sync.test.ts index 6104a797c4..b6afe40632 100644 --- a/src/mirror-sync/mirror_sync.test.ts +++ b/src/mirror-sync/mirror_sync.test.ts @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { getMirrorDiagnostics, getMirrorMetrics, + incrementMetric, + logMirrorEvent, resetMirrorDiagnostics, resetMirrorMetrics, } from "../mirror-observability/index.js"; @@ -122,6 +124,43 @@ function createMockResponse() { }; } +function createTestSyncObservabilityHooks() { + return { + onConflictWarning: () => { + incrementMetric("conflict_warnings"); + }, + onUpdatesPulled: (count: number) => { + incrementMetric("updates_pulled", count); + }, + onSyncFailure: () => { + incrementMetric("sync_failures"); + }, + onPeerAnnounced: (payload: { peer_id: string; base_url: string }) => { + logMirrorEvent("sync.peer.announced", payload); + }, + onPullCompleted: (payload: { + peer_id: string; + pulled_files: number; + conflicts: number; + graph_rebuilt: boolean; + }) => { + logMirrorEvent("sync.pull.completed", payload); + }, + onPullFailed: (payload: { peer_id: string; error: string }) => { + logMirrorEvent("sync.pull.failed", payload); + }, + }; +} + +function createObservedSyncManager( + options: Parameters[0], +): MirrorSyncManager { + return createMirrorSyncManager({ + ...options, + observability: createTestSyncObservabilityHooks(), + }); +} + describe("mirror sync", () => { it("executes shared sync actions for the supported routes", async () => { const peer = { @@ -381,7 +420,7 @@ describe("mirror sync", () => { ); await seedIndexFiles(loreDir, { stillness: ["TOBY_L0001_SeedOfStillness.md"] }); - const manager = createMirrorSyncManager({ + const manager = createObservedSyncManager({ nodeId: "node-a", loreDir, baseUrl: "http://127.0.0.1:7001", @@ -407,7 +446,7 @@ describe("mirror sync", () => { ); await seedIndexFiles(loreDir, { renewal: ["TOBY_L0001_SeedOfStillness.md"] }); - const manager = createMirrorSyncManager({ + const manager = createObservedSyncManager({ nodeId: "node-a", loreDir, baseUrl: "http://127.0.0.1:7001", @@ -450,12 +489,12 @@ describe("mirror sync", () => { pond: ["TOBY_L0002_PondMemory.md"], }); - const remoteManager = createMirrorSyncManager({ + const remoteManager = createObservedSyncManager({ nodeId: "node-remote", loreDir: remoteLoreDir, baseUrl: "http://127.0.0.1:7002", }); - const localManager = createMirrorSyncManager({ + const localManager = createObservedSyncManager({ nodeId: "node-local", loreDir: localLoreDir, baseUrl: "http://127.0.0.1:7001", @@ -499,12 +538,12 @@ describe("mirror sync", () => { await fs.utimes(path.join(localLoreDir, "TOBY_L0001_SeedOfStillness.md"), oldTime, oldTime); await fs.utimes(path.join(remoteLoreDir, "TOBY_L0001_SeedOfStillness.md"), newTime, newTime); - const remoteManager = createMirrorSyncManager({ + const remoteManager = createObservedSyncManager({ nodeId: "node-remote", loreDir: remoteLoreDir, baseUrl: "http://127.0.0.1:7002", }); - const localManager = createMirrorSyncManager({ + const localManager = createObservedSyncManager({ nodeId: "node-local", loreDir: localLoreDir, baseUrl: "http://127.0.0.1:7001", @@ -543,12 +582,12 @@ describe("mirror sync", () => { ); await seedIndexFiles(remoteLoreDir, { invalid: ["TOBY_L0002_InvalidRemote.md"] }); - const remoteManager = createMirrorSyncManager({ + const remoteManager = createObservedSyncManager({ nodeId: "node-remote", loreDir: remoteLoreDir, baseUrl: "http://127.0.0.1:7002", }); - const localManager = createMirrorSyncManager({ + const localManager = createObservedSyncManager({ nodeId: "node-local", loreDir: localLoreDir, baseUrl: "http://127.0.0.1:7001", @@ -599,12 +638,12 @@ describe("mirror sync", () => { pond: ["TOBY_L0002_PondMemory.md"], }); - const remoteManager = createMirrorSyncManager({ + const remoteManager = createObservedSyncManager({ nodeId: "node-remote", loreDir: remoteLoreDir, baseUrl: "http://127.0.0.1:7002", }); - const localManager = createMirrorSyncManager({ + const localManager = createObservedSyncManager({ nodeId: "node-local", loreDir: localLoreDir, baseUrl: "http://127.0.0.1:7001", diff --git a/src/mirror-sync/sync_manager.ts b/src/mirror-sync/sync_manager.ts index 2d120216f3..f1973787b6 100644 --- a/src/mirror-sync/sync_manager.ts +++ b/src/mirror-sync/sync_manager.ts @@ -1,5 +1,5 @@ import express from "express"; -import { incrementMetric, logMirrorEvent } from "../mirror-observability/index.js"; +import type { MirrorObservabilityContext } from "../mirror-observability/index.js"; import type { FetchLike } from "../mirror-provider/index.js"; import { applyRemoteCanonUpdates, @@ -40,6 +40,25 @@ export type MirrorSyncHandlers = { pull: (req: express.Request, res: express.Response) => Promise; }; +export type MirrorSyncObservabilityHooks = { + onConflictWarning?: () => void; + onUpdatesPulled?: (count: number) => void; + onSyncFailure?: () => void; + onPeerAnnounced?: (payload: { peer_id: string; base_url: string }) => void; + onPullCompleted?: (payload: { + peer_id: string; + pulled_files: number; + conflicts: number; + graph_rebuilt: boolean; + }) => void; + onPullFailed?: (payload: { peer_id: string; error: string }) => void; +}; + +type MirrorSyncObservabilityContextLike = Pick< + MirrorObservabilityContext, + "incrementMetric" | "logEvent" +>; + type MirrorSyncManagerOptions = { nodeId: string; loreDir: string; @@ -47,8 +66,53 @@ type MirrorSyncManagerOptions = { fetchImpl?: FetchLike; registry?: MirrorPeerRegistry; onRuntimeEvent?: (type: string, payload?: Record) => void; + observability?: MirrorSyncObservabilityHooks | MirrorSyncObservabilityContextLike; }; +function hasMirrorSyncObservabilityContext( + observability: MirrorSyncManagerOptions["observability"], +): observability is MirrorSyncObservabilityContextLike { + return ( + !!observability && + typeof observability === "object" && + "incrementMetric" in observability && + typeof observability.incrementMetric === "function" && + "logEvent" in observability && + typeof observability.logEvent === "function" + ); +} + +function normalizeMirrorSyncObservabilityHooks( + observability: MirrorSyncManagerOptions["observability"], +): MirrorSyncObservabilityHooks { + if (!observability) { + return {}; + } + if (!hasMirrorSyncObservabilityContext(observability)) { + return observability; + } + return { + onConflictWarning: () => { + observability.incrementMetric("conflict_warnings"); + }, + onUpdatesPulled: (count) => { + observability.incrementMetric("updates_pulled", count); + }, + onSyncFailure: () => { + observability.incrementMetric("sync_failures"); + }, + onPeerAnnounced: (payload) => { + observability.logEvent("sync.peer.announced", payload); + }, + onPullCompleted: (payload) => { + observability.logEvent("sync.pull.completed", payload); + }, + onPullFailed: (payload) => { + observability.logEvent("sync.pull.failed", payload); + }, + }; +} + async function parseJsonResponse(response: Response): Promise { if (!response.ok) { throw new Error(`sync request failed: ${response.status} ${await response.text()}`); @@ -225,6 +289,7 @@ function collectMirrorSyncPullNeededPaths( export function createMirrorSyncManager(options: MirrorSyncManagerOptions): MirrorSyncManager { const fetchImpl = options.fetchImpl ?? fetch; const registry = options.registry ?? createMirrorPeerRegistry(); + const observability = normalizeMirrorSyncObservabilityHooks(options.observability); let localBaseUrl = options.baseUrl ? normalizeMirrorPeerBaseUrl(options.baseUrl) : null; return { @@ -241,7 +306,7 @@ export function createMirrorSyncManager(options: MirrorSyncManagerOptions): Mirr peer_id: peer.peer_id, base_url: peer.base_url, }); - logMirrorEvent("sync.peer.announced", { + observability.onPeerAnnounced?.({ peer_id: peer.peer_id, base_url: peer.base_url, }); @@ -304,6 +369,14 @@ export function createMirrorSyncManager(options: MirrorSyncManagerOptions): Mirr local: localUpdates.canon, remote: remoteUpdates.canon, remoteContents, + metrics: { + onConflictWarning: () => { + observability.onConflictWarning?.(); + }, + onUpdatesPulled: (count) => { + observability.onUpdatesPulled?.(count); + }, + }, }); const graphResult = await syncLocalGraphFromRemote({ @@ -318,7 +391,7 @@ export function createMirrorSyncManager(options: MirrorSyncManagerOptions): Mirr conflicts: canonResult.conflicts.length, graph_rebuilt: graphResult.rebuilt, }); - logMirrorEvent("sync.pull.completed", { + observability.onPullCompleted?.({ peer_id: peer.peer_id, pulled_files: canonResult.pulledFiles.length, conflicts: canonResult.conflicts.length, @@ -332,13 +405,13 @@ export function createMirrorSyncManager(options: MirrorSyncManagerOptions): Mirr graphResult, }); } catch (error) { - incrementMetric("sync_failures"); + observability.onSyncFailure?.(); registry.markStatus(peer.peer_id, "error", String(error)); options.onRuntimeEvent?.("sync.pull.failed", { peer_id: peer.peer_id, error: String(error), }); - logMirrorEvent("sync.pull.failed", { + observability.onPullFailed?.({ peer_id: peer.peer_id, error: String(error), }); diff --git a/src/mirror/doctor/tests/checks.test.ts b/src/mirror/doctor/tests/checks.test.ts index 3c132a99b7..722d0731a9 100644 --- a/src/mirror/doctor/tests/checks.test.ts +++ b/src/mirror/doctor/tests/checks.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { runMirrorDoctorChecks } from "../checks.js"; +import { runMirrorDoctorChecks } from "../index.js"; describe("mirror doctor checks", () => { it("detects Mirror-native identity env keys only", async () => { diff --git a/src/mirror/doctor/tests/doctor.test.ts b/src/mirror/doctor/tests/doctor.test.ts index cfdc67aa9f..eb05ad02f5 100644 --- a/src/mirror/doctor/tests/doctor.test.ts +++ b/src/mirror/doctor/tests/doctor.test.ts @@ -19,7 +19,7 @@ vi.mock("../index.js", () => ({ runMirrorDoctor, })); -const { runMirrorDoctorCli } = await import("../../telemetry_tail/cli.js"); +const { runMirrorDoctorCli } = await import("../../telemetry_tail/index.js"); function getSubcommand(parent: Command, name: string): Command | undefined { return parent.commands.find((command) => command.name() === name); diff --git a/src/mirror/openclaw-env-boundary.test.ts b/src/mirror/openclaw-env-boundary.test.ts new file mode 100644 index 0000000000..a8e435525d --- /dev/null +++ b/src/mirror/openclaw-env-boundary.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { loadRuntimeSourceFilesForGuardrails } from "../test-utils/runtime-source-guardrail-scan.js"; + +const MIRROR_OWNED_PREFIXES = [ + "src/mirror/", + "src/mirror-cli/", + "src/mirror-service/", + "src/mirror-runtime/", + "src/mirror-provider/", + "src/mirror-gateway/", + "src/mirrordaemon/", +] as const; +const MIRROR_OWNED_FILES: ReadonlySet = new Set([ + "src/mirror-entry.ts", + "src/mirror-package.ts", +]); +const ALLOWED_PATHS: ReadonlySet = new Set([]); +const OPENCLAW_ENV_PATTERNS = [/\bOPENCLAW_[A-Z0-9_]+\b/g, /\bCLAWDBOT_[A-Z0-9_]+\b/g] as const; + +function isCanonicalMirrorOwnedFile(relativePath: string): boolean { + if (!relativePath.endsWith(".ts") && !relativePath.endsWith(".tsx")) { + return false; + } + if (relativePath.endsWith(".test.ts") || relativePath.endsWith(".test.tsx")) { + return false; + } + return ( + MIRROR_OWNED_PREFIXES.some((prefix) => relativePath.startsWith(prefix)) || + MIRROR_OWNED_FILES.has(relativePath) + ); +} + +function stripCommentsForScan(input: string): string { + return input.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +describe("Mirror-owned OpenClaw env boundary", () => { + it("rejects direct OpenClaw-specific env/config coupling in canonical Mirror-owned modules", async () => { + const files = await loadRuntimeSourceFilesForGuardrails(process.cwd()); + const offenders: string[] = []; + + for (const file of files) { + const relativePath = file.relativePath.replaceAll("\\", "/"); + if (!isCanonicalMirrorOwnedFile(relativePath) || ALLOWED_PATHS.has(relativePath)) { + continue; + } + + const scanSource = stripCommentsForScan(file.source); + const matches = new Set(); + for (const pattern of OPENCLAW_ENV_PATTERNS) { + for (const match of scanSource.matchAll(pattern)) { + matches.add(match[0]); + } + } + + if (matches.size === 0) { + continue; + } + + offenders.push(`${relativePath}: ${Array.from(matches).toSorted().join(", ")}`); + } + + expect(offenders).toEqual([]); + }); +}); diff --git a/src/mirror/openclaw-import-boundary.test.ts b/src/mirror/openclaw-import-boundary.test.ts new file mode 100644 index 0000000000..e11daa00bb --- /dev/null +++ b/src/mirror/openclaw-import-boundary.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { loadRuntimeSourceFilesForGuardrails } from "../test-utils/runtime-source-guardrail-scan.js"; + +const MIRROR_OWNED_PREFIXES = [ + "src/mirror/", + "src/mirror-cli/", + "src/mirror-service/", + "src/mirror-runtime/", + "src/mirror-provider/", + "src/mirror-gateway/", + "src/mirrordaemon/", +] as const; +const MIRROR_OWNED_FILES: ReadonlySet = new Set([ + "src/mirror-entry.ts", + "src/mirror-package.ts", +]); +const ALLOWED_PATHS: ReadonlySet = new Set([]); +const MODULE_SPECIFIER_PATTERNS = [ + /\bimport\s+type\s+[^;]*?\bfrom\s*["'`](?[^"'`]*)["'`]/gu, + /\bimport\s+[^;]*?\bfrom\s*["'`](?[^"'`]*)["'`]/gu, + /\bexport\s+[^;]*?\bfrom\s*["'`](?[^"'`]*)["'`]/gu, + /\bimport\s*\(\s*["'`](?[^"'`]*)["'`]\s*\)/gu, + /\brequire\s*\(\s*["'`](?[^"'`]*)["'`]\s*\)/gu, +] as const; + +function isCanonicalMirrorOwnedFile(relativePath: string): boolean { + if (!relativePath.endsWith(".ts") && !relativePath.endsWith(".tsx")) { + return false; + } + if (relativePath.endsWith(".test.ts") || relativePath.endsWith(".test.tsx")) { + return false; + } + return ( + MIRROR_OWNED_PREFIXES.some((prefix) => relativePath.startsWith(prefix)) || + MIRROR_OWNED_FILES.has(relativePath) + ); +} + +function stripCommentsForScan(input: string): string { + return input.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +function isOpenClawCompatSpecifier(specifier: string): boolean { + const normalized = specifier.replaceAll("\\", "/"); + return ( + normalized === "openclaw" || + normalized.startsWith("openclaw/") || + normalized.includes("/compat/openclaw/") || + normalized.endsWith("/compat/openclaw") + ); +} + +describe("Mirror-owned OpenClaw import boundary", () => { + it("rejects direct OpenClaw/compat import coupling in canonical Mirror-owned modules", async () => { + const files = await loadRuntimeSourceFilesForGuardrails(process.cwd()); + const offenders: string[] = []; + + for (const file of files) { + const relativePath = file.relativePath.replaceAll("\\", "/"); + if (!isCanonicalMirrorOwnedFile(relativePath) || ALLOWED_PATHS.has(relativePath)) { + continue; + } + + const scanSource = stripCommentsForScan(file.source); + const matches = new Set(); + for (const pattern of MODULE_SPECIFIER_PATTERNS) { + for (const match of scanSource.matchAll(pattern)) { + const specifier = match.groups?.specifier?.trim(); + if (specifier && isOpenClawCompatSpecifier(specifier)) { + matches.add(specifier); + } + } + } + + if (matches.size === 0) { + continue; + } + + offenders.push(`${relativePath}: ${Array.from(matches).toSorted().join(", ")}`); + } + + expect(offenders).toEqual([]); + }); +}); diff --git a/src/mirror/status/format.ts b/src/mirror/status/format.ts index 6bd78f6579..279f6aba20 100644 --- a/src/mirror/status/format.ts +++ b/src/mirror/status/format.ts @@ -7,8 +7,6 @@ function yesNo(value: boolean): string { export function formatMirrorStatusHuman(status: MirrorStatus): string { const lines = [ "🪞 Mirror Runtime", - `ts: ${status.ts}`, - `cwd: ${status.cwd}`, "runtime:", `- nodeId: ${status.runtime.node_id}`, `- startedAt: ${status.runtime.runtime_started_at}`, @@ -40,10 +38,6 @@ export function formatMirrorStatusHuman(status: MirrorStatus): string { `- nodeId: ${status.sync.node_id}`, `- baseUrl: ${status.sync.base_url ?? "-"}`, `- peersKnown: ${status.sync.peers_known}`, - "observability:", - `- diagnosticsEvents: ${status.observability.diagnostics_events}`, - `- chatRequests: ${status.observability.metrics.counters.chat_requests}`, - `- toolExecutions: ${status.observability.metrics.counters.tool_executions}`, "", ]; diff --git a/src/mirror/status/status.ts b/src/mirror/status/status.ts index 0d5ecbcbd0..c5aabddfd1 100644 --- a/src/mirror/status/status.ts +++ b/src/mirror/status/status.ts @@ -1,13 +1,11 @@ -import type { MirrorMetricsSnapshot } from "../../mirror-observability/index.js"; import type { MirrorRuntimeHost } from "../../mirror-service/index.js"; import { getMirrordaemonHealthState, + getMirrordaemonProvidersState, getMirrordaemonRuntimeState, } from "../../mirrordaemon/index.js"; export type MirrorStatus = { - ts: string; - cwd: string; runtime: ReturnType; service: { lore_dir: string; @@ -45,26 +43,15 @@ export type MirrorStatus = { base_url: string | null; peers_known: number; }; - observability: { - metrics: MirrorMetricsSnapshot; - diagnostics_events: number; - }; }; export type GetMirrorStatusOptions = { runtimeHost: MirrorRuntimeHost; - cwd?: string; - now?: Date; }; export async function getMirrorStatus(opts: GetMirrorStatusOptions): Promise { - const now = opts.now ?? new Date(); - const cwd = opts.cwd ?? process.cwd(); const daemon = opts.runtimeHost.daemon; - const boot = daemon.getBootSnapshot(); - const observability = daemon.getObservability(); - const metrics = observability.getMetrics(); - const peersKnown = metrics.gauges.peers_known || opts.runtimeHost.syncManager.listPeers().length; + const peers = opts.runtimeHost.syncManager.listPeers(); const baseUrl = opts.runtimeHost.syncManager.getLocalBaseUrl(); const runtime = getMirrordaemonRuntimeState(daemon, { port: opts.runtimeHost.config.port, @@ -73,27 +60,28 @@ export async function getMirrorStatus(opts: GetMirrorStatusOptions): Promise ({ + active_provider_id: providers.active_provider_id, + total: providers.total, + available: providers.available, + fallback_available: providers.fallback_available, + providers: providers.providers.map((provider) => ({ provider_id: provider.provider_id, label: provider.label, url: provider.url, @@ -103,22 +91,18 @@ export async function getMirrorStatus(opts: GetMirrorStatusOptions): Promise { describe("mirror status", () => { it("returns daemon-backed runtime and lore truth", async () => { - const dir = await createTempDir(); const loreDir = await createTempDir(); const runtimeHost = await createMirrorRuntimeHost({ loreDir, @@ -33,21 +32,19 @@ describe("mirror status", () => { try { const status = await getMirrorStatus({ runtimeHost, - cwd: dir, - now: new Date("2026-03-05T00:00:00.000Z"), }); - expect(status.ts).toBe("2026-03-05T00:00:00.000Z"); expect(status.runtime.node_id).toBe("status-node"); expect(status.runtime.sessions.total).toBe(0); expect(status.service.lore_dir).toBe(path.resolve(loreDir)); + expect(status.lore.dir).toBe(status.service.lore_dir); + expect(status.service.workspace_users_root).toBe(status.workspace.users_root); expect(status.provider.configured).toBe(false); expect(status.provider.active_provider_id).toBe("primary"); expect(status.provider.total).toBe(1); expect(status.lore.ready).toBe(true); expect(status.workspace.ready).toBe(true); expect(status.sync.node_id).toBe("status-node"); - expect(status.observability.metrics.counters.chat_requests).toBe(0); expect(JSON.stringify(status)).not.toContain("travelerName"); } finally { await runtimeHost.shutdown(); @@ -55,7 +52,6 @@ describe("mirror status", () => { }); it("reflects current daemon metrics and sessions", async () => { - const dir = await createTempDir(); const loreDir = await createTempDir(); const runtimeHost = await createMirrorRuntimeHost({ loreDir, @@ -73,25 +69,29 @@ describe("mirror status", () => { const status = await getMirrorStatus({ runtimeHost, - cwd: dir, }); expect(status.runtime.sessions.total).toBe(1); expect(status.runtime.sessions.open).toBe(1); - expect(status.observability.metrics.counters.chat_requests).toBe(1); + expect(status.sync.node_id).toBe(status.runtime.node_id); expect(status.provider.providers[0]?.provider_id).toBe("primary"); const json = JSON.stringify(status); const parsed = JSON.parse(json) as Record; expect(parsed).toHaveProperty("runtime"); expect(parsed).toHaveProperty("service"); - expect(parsed).toHaveProperty("observability"); + expect(parsed).toHaveProperty("sync"); + expect(parsed).not.toHaveProperty("ts"); + expect(parsed).not.toHaveProperty("cwd"); + expect(parsed).not.toHaveProperty("observability"); const human = formatMirrorStatusHuman(status); expect(human).toContain("🪞 Mirror Runtime"); expect(human).toContain("runtime:"); expect(human).toContain("service:"); - expect(human).toContain("observability:"); + expect(human).toContain("sync:"); + expect(human).not.toContain("cwd:"); + expect(human).not.toContain("observability:"); } finally { await runtimeHost.shutdown(); } diff --git a/src/mirror/telemetry_tail/cli.ts b/src/mirror/telemetry_tail/cli.ts index fe0b2af19e..165f51f87e 100644 --- a/src/mirror/telemetry_tail/cli.ts +++ b/src/mirror/telemetry_tail/cli.ts @@ -402,8 +402,8 @@ export function registerMirrorTelemetryCli(program: Command): void { mirror .command("verify-lore") .description("Compatibility wrapper for mirror verify-lore") - .option("--manifest ", "Lore manifest path", "lore/manifest.json") - .option("--dir ", "Canonical lore directory", "lore/canonical") + .option("--manifest ", "Lore manifest path (default: /manifest.json)") + .option("--dir ", "Canonical lore directory (default: MIRROR_LORE_DIR or ./lore-scrolls)") .option("--json", "Output machine-readable JSON", false) .action(async (opts: { manifest?: string; dir?: string; json?: boolean }) => { await runMirrorVerifyLoreCli({ diff --git a/src/mirror/telemetry_tail/index.ts b/src/mirror/telemetry_tail/index.ts index 514c41c8de..7746aa2f32 100644 --- a/src/mirror/telemetry_tail/index.ts +++ b/src/mirror/telemetry_tail/index.ts @@ -1,4 +1,8 @@ -export { runMirrorTelemetryTailCli } from "./cli.js"; +export { + registerMirrorTelemetryCli, + runMirrorDoctorCli, + runMirrorTelemetryTailCli, +} from "./cli.js"; export { buildTelemetryFilter, tailMirrorTelemetry } from "./tail.js"; export type { MirrorTelemetryTailCliOptions } from "./cli.js"; export type { diff --git a/src/mirror/telemetry_tail/tests/cli_wiring.test.ts b/src/mirror/telemetry_tail/tests/cli_wiring.test.ts index 8db825e491..b6cd02231d 100644 --- a/src/mirror/telemetry_tail/tests/cli_wiring.test.ts +++ b/src/mirror/telemetry_tail/tests/cli_wiring.test.ts @@ -53,26 +53,34 @@ describe("mirror cli wiring", () => { const doctor = getSubcommand(mirror, "doctor"); const status = getSubcommand(mirror, "status"); const passport = getSubcommand(mirror, "passport"); + const verifyLore = getSubcommand(mirror, "verify-lore"); const tail = getSubcommand(telemetry, "tail"); expect(doctor).toBeDefined(); expect(status).toBeDefined(); expect(passport).toBeDefined(); + expect(verifyLore).toBeDefined(); expect(tail).toBeDefined(); - if (!doctor || !status || !passport || !tail) { - throw new Error("expected mirror doctor/status/tail commands to be registered"); + if (!doctor || !status || !passport || !verifyLore || !tail) { + throw new Error("expected mirror doctor/status/verify-lore/tail commands to be registered"); } const doctorOptions = getLongOptionFlags(doctor); const statusOptions = getLongOptionFlags(status); + const verifyLoreOptions = getLongOptionFlags(verifyLore); const tailOptions = getLongOptionFlags(tail); expect(doctorOptions.has("--json")).toBe(true); expect(statusOptions.has("--json")).toBe(true); + expect(verifyLoreOptions.has("--manifest")).toBe(true); + expect(verifyLoreOptions.has("--dir")).toBe(true); expect(tailOptions.has("--json")).toBe(true); expect(tailOptions.has("--limit")).toBe(true); + expect(verifyLore.opts<{ manifest?: string; dir?: string }>().manifest).toBeUndefined(); + expect(verifyLore.opts<{ manifest?: string; dir?: string }>().dir).toBeUndefined(); expect(mirror.description()).toContain("compatibility"); expect(doctor.description()).toContain("Compatibility-only"); expect(status.description()).toContain("Compatibility wrapper"); + expect(verifyLore.description()).toContain("Compatibility wrapper"); expect(passport.description()).toContain("Compatibility-only"); expect(tail.description()).toContain("Compatibility-only"); }); diff --git a/src/mirrordaemon/daemon_types.ts b/src/mirrordaemon/daemon_types.ts index d0e35c3130..9bf7c781f0 100644 --- a/src/mirrordaemon/daemon_types.ts +++ b/src/mirrordaemon/daemon_types.ts @@ -194,9 +194,11 @@ export type MirrordaemonProvidersSummary = { provider_id: string; label: string; kind: string; + url: string; ready: boolean; configured: boolean; selected: boolean; + last_error?: string; }>; }; diff --git a/src/mirrordaemon/mirrordaemon.test.ts b/src/mirrordaemon/mirrordaemon.test.ts index 472524309d..48dca71885 100644 --- a/src/mirrordaemon/mirrordaemon.test.ts +++ b/src/mirrordaemon/mirrordaemon.test.ts @@ -91,12 +91,24 @@ describe("mirrordaemon", () => { const health = getMirrordaemonHealthState(daemon, { port: 7788, baseUrl: "http://127.0.0.1:7788", - peersKnown: 2, + peers: [ + { + peer_id: "peer-1", + base_url: "http://127.0.0.1:7999", + last_seen_at: "2026-03-13T00:01:00.000Z", + sync_status: "idle", + }, + { + peer_id: "peer-2", + base_url: "http://127.0.0.1:8000", + last_seen_at: "2026-03-13T00:02:00.000Z", + sync_status: "ok", + }, + ], }); const debug = getMirrordaemonDebugState(daemon, { port: 7788, baseUrl: "http://127.0.0.1:7788", - peersKnown: 2, }); expect(runtime.node_id).toBe("daemon-node"); diff --git a/src/mirrordaemon/runtime_state.test.ts b/src/mirrordaemon/runtime_state.test.ts new file mode 100644 index 0000000000..16ea7aab51 --- /dev/null +++ b/src/mirrordaemon/runtime_state.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from "vitest"; +import { + buildProvidersSummary, + buildDebugSnapshot, + buildHealthSummary, + buildRuntimeSummary, + createMirrordaemon, + getMirrordaemonActionsState, +} from "./index.js"; + +const baseConfig = { + port: 7777, + providerUrl: "http://brain.local/v1/chat/completions", + providerAuthToken: "token", + operatorToken: "secret", + loreDir: "/tmp/mirror-lore", + nodeId: "daemon-node", + baseUrl: "http://127.0.0.1:7777", +}; + +describe("mirrordaemon runtime state", () => { + it("removes operator-visible active actions after finished or failed runtime events", () => { + const daemon = createMirrordaemon({ + config: baseConfig, + lifecycle: { + discoveredLoreFiles: 2, + shutdown: async () => undefined, + }, + runtimeStartedAt: "2026-03-13T00:00:00.000Z", + }); + + daemon.publishRuntimeEvent("action.execution.started", { + trace_id: "trace-finished", + session_id: "session-finished", + action_id: "action-finished", + action: "mirror.find-scroll", + }); + daemon.publishRuntimeEvent("action.execution.started", { + trace_id: "trace-failed", + session_id: "session-failed", + action_id: "action-failed", + action: "mirror.find-scroll", + }); + + expect(getMirrordaemonActionsState(daemon)).toMatchObject({ + active: 2, + }); + expect(getMirrordaemonActionsState(daemon).actions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action_id: "action-finished", + session_id: "session-finished", + trace_id: "trace-finished", + }), + expect.objectContaining({ + action_id: "action-failed", + session_id: "session-failed", + trace_id: "trace-failed", + }), + ]), + ); + + daemon.publishRuntimeEvent("action.execution.finished", { + trace_id: "trace-finished", + session_id: "session-finished", + action_id: "action-finished", + action: "mirror.find-scroll", + }); + daemon.publishRuntimeEvent("action.execution.failed", { + trace_id: "trace-failed", + session_id: "session-failed", + action_id: "action-failed", + action: "mirror.find-scroll", + }); + + expect(getMirrordaemonActionsState(daemon)).toMatchObject({ + active: 0, + actions: [], + }); + }); + + it("keeps incomplete action start events out of operator inspection", () => { + const daemon = createMirrordaemon({ + config: baseConfig, + lifecycle: { + discoveredLoreFiles: 2, + shutdown: async () => undefined, + }, + runtimeStartedAt: "2026-03-13T00:00:00.000Z", + }); + + daemon.publishRuntimeEvent("action.execution.started", { + session_id: "missing-trace", + action_id: "action-missing-trace", + action: "mirror.find-scroll", + }); + daemon.publishRuntimeEvent("action.execution.started", { + trace_id: "trace-missing-name", + session_id: "missing-name", + action_id: "action-missing-name", + }); + daemon.publishRuntimeEvent("action.execution.started", { + trace_id: "trace-complete", + session_id: "session-complete", + action_id: "action-complete", + action: "mirror.find-scroll", + }); + + expect(getMirrordaemonActionsState(daemon)).toMatchObject({ + active: 1, + actions: [ + { + action_id: "action-complete", + action_name: "mirror.find-scroll", + session_id: "session-complete", + trace_id: "trace-complete", + }, + ], + }); + }); + + it("keeps websocket inspection truth aligned between runtime summary and debug snapshots", () => { + const daemon = createMirrordaemon({ + config: baseConfig, + lifecycle: { + discoveredLoreFiles: 2, + shutdown: async () => undefined, + }, + runtimeStartedAt: "2026-03-13T00:00:00.000Z", + }); + + daemon.publishRuntimeEvent("runtime.ws.connected", { + connection_id: "conn-1", + path: "/mirror/runtime/ws", + }); + daemon.publishRuntimeEvent("runtime.ws.disconnected", { + connection_id: "conn-1", + path: "/mirror/runtime/ws", + }); + + const runtime = buildRuntimeSummary(daemon, { + port: 7788, + baseUrl: "http://127.0.0.1:7788", + wsConnections: 2, + sseAvailable: true, + wsAvailable: true, + }); + const debug = buildDebugSnapshot(daemon, { + port: 7788, + baseUrl: "http://127.0.0.1:7788", + wsConnections: 2, + sseAvailable: true, + wsAvailable: true, + }); + + expect(debug.runtime).toMatchObject({ + node_id: runtime.node_id, + port: runtime.port, + base_url: runtime.base_url, + event_stream: runtime.event_stream, + }); + expect(runtime.event_stream.ws_connections).toBe(2); + expect(debug.runtime.event_stream.ws_connections).toBe(2); + expect(runtime.event_stream.sse_available).toBe(true); + expect(debug.runtime.event_stream.sse_available).toBe(true); + expect(runtime.event_stream.ws_available).toBe(true); + expect(debug.runtime.event_stream.ws_available).toBe(true); + expect(runtime.event_stream.recent_events).toBe(debug.recent_events.length); + expect(debug.runtime.event_stream.recent_events).toBe(debug.recent_events.length); + expect(debug.recent_events.some((event) => event.type === "runtime.ws.connected")).toBe(true); + expect(debug.recent_events.some((event) => event.type === "runtime.ws.disconnected")).toBe( + true, + ); + }); + + it("preserves websocket event stream truth between runtime and health summaries", () => { + const daemon = createMirrordaemon({ + config: baseConfig, + lifecycle: { + discoveredLoreFiles: 2, + shutdown: async () => undefined, + }, + runtimeStartedAt: "2026-03-13T00:00:00.000Z", + }); + + daemon.publishRuntimeEvent("runtime.ws.connected", { + connection_id: "conn-1", + path: "/mirror/runtime/ws", + }); + daemon.publishRuntimeEvent("runtime.ws.disconnected", { + connection_id: "conn-1", + path: "/mirror/runtime/ws", + }); + + const runtime = buildRuntimeSummary(daemon, { + port: 7788, + baseUrl: "http://127.0.0.1:7788", + wsConnections: 3, + sseAvailable: true, + wsAvailable: true, + }); + const health = buildHealthSummary(daemon, { + port: 7788, + baseUrl: "http://127.0.0.1:7788", + wsConnections: 3, + sseAvailable: true, + wsAvailable: true, + peers: [ + { + peer_id: "peer-1", + base_url: "http://127.0.0.1:7999", + last_seen_at: "2026-03-13T00:01:00.000Z", + sync_status: "idle", + }, + { + peer_id: "peer-2", + base_url: "http://127.0.0.1:8000", + last_seen_at: "2026-03-13T00:02:00.000Z", + sync_status: "ok", + }, + ], + }); + + expect(health.event_stream).toEqual(runtime.event_stream); + expect(health.event_stream.ws_connections).toBe(3); + expect(health.event_stream.sse_available).toBe(true); + expect(health.event_stream.ws_available).toBe(true); + expect(health.event_stream.recent_events).toBe(daemon.getRecentEvents().length); + expect(health.sync.peers_known).toBe(2); + }); + + it("includes provider url and last_error in the daemon provider summary", () => { + const daemon = createMirrordaemon({ + config: baseConfig, + lifecycle: { + discoveredLoreFiles: 2, + shutdown: async () => undefined, + }, + runtimeStartedAt: "2026-03-13T00:00:00.000Z", + }); + + const providers = buildProvidersSummary(daemon, { + providerPlane: { + listProviders: () => [ + { + provider_id: "primary", + label: "Primary", + kind: "openai-compatible", + url: "http://brain.local/v1/chat/completions", + ready: true, + configured: true, + selected: true, + last_error: undefined, + }, + { + provider_id: "fallback", + label: "Fallback", + kind: "openai-compatible", + url: "http://brain.local/v1/fallback", + ready: false, + configured: false, + selected: false, + last_error: "provider unavailable", + }, + ], + } as never, + }); + + expect(providers.providers).toEqual([ + { + provider_id: "primary", + label: "Primary", + kind: "openai-compatible", + url: "http://brain.local/v1/chat/completions", + ready: true, + configured: true, + selected: true, + last_error: undefined, + }, + { + provider_id: "fallback", + label: "Fallback", + kind: "openai-compatible", + url: "http://brain.local/v1/fallback", + ready: false, + configured: false, + selected: false, + last_error: "provider unavailable", + }, + ]); + }); +}); diff --git a/src/mirrordaemon/runtime_state.ts b/src/mirrordaemon/runtime_state.ts index e6a3603bd2..f4e0575dfb 100644 --- a/src/mirrordaemon/runtime_state.ts +++ b/src/mirrordaemon/runtime_state.ts @@ -23,6 +23,10 @@ type RuntimeStateOverrides = { wsAvailable?: boolean; }; +type HealthStateOverrides = RuntimeStateOverrides & { + peers?: MirrorSyncPeer[]; +}; + function buildCorrelationCapabilities() { return { trace_id: true as const, @@ -91,9 +95,11 @@ export function buildProvidersSummary( provider_id: provider.provider_id, label: provider.label, kind: provider.kind, + url: provider.url, ready: provider.ready, configured: provider.configured, selected: provider.selected, + last_error: provider.last_error, })) ?? []; return { @@ -180,7 +186,7 @@ export function buildRuntimeSummary( export function buildHealthSummary( daemon: Mirrordaemon, - overrides: RuntimeStateOverrides & { peersKnown?: number } = {}, + overrides: HealthStateOverrides = {}, ): MirrordaemonHealthSummary { const runtime = buildRuntimeSummary(daemon, overrides); const boot = daemon.getBootSnapshot(); @@ -205,7 +211,7 @@ export function buildHealthSummary( fallback_available: boot.readiness.provider.fallback_available, }, sync: { - peers_known: overrides.peersKnown ?? metrics.gauges.peers_known ?? 0, + peers_known: overrides.peers?.length ?? metrics.gauges.peers_known ?? 0, }, observability: { metrics_available: true, diff --git a/src/runtime/compat-legacy-boundary.test.ts b/src/runtime/compat-legacy-boundary.test.ts new file mode 100644 index 0000000000..42cc7c5f3d --- /dev/null +++ b/src/runtime/compat-legacy-boundary.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import * as compatBrainChat from "../compat/openclaw/runtime/brain-chat.js"; +import * as compatHealth from "../compat/openclaw/runtime/health.js"; +import * as canonicalMirrorRuntime from "../mirror-runtime/index.js"; +import * as canonicalMirrorService from "../mirror-service/index.js"; +import * as shimBrainChat from "./brain-chat.js"; +import * as shimHealth from "./health.js"; + +describe("legacy runtime compatibility boundaries", () => { + it("keeps the brain-chat legacy entrypoint as a thin forwarder to compat runtime code", () => { + expect(Object.keys(shimBrainChat)).toEqual(["handleBrainChatEndpoint"]); + expect(shimBrainChat.handleBrainChatEndpoint).toBe(compatBrainChat.handleBrainChatEndpoint); + expect("handleBrainChatEndpoint" in canonicalMirrorRuntime).toBe(false); + expect("executeMirrorChatRequest" in canonicalMirrorRuntime).toBe(true); + expect("handleBrainChatEndpoint" in canonicalMirrorService).toBe(false); + }); + + it("keeps the health legacy entrypoint as a thin forwarder to compat runtime code", () => { + expect(Object.keys(shimHealth)).toEqual(["handleHealthEndpoint"]); + expect(shimHealth.handleHealthEndpoint).toBe(compatHealth.handleHealthEndpoint); + expect("handleHealthEndpoint" in canonicalMirrorRuntime).toBe(false); + expect("handleHealthEndpoint" in canonicalMirrorService).toBe(false); + expect("startMirrorService" in canonicalMirrorService).toBe(true); + }); +}); diff --git a/test/mirror-actions-entry-surface.test.ts b/test/mirror-actions-entry-surface.test.ts new file mode 100644 index 0000000000..ece98e7443 --- /dev/null +++ b/test/mirror-actions-entry-surface.test.ts @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone actions entry surface", () => { + it("keeps the canonical actions-facing entry Mirror-native", () => { + const source = fs.readFileSync(path.join(process.cwd(), "src/mirror-actions/index.ts"), "utf8"); + + expect(source).toContain('export { createMirrorActionRuntime } from "./action_runtime.js";'); + expect(source).toContain("createMirrorActionsFromTools"); + expect(source).toContain("createMirrorToolRegistryFromActionRuntime"); + expect(source).toContain('} from "./skill_bridge.js";'); + expect(source).toContain("MirrorAction"); + expect(source).toContain("MirrorActionExecutionRequest"); + expect(source).toContain("MirrorActionExecutionResult"); + expect(source).toContain("MirrorActionRuntime"); + expect(source).toContain('} from "./action_types.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-cli-entry-surface.test.ts b/test/mirror-cli-entry-surface.test.ts new file mode 100644 index 0000000000..8fd745bcc7 --- /dev/null +++ b/test/mirror-cli-entry-surface.test.ts @@ -0,0 +1,21 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone cli entry surface", () => { + it("keeps the canonical CLI-facing entry Mirror-native", () => { + const source = fs.readFileSync(path.join(process.cwd(), "src/mirror-cli/index.ts"), "utf8"); + + expect(source).toContain('export { runMirrorCli } from "./mirror_cli.js";'); + expect(source).toContain("executeMirrorCliCommand"); + expect(source).toContain("parseMirrorCliArgs"); + expect(source).toContain('} from "./commands.js";'); + expect(source).toContain('export { formatMirrorCliResult } from "./output.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../cli/"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-cli-json-schemas-doc.test.ts b/test/mirror-cli-json-schemas-doc.test.ts new file mode 100644 index 0000000000..434dbb1b9e --- /dev/null +++ b/test/mirror-cli-json-schemas-doc.test.ts @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror cli json schemas doc", () => { + it("keeps the canonical json automation boundary documented", () => { + const doc = fs.readFileSync( + path.join(process.cwd(), "docs/mirror/CLI_JSON_SCHEMAS.md"), + "utf8", + ); + + expect(doc).toContain( + "The canonical automation surface is `mirror ...`. `openclaw mirror ...` is compatibility-only.", + ); + + expect(doc).toContain("## `mirror status --json`"); + expect(doc).toContain("## `mirror serve --json`"); + expect(doc).toContain("## `mirror verify-lore --json`"); + + expect(doc).toContain( + "Compatibility-only admin paths such as `openclaw mirror doctor`, `passport`, and telemetry replay/index/query/reflect are not part of the canonical standalone Mirror JSON automation surface.", + ); + }); +}); diff --git a/test/mirror-console-entry-surface.test.ts b/test/mirror-console-entry-surface.test.ts new file mode 100644 index 0000000000..fbb789fba6 --- /dev/null +++ b/test/mirror-console-entry-surface.test.ts @@ -0,0 +1,20 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone console entry surface", () => { + it("keeps the canonical console-facing entry Mirror-native", () => { + const source = fs.readFileSync(path.join(process.cwd(), "src/mirror-console/index.ts"), "utf8"); + + expect(source).toContain("createMirrorConsoleHandlers"); + expect(source).toContain("createMirrorConsoleRouter"); + expect(source).toContain("createMirrorConsoleRouterAtBase"); + expect(source).toContain('} from "./console_routes.js";'); + expect(source).toContain('export { renderMirrorConsoleHtml } from "./console_static.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-env-surface-boundary.test.ts b/test/mirror-env-surface-boundary.test.ts new file mode 100644 index 0000000000..8e71d4ddc8 --- /dev/null +++ b/test/mirror-env-surface-boundary.test.ts @@ -0,0 +1,31 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone env surface", () => { + it("keeps the canonical env/help surface Mirror-native", () => { + const source = fs.readFileSync(path.join(process.cwd(), "src/mirror-entry.ts"), "utf8"); + + expect(source).toContain("Environment:"); + expect(source).toContain("MIRROR_PROVIDER_URL"); + expect(source).toContain("MIRROR_PROVIDER_AUTH_TOKEN"); + expect(source).toContain("MIRROR_OPERATOR_TOKEN"); + expect(source).toContain("MIRROR_LORE_DIR"); + + expect(source).not.toContain("OPENCLAW_PROVIDER_URL"); + expect(source).not.toContain("OPENCLAW_PROVIDER_AUTH_TOKEN"); + expect(source).not.toContain("OPENCLAW_OPERATOR_TOKEN"); + expect(source).not.toContain("OPENCLAW_LORE_DIR"); + + expect(source).toContain("Compatibility:"); + expect(source).toContain( + "\\`openclaw mirror ...\\` remains available for compatibility-only diagnostics flows.", + ); + + const environmentIndex = source.indexOf("Environment:"); + const compatibilityIndex = source.indexOf("Compatibility:"); + + expect(environmentIndex).toBeGreaterThanOrEqual(0); + expect(compatibilityIndex).toBeGreaterThan(environmentIndex); + }); +}); diff --git a/test/mirror-gateway-entry-surface.test.ts b/test/mirror-gateway-entry-surface.test.ts new file mode 100644 index 0000000000..63c3a1e602 --- /dev/null +++ b/test/mirror-gateway-entry-surface.test.ts @@ -0,0 +1,28 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone gateway entry surface", () => { + it("keeps the canonical gateway-facing entry Mirror-native", () => { + const source = fs.readFileSync(path.join(process.cwd(), "src/mirror-gateway/index.ts"), "utf8"); + + expect(source).toContain( + 'export { createMirrorGateway, type MirrorGateway } from "./mirror_gateway.js";', + ); + expect(source).toContain("createMirrorGatewayHandlers"); + expect(source).toContain("createMirrorGatewayRouter"); + expect(source).toContain("validateMirrorToolInput"); + expect(source).toContain('} from "./routes.js";'); + expect(source).toContain("authorizeMirrorToolAccess"); + expect(source).toContain("authorizeMirrorToolRequest"); + expect(source).toContain("getMirrorOperatorToken"); + expect(source).toContain("readMirrorRequestToken"); + expect(source).toContain("requiresMirrorOperatorAuth"); + expect(source).toContain('} from "./auth.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-heartbeat-entry-surface.test.ts b/test/mirror-heartbeat-entry-surface.test.ts new file mode 100644 index 0000000000..1c73669eb5 --- /dev/null +++ b/test/mirror-heartbeat-entry-surface.test.ts @@ -0,0 +1,34 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone heartbeat entry surface", () => { + it("keeps the canonical heartbeat-facing entry Mirror-native", () => { + const source = fs.readFileSync( + path.join(process.cwd(), "src/mirror-heartbeat/index.ts"), + "utf8", + ); + + expect(source).toContain( + 'export { createMirrorHeartbeatManager } from "./heartbeat_manager.js";', + ); + expect(source).toContain( + 'export { createMirrorHeartbeatStore, type MirrorHeartbeatStore } from "./heartbeat_store.js";', + ); + expect(source).toContain('export { evaluateHeartbeat } from "./heartbeat_evaluator.js";'); + expect(source).toContain('export { renderHeartbeatTemplate } from "./heartbeat_templates.js";'); + expect(source).toContain("MirrorHeartbeatEvaluation"); + expect(source).toContain("MirrorHeartbeatEvaluationInput"); + expect(source).toContain("MirrorHeartbeatManager"); + expect(source).toContain("MirrorHeartbeatSignalSummary"); + expect(source).toContain("MirrorHeartbeatState"); + expect(source).toContain("MirrorHeartbeatTemplateInput"); + expect(source).toContain("MirrorHeartbeatTone"); + expect(source).toContain('} from "./heartbeat_types.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-help-surface-boundary.test.ts b/test/mirror-help-surface-boundary.test.ts new file mode 100644 index 0000000000..404f40ea6f --- /dev/null +++ b/test/mirror-help-surface-boundary.test.ts @@ -0,0 +1,36 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone help surface", () => { + it("keeps the canonical help surface Mirror-native", () => { + const entrySource = fs.readFileSync(path.join(process.cwd(), "src/mirror-entry.ts"), "utf8"); + const schemaSource = fs.readFileSync( + path.join(process.cwd(), "src/mirror-cli/schemas.ts"), + "utf8", + ); + + expect(entrySource).toContain("Usage:"); + expect(entrySource).toContain("mirror help [command]"); + expect(entrySource).toContain("mirror [options]"); + + expect(entrySource).toContain("Commands:"); + expect(entrySource).toContain("Compatibility:"); + expect(entrySource).toContain( + "\\`openclaw mirror ...\\` remains available for compatibility-only diagnostics flows.", + ); + + const compatibilityIndex = entrySource.indexOf("Compatibility:"); + const mirrorHelpIndex = entrySource.indexOf("mirror help [command]"); + const mirrorCommandIndex = entrySource.indexOf("mirror [options]"); + + expect(mirrorHelpIndex).toBeGreaterThanOrEqual(0); + expect(mirrorCommandIndex).toBeGreaterThanOrEqual(0); + expect(compatibilityIndex).toBeGreaterThan(mirrorHelpIndex); + expect(compatibilityIndex).toBeGreaterThan(mirrorCommandIndex); + + expect(schemaSource).toContain('command: "status"'); + expect(schemaSource).toContain('command: "verify-lore"'); + expect(schemaSource).toContain('command: "sync"'); + }); +}); diff --git a/test/mirror-launcher-surface-boundary.test.ts b/test/mirror-launcher-surface-boundary.test.ts new file mode 100644 index 0000000000..185fcbd2b4 --- /dev/null +++ b/test/mirror-launcher-surface-boundary.test.ts @@ -0,0 +1,21 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone launcher surface", () => { + it("keeps the shipped launcher Mirror-native and wired to the standalone entrypoint", () => { + const source = fs.readFileSync(path.join(process.cwd(), "mirror.mjs"), "utf8"); + + expect(source).toContain('tryImport("./dist/mirror-entry.js")'); + expect(source).toContain('tryImport("./dist/mirror-entry.mjs")'); + + expect(source).toContain('typeof mod.runMirrorEntry !== "function"'); + expect(source).toContain("mod.runMirrorEntry(process.argv)"); + + expect(source).toContain("mirror: missing dist/mirror-entry.(m)js (build output)."); + expect(source).toContain("mirror: dist/mirror-entry does not export runMirrorEntry()."); + + expect(source).not.toContain("openclaw:"); + expect(source).not.toContain("OPENCLAW"); + }); +}); diff --git a/test/mirror-lore-graph-entry-surface.test.ts b/test/mirror-lore-graph-entry-surface.test.ts new file mode 100644 index 0000000000..91daa5839a --- /dev/null +++ b/test/mirror-lore-graph-entry-surface.test.ts @@ -0,0 +1,29 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone lore-graph entry surface", () => { + it("keeps the canonical lore-graph-facing entry Mirror-native", () => { + const source = fs.readFileSync( + path.join(process.cwd(), "src/mirror-lore-graph/index.ts"), + "utf8", + ); + + expect(source).toContain('export { buildLoreGraph } from "./graph_builder.js";'); + expect(source).toContain('export type { MirrorLoreGraph } from "./lore_graph.js";'); + expect(source).toContain('export type { MirrorLoreGraphNode } from "./node_types.js";'); + expect(source).toContain( + 'export type { MirrorLoreGraphEdge, MirrorLoreGraphEdgeType } from "./edge_types.js";', + ); + expect(source).toContain("findConceptClusters"); + expect(source).toContain("findRelatedScrolls"); + expect(source).toContain("findScrollsSharingSymbols"); + expect(source).toContain("findSupersessionChains"); + expect(source).toContain('} from "./graph_query.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-method-doc.test.ts b/test/mirror-method-doc.test.ts new file mode 100644 index 0000000000..82f195dfe8 --- /dev/null +++ b/test/mirror-method-doc.test.ts @@ -0,0 +1,23 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror method doc", () => { + it("keeps the standalone mirror identity boundary documented", () => { + const doc = fs.readFileSync(path.join(process.cwd(), "docs/mirror/MIRROR_METHOD.md"), "utf8"); + + expect(doc).toContain( + "Mirror is the canonical identity for the standalone runtime, CLI, service, and console surfaces.", + ); + expect(doc).toContain( + "`openclaw mirror ...` exists only as a compatibility wrapper for legacy operational flows.", + ); + + expect(doc).toContain( + "Canonical standalone operator commands include `mirror status`, `mirror verify-lore`, and `mirror sync ...`.", + ); + expect(doc).toContain( + "Compatibility-only admin flows remain under `openclaw mirror doctor|passport|telemetry ...`.", + ); + }); +}); diff --git a/test/mirror-monk-actions-entry-surface.test.ts b/test/mirror-monk-actions-entry-surface.test.ts new file mode 100644 index 0000000000..a32c20e148 --- /dev/null +++ b/test/mirror-monk-actions-entry-surface.test.ts @@ -0,0 +1,38 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone monk-actions entry surface", () => { + it("keeps the canonical monk-actions-facing entry Mirror-native", () => { + const source = fs.readFileSync( + path.join(process.cwd(), "src/mirror-monk-actions/index.ts"), + "utf8", + ); + + expect(source).toContain( + 'export { createMirrorMonkActions } from "./monk_task_actions_runtime.js";', + ); + expect(source).toContain('export { formatMonkSuggestedAction } from "./monk_followup.js";'); + expect(source).toContain("buildMonkTaskFollowup"); + expect(source).toContain("buildMonkOpenWorkSummary"); + expect(source).toContain("selectNextMonkTask"); + expect(source).toContain('} from "./monk_task_actions.js";'); + expect(source).toContain("buildDueReminderActions"); + expect(source).toContain("buildReminderLinkedTaskFollowup"); + expect(source).toContain('} from "./monk_reminder_actions.js";'); + expect(source).toContain("buildMonkResumeContext"); + expect(source).toContain("buildSuggestedResumeAction"); + expect(source).toContain('} from "./monk_resume.js";'); + expect(source).toContain("MirrorMonkActionContext"); + expect(source).toContain("MirrorMonkActionKind"); + expect(source).toContain("MirrorMonkActionResult"); + expect(source).toContain("MirrorMonkActions"); + expect(source).toContain("MirrorMonkResumeContext"); + expect(source).toContain('} from "./monk_action_types.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-monk-entry-surface.test.ts b/test/mirror-monk-entry-surface.test.ts new file mode 100644 index 0000000000..fad7a26677 --- /dev/null +++ b/test/mirror-monk-entry-surface.test.ts @@ -0,0 +1,28 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone monk entry surface", () => { + it("keeps the canonical monk-facing entry Mirror-native", () => { + const source = fs.readFileSync(path.join(process.cwd(), "src/mirror-monk/index.ts"), "utf8"); + + expect(source).toContain( + 'export { createMirrorMonkWorkspaceBridge } from "./monk_workspace_bridge.js";', + ); + expect(source).toContain('export { buildMonkWorkspaceContext } from "./monk_context.js";'); + expect(source).toContain('export { buildMonkTaskView } from "./monk_task_view.js";'); + expect(source).toContain('export { buildMonkDraftView } from "./monk_draft_view.js";'); + expect(source).toContain('export { buildMonkSessionView } from "./monk_session_view.js";'); + expect(source).toContain("MirrorMonkDraftView"); + expect(source).toContain("MirrorMonkSessionView"); + expect(source).toContain("MirrorMonkTaskView"); + expect(source).toContain("MirrorMonkWorkspaceBridge"); + expect(source).toContain("MirrorMonkWorkspaceContext"); + expect(source).toContain('} from "./monk_types.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-observability-entry-surface.test.ts b/test/mirror-observability-entry-surface.test.ts new file mode 100644 index 0000000000..ac8e199e80 --- /dev/null +++ b/test/mirror-observability-entry-surface.test.ts @@ -0,0 +1,34 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone observability entry surface", () => { + it("keeps the canonical observability-facing entry Mirror-native", () => { + const source = fs.readFileSync( + path.join(process.cwd(), "src/mirror-observability/index.ts"), + "utf8", + ); + + expect(source).toContain("createMirrorObservabilityContext"); + expect(source).toContain("getCurrentMirrorObservabilityContext"); + expect(source).toContain("getDefaultMirrorObservabilityContext"); + expect(source).toContain("runWithMirrorObservabilityContext"); + expect(source).toContain('} from "./context.js";'); + expect(source).toContain("getMirrorMetrics"); + expect(source).toContain("incrementMetric"); + expect(source).toContain("recordLatency"); + expect(source).toContain('} from "./metrics.js";'); + expect(source).toContain("getMirrorDiagnostics"); + expect(source).toContain("recordDiagnosticEvent"); + expect(source).toContain('} from "./diagnostics.js";'); + expect(source).toContain('export { logMirrorEvent } from "./tracing.js";'); + expect(source).toContain("createMirrorObservabilityHandlers"); + expect(source).toContain("createMirrorObservabilityRouter"); + expect(source).toContain('} from "./observability_server.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-operator-guide-doc.test.ts b/test/mirror-operator-guide-doc.test.ts new file mode 100644 index 0000000000..c2e3b63315 --- /dev/null +++ b/test/mirror-operator-guide-doc.test.ts @@ -0,0 +1,33 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror operator guide doc", () => { + it("keeps the intended operator boundary documented", () => { + const guide = fs.readFileSync( + path.join(process.cwd(), "docs/mirror/MIRROR_OPERATOR_GUIDE.md"), + "utf8", + ); + + expect(guide).toContain("`mirror ...` for the standalone Mirror CLI"); + expect(guide).toContain("`/mirror/*` for the standalone Mirror service routes"); + + expect(guide).toContain("Compatibility-only path:"); + expect(guide).toContain("`openclaw mirror ...` for legacy diagnostics and telemetry workflows"); + + expect(guide).toContain("Mirror provides operators with read-only inspection tools for:"); + expect(guide).toContain( + "These tools are intended to inspect Mirror runtime behavior without modifying runtime state.", + ); + + expect(guide).toContain("```bash\nmirror status\n```"); + expect(guide).toContain("```bash\nmirror verify-lore\n```"); + + expect(guide).toContain("```bash\nopenclaw mirror passport\n```"); + expect(guide).toContain("```bash\nopenclaw mirror telemetry tail\n```"); + expect(guide).toContain("```bash\nopenclaw mirror telemetry replay\n```"); + expect(guide).toContain("```bash\nopenclaw mirror telemetry index\n```"); + expect(guide).toContain("```bash\nopenclaw mirror telemetry query\n```"); + expect(guide).toContain("```bash\nopenclaw mirror telemetry reflect\n```"); + }); +}); diff --git a/test/mirror-package-boundary.test.ts b/test/mirror-package-boundary.test.ts index 24e2c95421..90dc3357ae 100644 --- a/test/mirror-package-boundary.test.ts +++ b/test/mirror-package-boundary.test.ts @@ -34,11 +34,30 @@ describe("mirror package boundary", () => { "node --import tsx scripts/ci-mirror-smoke.ts", ); expect(packageJson.exports?.["."]).toBe("./dist/mirror-package.js"); - expect(packageJson.exports?.["./mirror-runtime"]).toBeDefined(); + expect(packageJson.exports?.["./mirror-runtime"]).toBe("./dist/mirror-package.js"); expect(packageJson.exports?.["./openclaw-compat"]).toBe("./dist/index.js"); expect(packageJson.exports?.["./cli-entry"]).toBe("./mirror.mjs"); }); + it("keeps canonical mirror exports quarantined from compat OpenClaw surfaces", () => { + const packageJson = JSON.parse( + fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8"), + ) as { + exports?: Record; + }; + const mirrorPackageSource = fs.readFileSync( + path.join(process.cwd(), "src/mirror-package.ts"), + "utf8", + ); + + expect(packageJson.exports?.["."]).toBe("./dist/mirror-package.js"); + expect(packageJson.exports?.["./mirror-runtime"]).toBe("./dist/mirror-package.js"); + expect(packageJson.exports?.["./openclaw-compat"]).toBe("./dist/index.js"); + + expect(mirrorPackageSource).not.toContain("compat/openclaw"); + expect(mirrorPackageSource).not.toContain("openclaw-compat"); + }); + it("defines an explicit openclaw compatibility workspace package", () => { const packageJson = JSON.parse( fs.readFileSync(path.join(process.cwd(), "packages/openclaw/package.json"), "utf8"), diff --git a/test/mirror-package-entry-surface.test.ts b/test/mirror-package-entry-surface.test.ts new file mode 100644 index 0000000000..8af4b2b6b4 --- /dev/null +++ b/test/mirror-package-entry-surface.test.ts @@ -0,0 +1,21 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone package entry surface", () => { + it("keeps the canonical package-facing entry Mirror-native", () => { + const source = fs.readFileSync(path.join(process.cwd(), "src/mirror-package.ts"), "utf8"); + + expect(source).toContain('export * from "./mirrordaemon/index.js";'); + expect(source).toContain('export * from "./mirror-service/index.js";'); + expect(source).toContain('export * from "./mirror-runtime/index.js";'); + expect(source).toContain('export * from "./mirror-provider/index.js";'); + expect(source).toContain('export * from "./mirror-gateway/index.js";'); + expect(source).toContain('export * from "./mirror-cli/index.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain('export * from "./compat/'); + expect(source).not.toContain('export * from "./openclaw'); + }); +}); diff --git a/test/mirror-packaging-readme-doc.test.ts b/test/mirror-packaging-readme-doc.test.ts new file mode 100644 index 0000000000..a30b033569 --- /dev/null +++ b/test/mirror-packaging-readme-doc.test.ts @@ -0,0 +1,26 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror packaging readme doc", () => { + it("keeps the standalone packaging boundary documented", () => { + const readme = fs.readFileSync( + path.join(process.cwd(), "packaging/mirror-runtime/README.md"), + "utf8", + ); + + expect(readme).toContain("/opt/mirror-runtime"); + + expect(readme).toContain("- `bin/mirror`"); + expect(readme).toContain("- `mirror.mjs`"); + expect(readme).toContain("- `dist/mirror-entry.js`"); + expect(readme).toContain("- `dist/mirror-package.js`"); + + expect(readme).toContain("dist/mirror-runtime-linux/"); + expect(readme).toContain("mirror-runtime-linux.tar.gz"); + + expect(readme).toContain("pnpm package:mirror-runtime"); + expect(readme).toContain("pnpm verify:mirror-runtime-dist"); + expect(readme).toContain("pnpm verify:mirror-runtime-bootstrap"); + }); +}); diff --git a/test/mirror-policy-entry-surface.test.ts b/test/mirror-policy-entry-surface.test.ts new file mode 100644 index 0000000000..3dd20c7060 --- /dev/null +++ b/test/mirror-policy-entry-surface.test.ts @@ -0,0 +1,30 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone policy entry surface", () => { + it("keeps the canonical policy-facing entry Mirror-native", () => { + const source = fs.readFileSync(path.join(process.cwd(), "src/mirror-policy/index.ts"), "utf8"); + + expect(source).toContain("createDefaultMirrorPolicyRules"); + expect(source).toContain("createMirrorMutableSurfacePolicyRule"); + expect(source).toContain("createMirrorOperatorAccessPolicyRule"); + expect(source).toContain('} from "./default_rules.js";'); + expect(source).toContain("isMirrorLocalOnlySurface"); + expect(source).toContain("isMirrorMutableActionName"); + expect(source).toContain("isMirrorNetworkExposedSurface"); + expect(source).toContain('} from "./mutable_surfaces.js";'); + expect(source).toContain("createMirrorPolicyEngine"); + expect(source).toContain("ensureMirrorPolicyAllowed"); + expect(source).toContain("MirrorPolicyDeniedError"); + expect(source).toContain('} from "./policy_engine.js";'); + expect(source).toContain("buildMirrorActionPolicyTarget"); + expect(source).toContain("type MirrorPolicyTarget"); + expect(source).toContain('} from "./policy_types.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-provider-entry-surface.test.ts b/test/mirror-provider-entry-surface.test.ts new file mode 100644 index 0000000000..b3985caaa9 --- /dev/null +++ b/test/mirror-provider-entry-surface.test.ts @@ -0,0 +1,31 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone provider entry surface", () => { + it("keeps the canonical provider-facing entry Mirror-native", () => { + const source = fs.readFileSync( + path.join(process.cwd(), "src/mirror-provider/index.ts"), + "utf8", + ); + + expect(source).toContain( + 'export { executeMirrorProviderRequest, type FetchLike } from "./mirror_provider.js";', + ); + expect(source).toContain('export { buildMirrorProviderHeaders } from "./provider_auth.js";'); + expect(source).toContain("buildPrimaryProviderDescriptorFromConfig"); + expect(source).toContain("createMirrorProviderPlane"); + expect(source).toContain('} from "./provider_plane.js";'); + expect(source).toContain( + 'export type { MirrorProviderConfig, MirrorProviderRequest } from "./provider_request.js";', + ); + expect(source).toContain( + 'export type { MirrorProviderResponse } from "./provider_response.js";', + ); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-reflection-entry-surface.test.ts b/test/mirror-reflection-entry-surface.test.ts new file mode 100644 index 0000000000..f343e52ab5 --- /dev/null +++ b/test/mirror-reflection-entry-surface.test.ts @@ -0,0 +1,31 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone reflection entry surface", () => { + it("keeps the canonical reflection-facing entry Mirror-native", () => { + const source = fs.readFileSync( + path.join(process.cwd(), "src/mirror-reflection/index.ts"), + "utf8", + ); + + expect(source).toContain("buildReflectionPrompt"); + expect(source).toContain("reflectOnCanonContext"); + expect(source).toContain("reviewCanonDraft"); + expect(source).toContain('} from "./reflection_engine.js";'); + expect(source).toContain('export { analyzeCanonContext } from "./canon_analysis.js";'); + expect(source).toContain('export { analyzeSymbolResonance } from "./symbol_analysis.js";'); + expect(source).toContain('export { reviewDraftAgainstCanon } from "./draft_review.js";'); + expect(source).toContain("MirrorCanonReflection"); + expect(source).toContain("MirrorDraftReview"); + expect(source).toContain("MirrorSymbolResonance"); + expect(source).toContain("ReflectCanonInput"); + expect(source).toContain("ReviewDraftInput"); + expect(source).toContain('} from "./reflection_types.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-reminder-entry-surface.test.ts b/test/mirror-reminder-entry-surface.test.ts new file mode 100644 index 0000000000..2fef4f440a --- /dev/null +++ b/test/mirror-reminder-entry-surface.test.ts @@ -0,0 +1,35 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone reminder entry surface", () => { + it("keeps the canonical reminder-facing entry Mirror-native", () => { + const source = fs.readFileSync( + path.join(process.cwd(), "src/mirror-reminder/index.ts"), + "utf8", + ); + + expect(source).toContain( + 'export { createMirrorReminderManager } from "./reminder_manager.js";', + ); + expect(source).toContain( + 'export { createMirrorReminderStore, type MirrorReminderStore } from "./reminder_store.js";', + ); + expect(source).toContain("filterDueReminders"); + expect(source).toContain("getReminderScheduleState"); + expect(source).toContain('} from "./reminder_scheduler.js";'); + expect(source).toContain("CreateMirrorReminderInput"); + expect(source).toContain("MirrorReminder"); + expect(source).toContain("MirrorReminderManager"); + expect(source).toContain("MirrorReminderRecurrence"); + expect(source).toContain("MirrorReminderScheduleState"); + expect(source).toContain("MirrorReminderStatusValue"); + expect(source).toContain("UpdateMirrorReminderInput"); + expect(source).toContain('} from "./reminder_types.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-review-entry-surface.test.ts b/test/mirror-review-entry-surface.test.ts new file mode 100644 index 0000000000..0e246fa2d2 --- /dev/null +++ b/test/mirror-review-entry-surface.test.ts @@ -0,0 +1,28 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone review entry surface", () => { + it("keeps the canonical review-facing entry Mirror-native", () => { + const source = fs.readFileSync(path.join(process.cwd(), "src/mirror-review/index.ts"), "utf8"); + + expect(source).toContain('export { reviewDraftForCanon } from "./review_engine.js";'); + expect(source).toContain('export { MIRROR_REVIEW_RULES } from "./review_rules.js";'); + expect(source).toContain('export { detectCanonConflicts } from "./canon_conflict.js";'); + expect(source).toContain( + 'export { detectNarrativeSimilarity } from "./narrative_similarity.js";', + ); + expect(source).toContain('export { validateDraftSymbols } from "./symbol_validation.js";'); + expect(source).toContain("MirrorCanonConflict"); + expect(source).toContain("MirrorCanonReviewResult"); + expect(source).toContain("MirrorNarrativeSimilarity"); + expect(source).toContain("MirrorReviewStatus"); + expect(source).toContain("MirrorSymbolValidation"); + expect(source).toContain('} from "./review_types.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-runtime-canonical-entrypoints-doc.test.ts b/test/mirror-runtime-canonical-entrypoints-doc.test.ts new file mode 100644 index 0000000000..37e0938383 --- /dev/null +++ b/test/mirror-runtime-canonical-entrypoints-doc.test.ts @@ -0,0 +1,39 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror runtime canonical entrypoints doc", () => { + it("keeps the canonical-vs-compat split boundary documented", () => { + const doc = fs.readFileSync( + path.join(process.cwd(), "docs/debug/mirror-runtime-canonical-entrypoints.md"), + "utf8", + ); + + expect(doc).toContain("# Mirror Runtime Canonical Entrypoints"); + expect(doc).toContain("## Canonical Mirror-native entrypoints"); + + expect(doc).toContain("[mirror.mjs]"); + expect(doc).toContain("[src/mirror-entry.ts]"); + expect(doc).toContain("[src/mirror-cli/mirror_cli.ts]"); + + expect(doc).toContain("[src/mirror-service/mirror_service.ts]"); + expect(doc).toContain("[src/mirror-service/runtime_host.ts]"); + expect(doc).toContain("[src/mirrordaemon/mirrordaemon.ts]"); + expect(doc).toContain("[src/mirror-runtime/mirror_chat_engine.ts]"); + expect(doc).toContain("[src/mirror-provider/mirror_provider.ts]"); + expect(doc).toContain("[src/mirror-gateway/routes.ts]"); + expect(doc).toContain("[src/mirror-package.ts]"); + expect(doc).toContain("canonical root export surface"); + + expect(doc).toContain("Compatibility code lives under:"); + expect(doc).toContain("[src/compat/openclaw]"); + + expect(doc).toContain("Compatibility wrapper paths still exist at:"); + expect(doc).toContain("[src/runtime/server.ts]"); + expect(doc).toContain("[src/runtime/brain-chat.ts]"); + expect(doc).toContain("[src/runtime/health.ts]"); + expect(doc).toContain("[src/cli/mirror-cli.ts]"); + + expect(doc).toContain("These are not canonical runtime entrypoints anymore."); + }); +}); diff --git a/test/mirror-runtime-entry-surface.test.ts b/test/mirror-runtime-entry-surface.test.ts new file mode 100644 index 0000000000..3127a976f0 --- /dev/null +++ b/test/mirror-runtime-entry-surface.test.ts @@ -0,0 +1,27 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone runtime entry surface", () => { + it("keeps the canonical runtime-facing entry Mirror-native", () => { + const source = fs.readFileSync(path.join(process.cwd(), "src/mirror-runtime/index.ts"), "utf8"); + + expect(source).toContain("prepareMirrorChatRequest"); + expect(source).toContain("executeMirrorChatRequest"); + expect(source).toContain("executeMirrorChatWithProvider"); + expect(source).toContain("executeMirrorChatWithProviderPlane"); + expect(source).toContain('from "./mirror_chat_engine.js";'); + + expect(source).toContain("buildMirrorCorrelationFromPolicyContext"); + expect(source).toContain("getMirrorTraceIdFromPolicyContext"); + expect(source).toContain("mergeMirrorCorrelation"); + expect(source).toContain("resolveMirrorTraceId"); + expect(source).toContain("withMirrorCorrelation"); + expect(source).toContain('from "./correlation.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-service-entry-surface.test.ts b/test/mirror-service-entry-surface.test.ts new file mode 100644 index 0000000000..e535484c4d --- /dev/null +++ b/test/mirror-service-entry-surface.test.ts @@ -0,0 +1,29 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone service entry surface", () => { + it("keeps the canonical service-facing entry Mirror-native", () => { + const source = fs.readFileSync(path.join(process.cwd(), "src/mirror-service/index.ts"), "utf8"); + + expect(source).toContain( + 'export { loadMirrorServiceConfig, type MirrorServiceConfig } from "./config.js";', + ); + expect(source).toContain( + 'export { initializeMirrorServiceLifecycle, type MirrorServiceLifecycle } from "./lifecycle.js";', + ); + expect(source).toContain( + 'export { startMirrorService, type MirrorService } from "./mirror_service.js";', + ); + expect(source).toContain( + 'export { createMirrorRuntimeHost, type MirrorRuntimeHost } from "./runtime_host.js";', + ); + expect(source).toContain('from "./runtime_events_ws.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain("./runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-split-readiness-checklist-doc.test.ts b/test/mirror-split-readiness-checklist-doc.test.ts new file mode 100644 index 0000000000..d1e67490ba --- /dev/null +++ b/test/mirror-split-readiness-checklist-doc.test.ts @@ -0,0 +1,46 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror split-readiness checklist doc", () => { + it("keeps the settled split-readiness guardrails reflected in the checklist", () => { + const doc = fs.readFileSync( + path.join(process.cwd(), "docs/architecture/mirror-runtime-split-readiness-checklist.md"), + "utf8", + ); + + expect(doc).toContain("### 6. Compatibility quarantine"); + expect(doc).toContain( + "- [x] Canonical operator docs and entrypoints point to Mirror-native paths first.", + ); + expect(doc).toContain( + "Canonical entrypoint, operator, and JSON automation docs now point to Mirror-native paths first and describe `openclaw mirror ...` as compatibility-only.", + ); + + expect(doc).toContain("### 7. Packaging and build boundary"); + expect(doc).toContain("Current score: `yellow`"); + expect(doc).toContain( + "Mirror now has an explicit package boundary, standalone Linux runtime artifact, extracted-artifact smoke, dist verification, and bootstrap verification.", + ); + + expect(doc).toContain("### 8. CI gates before split"); + expect(doc).toContain( + "- [x] Boundary gates prevent new OpenClaw-specific env/config and import/package coupling inside Mirror-owned runtime modules.", + ); + expect(doc).toContain( + "- [x] Mirror-specific checks are isolated enough to serve as a true split gate rather than an early smoke lane.", + ); + expect(doc).toContain( + "Dedicated split-readiness gates now include first-class boundary enforcement for new OpenClaw-specific env/config and import/package coupling inside Mirror-owned modules.", + ); + + expect(doc).toContain("## Current known gaps"); + expect(doc).toContain("1. Move observability ownership under daemon/runtime control."); + expect(doc).toContain( + "2. Make any remaining console execution seams explicitly daemon-backed where they are still partial.", + ); + expect(doc).toContain( + "4. Keep parity coverage growing only where a canonical operator/runtime seam is still weak.", + ); + }); +}); diff --git a/test/mirror-sync-entry-surface.test.ts b/test/mirror-sync-entry-surface.test.ts new file mode 100644 index 0000000000..59d604e215 --- /dev/null +++ b/test/mirror-sync-entry-surface.test.ts @@ -0,0 +1,33 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone sync entry surface", () => { + it("keeps the canonical sync-facing entry Mirror-native", () => { + const source = fs.readFileSync(path.join(process.cwd(), "src/mirror-sync/index.ts"), "utf8"); + + expect(source).toContain("createMirrorSyncManager"); + expect(source).toContain("createMirrorSyncHandlers"); + expect(source).toContain("createMirrorSyncRouter"); + expect(source).toContain("executeMirrorSyncAction"); + expect(source).toContain("parseMirrorSyncAnnounceInput"); + expect(source).toContain("parseMirrorSyncPullInput"); + expect(source).toContain("parseMirrorSyncUpdatesInput"); + expect(source).toContain("wrapMirrorSyncPullResponse"); + expect(source).toContain('} from "./sync_manager.js";'); + expect(source).toContain( + 'export { createMirrorPeerRegistry, type MirrorPeerRegistry } from "./peer_registry.js";', + ); + expect(source).toContain("collectLocalCanonUpdates"); + expect(source).toContain("applyRemoteCanonUpdates"); + expect(source).toContain('} from "./canon_sync.js";'); + expect(source).toContain("collectLocalGraphMetadata"); + expect(source).toContain("syncLocalGraphFromRemote"); + expect(source).toContain('} from "./graph_sync.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-task-entry-surface.test.ts b/test/mirror-task-entry-surface.test.ts new file mode 100644 index 0000000000..095037a97f --- /dev/null +++ b/test/mirror-task-entry-surface.test.ts @@ -0,0 +1,25 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone task entry surface", () => { + it("keeps the canonical task-facing entry Mirror-native", () => { + const source = fs.readFileSync(path.join(process.cwd(), "src/mirror-task/index.ts"), "utf8"); + + expect(source).toContain('export { createMirrorTaskManager } from "./task_manager.js";'); + expect(source).toContain( + 'export { createMirrorTaskApi, type MirrorTaskApi } from "./task_api.js";', + ); + expect(source).toContain("CreateMirrorTaskInput"); + expect(source).toContain("MirrorTask"); + expect(source).toContain("MirrorTaskManager"); + expect(source).toContain("MirrorTaskStatusValue"); + expect(source).toContain("UpdateMirrorTaskInput"); + expect(source).toContain('} from "./task_types.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-ui-api-entry-surface.test.ts b/test/mirror-ui-api-entry-surface.test.ts new file mode 100644 index 0000000000..56118874dc --- /dev/null +++ b/test/mirror-ui-api-entry-surface.test.ts @@ -0,0 +1,20 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone ui api entry surface", () => { + it("keeps the canonical UI/API-facing entry Mirror-native", () => { + const source = fs.readFileSync(path.join(process.cwd(), "src/mirror-ui-api/index.ts"), "utf8"); + + expect(source).toContain("createMirrorUiApiHandlers"); + expect(source).toContain("createMirrorUiApiRouter"); + expect(source).toContain("type MirrorUiApiHandlers"); + expect(source).toContain('} from "./routes.js";'); + expect(source).toContain('export * from "./contracts.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirror-user-workspace-entry-surface.test.ts b/test/mirror-user-workspace-entry-surface.test.ts new file mode 100644 index 0000000000..5034c1affc --- /dev/null +++ b/test/mirror-user-workspace-entry-surface.test.ts @@ -0,0 +1,32 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirror standalone user-workspace entry surface", () => { + it("keeps the canonical user-workspace-facing entry Mirror-native", () => { + const source = fs.readFileSync( + path.join(process.cwd(), "src/mirror-user-workspace/index.ts"), + "utf8", + ); + + expect(source).toContain( + 'export { createMirrorWorkspaceManager } from "./workspace_manager.js";', + ); + expect(source).toContain("createMirrorWorkspaceStore"); + expect(source).toContain("resolveMirrorWorkspaceUsersRoot"); + expect(source).toContain("resolveUserWorkspacePaths"); + expect(source).toContain("sanitizeMirrorWorkspaceUserId"); + expect(source).toContain('} from "./workspace_store.js";'); + expect(source).toContain("MirrorHeartbeatPreferences"); + expect(source).toContain("MirrorUserTask"); + expect(source).toContain("MirrorUserWorkspace"); + expect(source).toContain("MirrorWorkspaceManager"); + expect(source).toContain("MirrorWorkspaceStore"); + expect(source).toContain('} from "./workspace_types.js";'); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/mirrordaemon-entry-surface.test.ts b/test/mirrordaemon-entry-surface.test.ts new file mode 100644 index 0000000000..399950b118 --- /dev/null +++ b/test/mirrordaemon-entry-surface.test.ts @@ -0,0 +1,27 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("mirrordaemon standalone entry surface", () => { + it("keeps the canonical daemon-facing entry Mirror-native", () => { + const source = fs.readFileSync(path.join(process.cwd(), "src/mirrordaemon/index.ts"), "utf8"); + + expect(source).toContain( + 'export { createMirrordaemon, type Mirrordaemon } from "./mirrordaemon.js";', + ); + expect(source).toContain('export { createRuntimeEventStream } from "./event_stream.js";'); + expect(source).toContain("getMirrordaemonRuntimeState"); + expect(source).toContain("getMirrordaemonHealthState"); + expect(source).toContain("getMirrordaemonActionsState"); + expect(source).toContain("getMirrordaemonProvidersState"); + expect(source).toContain("getMirrordaemonSyncState"); + expect(source).toContain('export { getMirrordaemonDebugState } from "./debug_api.js";'); + expect(source).toContain("buildRuntimeSummary"); + expect(source).toContain("buildStatusPayload"); + + expect(source).not.toContain("compat/openclaw"); + expect(source).not.toContain("openclaw-compat"); + expect(source).not.toContain("../runtime/"); + expect(source).not.toContain('from "../compat/'); + }); +}); diff --git a/test/setup.ts b/test/setup.ts index 4e008ff188..9a71be5226 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -34,6 +34,8 @@ const [{ installProcessWarningFilter }, { setActivePluginRegistry }, { createTes installProcessWarningFilter(); +let fetchAtTestStart: typeof globalThis.fetch; + const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => { switch (id) { case "discord": @@ -178,10 +180,17 @@ const createDefaultRegistry = () => const DEFAULT_PLUGIN_REGISTRY = createDefaultRegistry(); beforeEach(() => { + fetchAtTestStart = globalThis.fetch; setActivePluginRegistry(DEFAULT_PLUGIN_REGISTRY); }); afterEach(() => { + if (fetchAtTestStart) { + globalThis.fetch = fetchAtTestStart; + } else { + delete (globalThis as { fetch?: typeof fetch }).fetch; + } + // Guard against leaked fake timers across test files/workers. if (vi.isFakeTimers()) { vi.useRealTimers();