diff --git a/docs/architecture/mirror-monk-coder-boundary.md b/docs/architecture/mirror-monk-coder-boundary.md index 3ad71dfa76..42cf0f8312 100644 --- a/docs/architecture/mirror-monk-coder-boundary.md +++ b/docs/architecture/mirror-monk-coder-boundary.md @@ -101,3 +101,5 @@ Use this boundary to guide: - user-surface design - repo split decisions - future utility and tool expansion + +For the ordered pre-split execution checklist, see [Mirror Runtime Split-Readiness Checklist](./mirror-runtime-split-readiness-checklist.md). diff --git a/docs/architecture/mirror-runtime-split-readiness-checklist.md b/docs/architecture/mirror-runtime-split-readiness-checklist.md new file mode 100644 index 0000000000..c3aa74eba6 --- /dev/null +++ b/docs/architecture/mirror-runtime-split-readiness-checklist.md @@ -0,0 +1,184 @@ +# Mirror Runtime Split-Readiness Checklist + +This checklist defines what should be true before Mirror Runtime is split out of the current repo. + +It is intentionally operational. + +- It is for sequencing small PRs. +- It is not a broad redesign plan. +- It should track current repo reality, not an idealized future architecture. + +## Purpose + +Use this checklist to decide whether a proposed PR improves split-readiness, preserves it, or widens scope without reducing split risk. + +The core rule remains: + +1. detach first +2. split second +3. expand third + +## How to use this checklist + +- Prefer the next smallest yellow or red item that can be fixed in one reviewable PR. +- Prefer additive read surfaces and focused regression tests before broad refactors. +- Re-score the touched section after each merged PR. +- Do not mark a section green if the canonical runtime path still depends on compatibility-only wrappers or duplicate execution planes. + +## Split-Readiness Scorecard + +### Green + +- The seam is explicit. +- The canonical Mirror path is clear. +- The behavior is covered by focused tests. +- A repo split would not require untangling hidden ownership in this area. + +### Yellow + +- The seam exists, but ownership is still partial, duplicated, or weakly tested. +- A small follow-up PR can improve the boundary without redesigning the runtime. + +### Red + +- Ownership is still mixed or misleading. +- Canonical and compatibility paths still compete. +- A split here would copy technical debt into a new repo boundary. + +## Checklist + +### 1. Runtime ownership seams + +Current score: `yellow` + +- [ ] Mirrordaemon is the canonical state owner for service, console, and CLI execution paths. +- [ ] Session creation and touch behavior are consistent across `/mirror/chat`, `/mirror/tools/*`, console API paths, and CLI execution. +- [ ] Runtime summaries are built from daemon-owned state rather than sidecar module state. +- [ ] New runtime features land on the daemon-backed path first, not on compatibility wrappers. + +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. + +### 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. +- [ ] Operator-facing surfaces do not depend on legacy `src/runtime/**` wrappers. +- [ ] 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. + +### 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. +- [ ] 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. + +Current reality: + +- Sync read surfaces are real. +- `/mirror/sync` now exposes read-only peer state from the live registry. +- Sync is still a seam because execution state is not fully daemon-owned beyond the current registry and event stream summaries. + +### 4. CLI and service parity + +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. +- [ ] 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. + +### 5. Observability ownership + +Current score: `red` + +- [ ] Metrics are daemon-scoped or runtime-context-scoped. +- [ ] Diagnostics are daemon-scoped or runtime-context-scoped. +- [ ] Debug surfaces do not depend on process-global singleton state. +- [ ] Runtime event inspection and observability tell the same story about execution. + +Current reality: + +- Observability surfaces exist and are useful. +- Ownership is still process-global enough that this remains a pre-split blocker. + +### 6. Compatibility quarantine + +Current score: `red` + +- [ ] `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. +- [ ] 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. + +### 7. Packaging and build boundary + +Current score: `red` + +- [ ] 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. + +Current reality: + +- Mirror runtime behavior is increasingly standalone. +- Package, bin, and release identity are still shared enough that splitting now would be premature. + +### 8. CI gates before split + +Current score: `red` + +- [ ] 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. + +Current reality: + +- Runtime tests exist. +- Dedicated split-readiness gates are still missing. + +## 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. + +## Do Not Split Before + +Do not split before all of these are true: + +- Mirrordaemon is the clear runtime owner across the canonical execution surfaces. +- Observability ownership is no longer effectively process-global. +- Compatibility-only wrappers are quarantined or removed from required runtime paths. +- Mirror package/build identity is explicit enough to survive extraction. +- CI can prove the standalone Mirror runtime path without relying on broad repo-level signal. + +If one of those remains red, keep building in this repo. diff --git a/src/mirror-runtime/mirror_chat_engine.test.ts b/src/mirror-runtime/mirror_chat_engine.test.ts index d24e79cc7a..d634c64585 100644 --- a/src/mirror-runtime/mirror_chat_engine.test.ts +++ b/src/mirror-runtime/mirror_chat_engine.test.ts @@ -21,6 +21,7 @@ const tempDirs: string[] = []; const originalMirrorLoreDir = process.env.MIRROR_LORE_DIR; const originalMirrorMemoryDbPath = process.env.MIRROR_MEMORY_DB_PATH; const originalLogLevel = process.env.MIRROR_LOG_LEVEL; +const originalOpenClawLogLevel = process.env.OPENCLAW_LOG_LEVEL; afterEach(async () => { if (originalMirrorLoreDir === undefined) { @@ -38,6 +39,11 @@ afterEach(async () => { } else { process.env.MIRROR_LOG_LEVEL = originalLogLevel; } + if (originalOpenClawLogLevel === undefined) { + delete process.env.OPENCLAW_LOG_LEVEL; + } else { + process.env.OPENCLAW_LOG_LEVEL = originalOpenClawLogLevel; + } closeMirrorMemoryDb(); await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); @@ -161,6 +167,23 @@ describe("mirror chat engine", () => { expect(prompt).toContain("Traveler note: the Patience Vault still exists."); }); + it("ignores OPENCLAW_LOG_LEVEL in the canonical Mirror debug path", async () => { + const loreDir = await createTempLoreDir(); + await seedLoreCorpus(loreDir); + process.env.MIRROR_LORE_DIR = loreDir; + delete process.env.MIRROR_LOG_LEVEL; + process.env.OPENCLAW_LOG_LEVEL = "debug"; + + const prepared = await prepareMirrorChatRequest({ + model: "test-model", + messages: [{ role: "user", content: "patience vault" }], + }); + + expect(prepared.modelRequest.messages[0]?.content).toContain("Mirror canon context:"); + expect(prepared.modelRequest.messages[0]?.content).not.toContain("[RETRIEVAL_DIAGNOSTICS]"); + expect(prepared.diagnostics).toBeUndefined(); + }); + it("executes through the provider boundary without OpenClaw-specific request types", async () => { const loreDir = await createTempLoreDir(); await seedLoreCorpus(loreDir); diff --git a/src/mirror-service/mirror_service.test.ts b/src/mirror-service/mirror_service.test.ts index b4be18fed2..da8f56cc27 100644 --- a/src/mirror-service/mirror_service.test.ts +++ b/src/mirror-service/mirror_service.test.ts @@ -1178,6 +1178,136 @@ describe("mirror service", () => { } }); + it("returns announced peers from the read-only sync peers ingress", 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: "service-node", + baseUrl: "http://127.0.0.1:7001", + }); + + try { + await requestJsonFromApp(service.app, "POST", "/mirror-sync/announce", { + headers: { + "x-mirror-operator-token": "secret", + }, + body: { + peer_id: "peer-1", + base_url: "http://127.0.0.1:7999", + }, + }); + + const peers = (await requestJsonFromApp(service.app, "GET", "/mirror-sync/peers")) as { + peers: Array<{ + peer_id: string; + base_url: string; + last_seen_at: string; + sync_status: string; + }>; + }; + + expect(peers).toEqual({ + peers: [ + { + peer_id: "peer-1", + base_url: "http://127.0.0.1:7999", + last_seen_at: expect.any(String), + sync_status: "idle", + }, + ], + }); + } finally { + await service.shutdown(); + } + }); + + it("returns the current sync updates snapshot from the read-only ingress", async () => { + const loreDir = await createTempLoreDir(); + await seedLoreCorpus(loreDir); + process.env.MIRROR_MEMORY_DB_PATH = await createTempMemoryDbPath(); + + const service = await startMirrorService({ + port: 0, + loreDir, + providerUrl: "http://brain.local/v1/chat/completions", + providerAuthToken: "token", + nodeId: "service-node", + baseUrl: "http://127.0.0.1:7001", + }); + + try { + const updates = (await requestJsonFromApp(service.app, "GET", "/mirror-sync/updates")) as { + node_id: string; + base_url: string | null; + canon: { + lore_dir: string; + index_path: string; + index_version: number; + files: Array<{ path: string }>; + }; + graph: { + version: string; + node_count: number; + edge_count: number; + }; + }; + + expect(updates.node_id).toBe("service-node"); + expect(updates.base_url).toBe("http://127.0.0.1:7001"); + expect(updates.canon.lore_dir).toBe(loreDir); + expect(updates.canon.index_path).toContain("_index/"); + expect(updates.canon.index_path.endsWith(".json")).toBe(true); + expect(typeof updates.canon.index_version).toBe("number"); + expect(updates.canon.index_version).toBeGreaterThan(0); + expect(updates.canon.files[0]?.path).toBe("TOBY_L1219_Rune3_PatienceVaultCancelled.md"); + expect(typeof updates.graph.version).toBe("string"); + expect(updates.graph.node_count).toBeGreaterThanOrEqual(0); + expect(updates.graph.edge_count).toBeGreaterThanOrEqual(0); + } finally { + await service.shutdown(); + } + }); + + it("returns requested file contents from the read-only sync updates ingress", async () => { + const loreDir = await createTempLoreDir(); + await seedLoreCorpus(loreDir); + process.env.MIRROR_MEMORY_DB_PATH = await createTempMemoryDbPath(); + + const service = await startMirrorService({ + port: 0, + loreDir, + providerUrl: "http://brain.local/v1/chat/completions", + providerAuthToken: "token", + nodeId: "service-node", + baseUrl: "http://127.0.0.1:7001", + }); + + try { + const updates = (await requestJsonFromApp( + service.app, + "GET", + "/mirror-sync/updates?include_content=1&paths=TOBY_L1219_Rune3_PatienceVaultCancelled.md", + )) as { + file_contents?: Record; + }; + + expect(updates.file_contents).toEqual({ + "TOBY_L1219_Rune3_PatienceVaultCancelled.md": expect.stringContaining( + "The Patience Vault was cancelled.", + ), + }); + } finally { + await service.shutdown(); + } + }); + it("preserves invalid sync announce responses and wrapper events", async () => { const loreDir = await createTempLoreDir(); await seedLoreCorpus(loreDir); @@ -1310,6 +1440,38 @@ describe("mirror service", () => { } }); + it("exposes an empty standalone sync status endpoint", async () => { + const loreDir = await createTempLoreDir(); + await seedLoreCorpus(loreDir); + process.env.MIRROR_MEMORY_DB_PATH = await createTempMemoryDbPath(); + + const service = await startMirrorService({ + port: 0, + loreDir, + providerUrl: "http://brain.local/v1/chat/completions", + providerAuthToken: "token", + nodeId: "sync-node-empty", + }); + + try { + const sync = (await requestJsonFromApp(service.app, "GET", "/mirror/sync")) as { + ok: boolean; + daemon_session_id: string; + peers_known: number; + peers: unknown[]; + }; + + expect(sync).toEqual({ + ok: true, + daemon_session_id: expect.any(String), + peers_known: 0, + peers: [], + }); + } finally { + await service.shutdown(); + } + }); + it("exposes canonical runtime state and debug endpoints", async () => { const loreDir = await createTempLoreDir(); await seedLoreCorpus(loreDir); @@ -1364,6 +1526,18 @@ describe("mirror service", () => { total: number; providers: Array<{ provider_id: string; selected: boolean }>; }; + const sync = (await requestJsonFromApp(service.app, "GET", "/mirror/sync")) as { + ok: boolean; + peers_known: number; + peers: Array<{ + peer_id: string; + base_url: string; + last_seen_at: string; + sync_status: string; + last_sync_at?: string; + last_error?: string; + }>; + }; expect(runtime.ok).toBe(true); expect(runtime.version.length).toBeGreaterThan(0); @@ -1387,6 +1561,67 @@ describe("mirror service", () => { expect(providers.total).toBe(1); expect(providers.providers[0]?.provider_id).toBe("primary"); expect(providers.providers[0]?.selected).toBe(true); + expect(sync.ok).toBe(true); + expect(sync.peers_known).toBe(0); + expect(sync.peers).toEqual([]); + } finally { + await service.shutdown(); + } + }); + + it("reflects announced peers on the standalone sync status endpoint", 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: "sync-node-populated", + baseUrl: "http://127.0.0.1:7001", + }); + + try { + await requestJsonFromApp(service.app, "POST", "/mirror-sync/announce", { + headers: { + "x-mirror-operator-token": "secret", + }, + body: { + peer_id: "peer-1", + base_url: "http://127.0.0.1:7999", + }, + }); + + const sync = (await requestJsonFromApp(service.app, "GET", "/mirror/sync")) as { + ok: boolean; + daemon_session_id: string; + peers_known: number; + peers: Array<{ + peer_id: string; + base_url: string; + last_seen_at: string; + sync_status: string; + last_sync_at?: string; + last_error?: string; + }>; + }; + + expect(sync.ok).toBe(true); + expect(sync.daemon_session_id.length).toBeGreaterThan(0); + expect(sync.peers_known).toBe(1); + expect(sync.peers).toEqual([ + { + peer_id: "peer-1", + base_url: "http://127.0.0.1:7999", + last_seen_at: expect.any(String), + sync_status: "idle", + last_sync_at: undefined, + last_error: undefined, + }, + ]); } finally { await service.shutdown(); } diff --git a/src/mirror-service/mirror_service.ts b/src/mirror-service/mirror_service.ts index f413fa20b5..1852c9b02e 100644 --- a/src/mirror-service/mirror_service.ts +++ b/src/mirror-service/mirror_service.ts @@ -29,6 +29,7 @@ import { getMirrordaemonHealthState, getMirrordaemonProvidersState, getMirrordaemonRuntimeState, + getMirrordaemonSyncState, type Mirrordaemon, } from "../mirrordaemon/index.js"; import { type MirrorServiceConfig } from "./config.js"; @@ -196,6 +197,13 @@ export async function startMirrorService( }), ); }); + app.get("/mirror/sync", (_req, res) => { + res.json( + getMirrordaemonSyncState(daemon, { + peers: syncManager.listPeers(), + }), + ); + }); app.get("/mirror/runtime/events", (req, res) => { res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); diff --git a/src/mirrordaemon/daemon_types.ts b/src/mirrordaemon/daemon_types.ts index 2d354e11aa..d0e35c3130 100644 --- a/src/mirrordaemon/daemon_types.ts +++ b/src/mirrordaemon/daemon_types.ts @@ -2,6 +2,7 @@ import type { MirrorDiagnosticEvent, MirrorObservabilityContext, } from "../mirror-observability/index.js"; +import type { MirrorSyncPeer } from "../mirror-sync/index.js"; export type MirrordaemonSurfaceName = | "cli" @@ -199,6 +200,18 @@ export type MirrordaemonProvidersSummary = { }>; }; +export type MirrordaemonSyncSummary = { + ok: true; + daemon_session_id: string; + peers_known: number; + peers: Array< + Pick< + MirrorSyncPeer, + "peer_id" | "base_url" | "last_seen_at" | "sync_status" | "last_sync_at" | "last_error" + > + >; +}; + export type MirrordaemonEventSubscription = { unsubscribe: () => void; }; diff --git a/src/mirrordaemon/index.ts b/src/mirrordaemon/index.ts index b8242c09b8..40cbea84ec 100644 --- a/src/mirrordaemon/index.ts +++ b/src/mirrordaemon/index.ts @@ -3,7 +3,11 @@ export { createMirrordaemon, type Mirrordaemon } from "./mirrordaemon.js"; export { createSessionRegistry, type MirrordaemonSessionRegistry } from "./session_registry.js"; export { createRuntimeEventStream } from "./event_stream.js"; export { getMirrordaemonRuntimeState, getMirrordaemonHealthState } from "./status_api.js"; -export { getMirrordaemonActionsState, getMirrordaemonProvidersState } from "./status_api.js"; +export { + getMirrordaemonActionsState, + getMirrordaemonProvidersState, + getMirrordaemonSyncState, +} from "./status_api.js"; export { getMirrordaemonDebugState } from "./debug_api.js"; export { buildActionsSummary, @@ -11,6 +15,7 @@ export { buildHealthSummary, buildDebugSnapshot, buildProvidersSummary, + buildSyncSummary, buildStatusPayload, } from "./runtime_state.js"; export type { @@ -27,6 +32,7 @@ export type { MirrordaemonRuntimeEvent, MirrordaemonRuntimeSummary, MirrordaemonSession, + MirrordaemonSyncSummary, MirrordaemonSurfaceName, TouchMirrordaemonSessionInput, } from "./daemon_types.js"; diff --git a/src/mirrordaemon/runtime_state.ts b/src/mirrordaemon/runtime_state.ts index afa63d96ac..e6a3603bd2 100644 --- a/src/mirrordaemon/runtime_state.ts +++ b/src/mirrordaemon/runtime_state.ts @@ -1,5 +1,6 @@ import type { MirrorActionRuntime } from "../mirror-actions/index.js"; import type { MirrorProviderPlane } from "../mirror-provider/index.js"; +import type { MirrorSyncPeer } from "../mirror-sync/index.js"; import { VERSION } from "../version.js"; import type { MirrordaemonActionsSummary, @@ -8,6 +9,7 @@ import type { MirrordaemonHealthSummary, MirrordaemonProvidersSummary, MirrordaemonRuntimeSummary, + MirrordaemonSyncSummary, } from "./daemon_types.js"; import type { Mirrordaemon } from "./mirrordaemon.js"; @@ -105,6 +107,32 @@ export function buildProvidersSummary( }; } +function buildSyncPeerSummary(peer: MirrorSyncPeer): MirrordaemonSyncSummary["peers"][number] { + return { + peer_id: peer.peer_id, + base_url: peer.base_url, + last_seen_at: peer.last_seen_at, + sync_status: peer.sync_status, + last_sync_at: peer.last_sync_at, + last_error: peer.last_error, + }; +} + +export function buildSyncSummary( + daemon: Mirrordaemon, + options: { peers?: MirrorSyncPeer[] } = {}, +): MirrordaemonSyncSummary { + const boot = daemon.getBootSnapshot(); + const peers = (options.peers ?? []).map(buildSyncPeerSummary); + + return { + ok: true, + daemon_session_id: boot.config.daemon_session_id, + peers_known: peers.length, + peers, + }; +} + export function buildRuntimeSummary( daemon: Mirrordaemon, overrides: RuntimeStateOverrides = {}, diff --git a/src/mirrordaemon/status_api.ts b/src/mirrordaemon/status_api.ts index ede2011b61..688e4a3a6a 100644 --- a/src/mirrordaemon/status_api.ts +++ b/src/mirrordaemon/status_api.ts @@ -4,6 +4,7 @@ import { buildHealthSummary, buildProvidersSummary, buildRuntimeSummary, + buildSyncSummary, } from "./runtime_state.js"; export function getMirrordaemonRuntimeState( @@ -33,3 +34,10 @@ export function getMirrordaemonProvidersState( ) { return buildProvidersSummary(daemon, params); } + +export function getMirrordaemonSyncState( + daemon: Mirrordaemon, + params: Parameters[1] = {}, +) { + return buildSyncSummary(daemon, params); +} 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();