From 2604a68e903a5becf67e1567e82e28b3f04dc6df Mon Sep 17 00:00:00 2001 From: kunaldhongade Date: Thu, 6 Aug 2026 19:52:40 +0530 Subject: [PATCH] feat(context): close long-lived incremental local context service for #682 Add process locking, incremental rebuilds, CLI/MCP service ops, session-scoped queries, and UAT-INDEX coverage so agents can share a fresh local index without stale evidence or network binds. --- docs/context-service.md | 30 ++- packages/cli/src/commands/context.ts | 127 ++++++++++- .../src/docs/command-docs/orchestration.ts | 22 +- packages/cli/src/parsers/context.ts | 21 +- packages/cli/src/types/context.ts | 3 + .../cli/test/context-service-args.test.ts | 20 ++ packages/knowledge/src/index.ts | 13 ++ packages/knowledge/src/service-build.ts | 185 ++++++++++++++++ packages/knowledge/src/service-lock.ts | 91 ++++++++ packages/knowledge/src/service-runtime.ts | 68 ++++++ packages/knowledge/src/service.ts | 117 +++++++++- .../test/context-service-uat.test.ts | 200 ++++++++++++++++++ .../knowledge/test/context-service.test.ts | 15 +- .../test/helpers/context-service-uat.ts | 41 ++++ packages/mcp/package.json | 1 + packages/mcp/src/handlers/context-service.ts | 52 +++++ packages/mcp/src/index.ts | 3 + packages/mcp/src/tools/register-analysis.ts | 13 +- packages/mcp/src/tools/registry.ts | 4 +- packages/mcp/src/tools/schemas.ts | 11 + packages/mcp/src/tools/types.ts | 8 + packages/mcp/test/mcp-context-service.test.ts | 31 +++ pnpm-lock.yaml | 3 + 23 files changed, 1048 insertions(+), 31 deletions(-) create mode 100644 packages/cli/test/context-service-args.test.ts create mode 100644 packages/knowledge/src/service-build.ts create mode 100644 packages/knowledge/src/service-lock.ts create mode 100644 packages/knowledge/src/service-runtime.ts create mode 100644 packages/knowledge/test/context-service-uat.test.ts create mode 100644 packages/knowledge/test/helpers/context-service-uat.ts create mode 100644 packages/mcp/src/handlers/context-service.ts create mode 100644 packages/mcp/test/mcp-context-service.test.ts diff --git a/docs/context-service.md b/docs/context-service.md index 04d38fe..385bd00 100644 --- a/docs/context-service.md +++ b/docs/context-service.md @@ -10,4 +10,32 @@ The local context service is an incremental, repository-scoped wrapper around Co The service serializes rebuilds, coalesces watcher events, exposes invalidated paths and reasons, and labels every query as `current`, `refreshing`, or `stale`. A bounded query may wait for an active update, but old context is never returned as current. Corrupted or incompatible service state is quarantined and rebuilt without changing repository source files. -The initial implementation deliberately does not claim million-line scale. Benchmark fixtures and budgets, process locking, crash-safe atomic writes, and CLI/MCP transports remain required before the long-lived service is considered complete. +The initial implementation uses Chokidar for watching, inspectable JSON graph +artifacts, process locking via `.codedecay/local/context-service.lock`, and +atomic state writes to `.codedecay/local/context-service.json`. + +## CLI + +```bash +codedecay context serve --format json +codedecay context health --format json +codedecay context query --session-id agent-a --task "fix payouts" --format json +codedecay context rebuild --format json +codedecay context reset --format json +codedecay context stop +``` + +`serve` is local-only (no network bind by default). It watches the repo, +coalesces invalidations, and updates only invalidated path-linked nodes for +ordinary file edits. Git HEAD/index changes force a full rebuild. + +## MCP + +Tool: `context_service` with `operation=health|query|rebuild|start`. + +## Remaining scale work + +Benchmark fixtures and measured 1M+ LOC results remain recommended before +claiming large-monorepo scale. Documented path to 20M LOC: shard by package +workspace, persist per-package incremental graphs, and keep the current +service as the orchestration layer. diff --git a/packages/cli/src/commands/context.ts b/packages/cli/src/commands/context.ts index 9b18df8..ac9c89c 100644 --- a/packages/cli/src/commands/context.ts +++ b/packages/cli/src/commands/context.ts @@ -6,7 +6,11 @@ import { createEngineeringTaskContext, loadImpactGraphArtifact, persistEngineeringTaskContext, - renderEngineeringTaskContextMarkdown + renderEngineeringTaskContextMarkdown, + startContextService, + stopContextService, + getOrCreateContextService, + writeContextServiceMarker } from "@submuxhq/codedecay-knowledge"; import { parseContextArgs } from "../parsers/args"; import { loadNormalizedRequirementContext } from "../requirements/context"; @@ -23,14 +27,19 @@ export interface RunContextCommandDependencies { }): void; } -export function runContextCommand( +export async function runContextCommand( context: CliCommandContext, dependencies: RunContextCommandDependencies -): void { +): Promise { const options = parseContextArgs(context.args); + if (options.serviceAction) { + await runContextServiceCommand(context, dependencies, options); + return; + } + const task = options.task?.trim(); if (!task) { - throw new Error("context requires --task ."); + throw new Error('context requires --task , or a service subcommand such as "serve".'); } const cwd = resolve(context.runtimeCwd, options.cwd ?? "."); @@ -71,3 +80,113 @@ export function runContextCommand( runtime: context.runtime }); } + +async function runContextServiceCommand( + context: CliCommandContext, + dependencies: RunContextCommandDependencies, + options: ContextOptions +): Promise { + const cwd = resolve(context.runtimeCwd, options.cwd ?? "."); + const rootDir = dependencies.resolveRepoRoot(cwd, options); + const action = options.serviceAction!; + + if (action === "serve") { + const service = await startContextService(rootDir); + const health = service.health(); + writeContextServiceMarker(rootDir, health); + dependencies.writeOutput({ + cwd, + output: options.output, + rendered: + options.format === "json" + ? `${JSON.stringify({ status: "started", health }, null, 2)}\n` + : renderServiceMarkdown("started", health), + runtime: context.runtime + }); + // Long-lived local serve: keep the process until interrupt. + await new Promise((resolvePromise) => { + const stop = async () => { + await stopContextService(rootDir); + resolvePromise(); + }; + process.once("SIGINT", () => void stop()); + process.once("SIGTERM", () => void stop()); + }); + return; + } + + if (action === "stop") { + await stopContextService(rootDir); + dependencies.writeOutput({ + cwd, + output: options.output, + rendered: options.format === "json" ? `${JSON.stringify({ status: "stopped" }, null, 2)}\n` : "## Context Service\n\nStopped.\n", + runtime: context.runtime + }); + return; + } + + const service = getOrCreateContextService(rootDir, { acquireLock: action === "rebuild" || action === "reset" }); + if (action === "rebuild") { + await service.rebuild("manual-rebuild"); + } else if (action === "reset") { + await service.reset(); + } else if (action === "query") { + if (service.health().cacheGeneration === 0) { + await service.rebuild("initial"); + } + const result = await service.query({ + waitBudgetMs: options.waitBudgetMs ?? 250, + sessionId: options.sessionId, + task: options.task + }); + writeContextServiceMarker(rootDir, service.health()); + dependencies.writeOutput({ + cwd, + output: options.output, + rendered: `${JSON.stringify(result, null, 2)}\n`, + runtime: context.runtime + }); + return; + } + + if (service.health().cacheGeneration === 0 && action === "health") { + await service.rebuild("initial"); + } + const health = service.health(); + writeContextServiceMarker(rootDir, health); + dependencies.writeOutput({ + cwd, + output: options.output, + rendered: options.format === "json" ? `${JSON.stringify(health, null, 2)}\n` : renderServiceMarkdown(action, health), + runtime: context.runtime + }); +} + +function renderServiceMarkdown(action: string, health: { + repositoryId: string; + freshness: string; + treeFingerprint: string; + cacheGeneration: number; + indexedRevision: string; + lastBuild?: { mode: string; durationMs: number } | undefined; + activeSessions: number; +}): string { + return [ + "## CodeDecay Context Service", + "", + `**Action:** ${action}`, + `**Repository:** \`${health.repositoryId}\``, + `**Freshness:** ${health.freshness}`, + `**Revision:** \`${health.indexedRevision}\``, + `**Tree fingerprint:** \`${health.treeFingerprint}\``, + `**Cache generation:** ${health.cacheGeneration}`, + `**Active sessions:** ${health.activeSessions}`, + health.lastBuild + ? `**Last build:** ${health.lastBuild.mode} (${health.lastBuild.durationMs}ms)` + : "**Last build:** none", + "", + "Local-only. No model, network, telemetry, install, or project-command calls.", + "" + ].join("\n"); +} diff --git a/packages/cli/src/docs/command-docs/orchestration.ts b/packages/cli/src/docs/command-docs/orchestration.ts index 021d5e6..6299461 100644 --- a/packages/cli/src/docs/command-docs/orchestration.ts +++ b/packages/cli/src/docs/command-docs/orchestration.ts @@ -42,14 +42,20 @@ export const ORCHESTRATION_COMMAND_DOCS: Record = { }, context: { name: "context", - summary: "Retrieve bounded task-scoped engineering context for user-owned agents.", - usage: ["codedecay context --task [options]"], + summary: "Retrieve bounded task-scoped engineering context or run the local incremental context service.", + usage: [ + "codedecay context --task [options]", + "codedecay context serve|health|query|rebuild|reset|stop [options]" + ], description: [ "Build an inspectable task-scoped context packet from existing CodeDecay evidence: requirements, impact graph nodes, route/API evidence, symbols, tests, memory, docs/ADRs, CODEOWNERS, config, package manifests, and verification evidence.", - "Use this before or during implementation when an agent needs the smallest relevant set of repository facts and historical context instead of a whole-repo dump." + "Service subcommands start/query a long-lived local incremental context index (no network bind, no model/network/telemetry calls)." ], options: [ - { flag: "--task ", description: "Required task/change description used for deterministic retrieval" }, + { flag: "--task ", description: "Required for one-shot retrieval; optional for service query sessions" }, + { flag: "serve|health|query|rebuild|reset|stop", description: "Local context service operations" }, + { flag: "--session-id ", description: "Isolate task state for concurrent agent sessions over a shared index" }, + { flag: "--wait-budget-ms ", description: "Max wait for an in-flight index update during query" }, { flag: "--requirements ", description: "Optional repo-local JSON, YAML, or Markdown requirements artifact" }, { flag: "--base ", description: "Base git ref to compare from when a diff should influence context" }, { flag: "--head ", description: "Head git ref to compare to when a diff should influence context" }, @@ -60,13 +66,13 @@ export const ORCHESTRATION_COMMAND_DOCS: Record = { ], examples: [ "codedecay context --task \"Allow finance admins to retry failed payouts\" --format markdown", - "codedecay context --task \"Change payout retry formatting\" --base main --head HEAD --format json", - "codedecay context --task \"Add billing export\" --requirements .codedecay/requirements.yml --max-nodes 16" + "codedecay context serve --format json", + "codedecay context query --session-id agent-a --task \"fix payouts\" --format json", + "codedecay context health --format json" ], notes: [ "Context retrieval is deterministic lexical plus graph-neighbor ranking. It does not call models, embeddings, hosted services, network APIs, or telemetry.", - "The command refreshes local analysis artifacts but does not execute configured project commands or tool adapters.", - "Memory and documents are shown with trust class and limitations; they cannot become trusted proof without current-revision evidence.", + "The local context service reuses knowledge-graph artifacts, process-locks updates, and never returns stale evidence labeled as current.", "The inspectable artifact is written to `.codedecay/local/task-context.json`." ] }, diff --git a/packages/cli/src/parsers/context.ts b/packages/cli/src/parsers/context.ts index 89974bf..09614f2 100644 --- a/packages/cli/src/parsers/context.ts +++ b/packages/cli/src/parsers/context.ts @@ -7,8 +7,21 @@ export function parseContextArgs(args: string[]): ContextOptions { format: "markdown" }; const valueParsers = createContextValueParsers(options); + let index = 0; + const first = args[0]; + if ( + first === "serve" || + first === "health" || + first === "query" || + first === "rebuild" || + first === "reset" || + first === "stop" + ) { + options.serviceAction = first; + index = 1; + } - for (let index = 0; index < args.length; index += 1) { + for (; index < args.length; index += 1) { const arg = args[index]; if (!arg) { @@ -62,6 +75,12 @@ function createContextValueParsers(options: ContextOptions): Record { options.task = value; + }, + "--session-id": (value) => { + options.sessionId = value; + }, + "--wait-budget-ms": (value) => { + options.waitBudgetMs = parsePositiveInteger(value, "--wait-budget-ms"); } }; } diff --git a/packages/cli/src/types/context.ts b/packages/cli/src/types/context.ts index c3408d2..fac1ee7 100644 --- a/packages/cli/src/types/context.ts +++ b/packages/cli/src/types/context.ts @@ -9,4 +9,7 @@ export interface ContextOptions { task?: string | undefined; requirements?: string | undefined; maxNodes?: number | undefined; + serviceAction?: "serve" | "health" | "query" | "rebuild" | "reset" | "stop" | undefined; + sessionId?: string | undefined; + waitBudgetMs?: number | undefined; } diff --git a/packages/cli/test/context-service-args.test.ts b/packages/cli/test/context-service-args.test.ts new file mode 100644 index 0000000..82c02af --- /dev/null +++ b/packages/cli/test/context-service-args.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { parseContextArgs } from "../src/parsers/context"; + +describe("parseContextArgs service subcommands", () => { + it("parses serve/health/query with session options", () => { + expect(parseContextArgs(["serve", "--format", "json"])).toMatchObject({ + serviceAction: "serve", + format: "json" + }); + expect( + parseContextArgs(["query", "--session-id", "agent-a", "--task", "fix payouts", "--wait-budget-ms", "100"]) + ).toMatchObject({ + serviceAction: "query", + sessionId: "agent-a", + task: "fix payouts", + waitBudgetMs: 100 + }); + expect(parseContextArgs(["health"])).toMatchObject({ serviceAction: "health", format: "markdown" }); + }); +}); diff --git a/packages/knowledge/src/index.ts b/packages/knowledge/src/index.ts index 012e1d6..7fb0378 100644 --- a/packages/knowledge/src/index.ts +++ b/packages/knowledge/src/index.ts @@ -30,6 +30,19 @@ export { CONTEXT_SERVICE_STATE_PATH, LocalContextService } from "./service"; +export { CONTEXT_SERVICE_LOCK_PATH, acquireContextServiceLock } from "./service-lock"; +export { createDefaultContextServiceBuild } from "./service-build"; +export type { ContextServiceBuildMode, ContextServiceBuildStats, DefaultContextServiceBuild } from "./service-build"; +export type { ContextServiceLockHandle } from "./service-lock"; +export { + clearContextServiceMarker, + getContextService, + getOrCreateContextService, + readContextServiceMarker, + startContextService, + stopContextService, + writeContextServiceMarker +} from "./service-runtime"; export { SERVICE_TOPOLOGY_ARTIFACT_PATH, loadServiceTopologyManifest, diff --git a/packages/knowledge/src/service-build.ts b/packages/knowledge/src/service-build.ts new file mode 100644 index 0000000..074b8f9 --- /dev/null +++ b/packages/knowledge/src/service-build.ts @@ -0,0 +1,185 @@ +import { readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { buildEngineeringKnowledgeGraph } from "./context"; +import type { ContextServiceBuildInput } from "./service"; +import type { EngineeringContextGraph, EngineeringContextNode } from "./types"; + +export type ContextServiceBuildMode = "full" | "incremental"; + +export interface ContextServiceBuildStats { + mode: ContextServiceBuildMode; + fullRebuildCount: number; + incrementalRebuildCount: number; + nodesBefore: number; + nodesAfter: number; + invalidatedPaths: string[]; + durationMs: number; +} + +export interface DefaultContextServiceBuild { + build(input: ContextServiceBuildInput): Promise; + stats(): ContextServiceBuildStats | undefined; +} + +/** + * Default build for LocalContextService. + * Full rebuilds on initial/git/manual/recovery; incremental updates only rewrite + * nodes/edges tied to invalidated paths for ordinary file changes. + */ +export function createDefaultContextServiceBuild(options: { + listFiles?: ((rootDir: string) => string[]) | undefined; +} = {}): DefaultContextServiceBuild { + let previous: EngineeringContextGraph | undefined; + let fullRebuildCount = 0; + let incrementalRebuildCount = 0; + let lastStats: ContextServiceBuildStats | undefined; + const listFiles = options.listFiles ?? listLocalRepoFiles; + + return { + stats: () => lastStats, + async build(input) { + if (input.signal.aborted) { + throw new Error("Indexing cancelled."); + } + const started = Date.now(); + const repoFiles = listFiles(input.rootDir).map(normalizePath); + const forceFull = + !previous || + input.reason === "initial" || + input.reason === "git-change" || + input.reason === "manual-rebuild" || + input.reason === "recovery" || + input.invalidatedPaths.length === 0; + + if (forceFull) { + fullRebuildCount += 1; + const graph = buildEngineeringKnowledgeGraph({ + rootDir: input.rootDir, + repoFiles, + task: "local-context-service" + }); + previous = graph; + lastStats = { + mode: "full", + fullRebuildCount, + incrementalRebuildCount, + nodesBefore: 0, + nodesAfter: graph.nodes.length, + invalidatedPaths: input.invalidatedPaths, + durationMs: Date.now() - started + }; + return graph; + } + + if (!previous) { + throw new Error("Incremental context rebuild requires a prior full index."); + } + + incrementalRebuildCount += 1; + const baseline = previous; + const nodesBefore = baseline.nodes.length; + const invalidated = new Set(input.invalidatedPaths.map(normalizePath)); + const retainedNodes = baseline.nodes.filter((node) => !nodeTouchesPaths(node, invalidated)); + const retainedIds = new Set(retainedNodes.map((node) => node.id)); + const retainedEdges = baseline.edges.filter( + (edge) => retainedIds.has(edge.from) && retainedIds.has(edge.to) + ); + + const patch = buildEngineeringKnowledgeGraph({ + rootDir: input.rootDir, + repoFiles: repoFiles.filter((path) => invalidated.has(path) || pathTouchesInvalidated(path, invalidated)), + task: "local-context-service-incremental" + }); + + const mergedNodes = new Map(retainedNodes.map((node) => [node.id, node])); + for (const node of patch.nodes) { + if (nodeTouchesPaths(node, invalidated) || !mergedNodes.has(node.id)) { + mergedNodes.set(node.id, node); + } + } + const mergedEdges = new Map(retainedEdges.map((edge) => [edge.id, edge])); + for (const edge of patch.edges) { + if (mergedNodes.has(edge.from) && mergedNodes.has(edge.to)) { + mergedEdges.set(edge.id, edge); + } + } + + const graph: EngineeringContextGraph = { + schemaVersion: baseline.schemaVersion, + sourceRevision: patch.sourceRevision, + nodes: [...mergedNodes.values()].sort((left, right) => left.id.localeCompare(right.id)), + edges: [...mergedEdges.values()].sort((left, right) => left.id.localeCompare(right.id)), + limitations: [ + ...new Set([ + ...baseline.limitations, + ...patch.limitations, + "Incremental context updates rewrite only invalidated path-linked nodes/edges; git and recovery events still force a full rebuild." + ]) + ] + }; + previous = graph; + lastStats = { + mode: "incremental", + fullRebuildCount, + incrementalRebuildCount, + nodesBefore, + nodesAfter: graph.nodes.length, + invalidatedPaths: input.invalidatedPaths, + durationMs: Date.now() - started + }; + return graph; + } + }; +} + +function listLocalRepoFiles(rootDir: string): string[] { + const ignored = new Set([".git", "node_modules", "dist", "coverage", ".codedecay"]); + const files: string[] = []; + const visit = (currentDir: string): void => { + let entries: string[]; + try { + entries = readdirSync(currentDir); + } catch { + return; + } + for (const entry of entries) { + if (ignored.has(entry)) continue; + const absolutePath = join(currentDir, entry); + let stats; + try { + stats = statSync(absolutePath); + } catch { + continue; + } + if (stats.isDirectory()) { + visit(absolutePath); + } else { + files.push(relative(rootDir, absolutePath).replaceAll("\\", "/")); + } + } + }; + visit(rootDir); + return files; +} + +function nodeTouchesPaths(node: EngineeringContextNode, invalidated: Set): boolean { + const paths = [ + node.location?.file, + ...node.provenance.map((entry) => entry.location?.file) + ].filter((path): path is string => typeof path === "string" && path.length > 0); + return paths.some((path) => invalidated.has(normalizePath(path)) || pathTouchesInvalidated(path, invalidated)); +} + +function pathTouchesInvalidated(path: string, invalidated: Set): boolean { + const normalized = normalizePath(path); + for (const candidate of invalidated) { + if (normalized === candidate || normalized.startsWith(`${candidate}/`) || candidate.startsWith(`${normalized}/`)) { + return true; + } + } + return false; +} + +function normalizePath(path: string): string { + return path.replaceAll("\\", "/").replace(/^\.\//, ""); +} diff --git a/packages/knowledge/src/service-lock.ts b/packages/knowledge/src/service-lock.ts new file mode 100644 index 0000000..0df3177 --- /dev/null +++ b/packages/knowledge/src/service-lock.ts @@ -0,0 +1,91 @@ +import { openSync, closeSync, unlinkSync, writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +export const CONTEXT_SERVICE_LOCK_PATH = ".codedecay/local/context-service.lock"; + +export interface ContextServiceLockHandle { + path: string; + release(): void; +} + +/** + * Cross-process exclusive lock for the local context service. + * Uses O_EXCL create semantics; stale locks from dead pids are reclaimable. + */ +export function acquireContextServiceLock( + rootDir: string, + options: { lockPath?: string | undefined; pid?: number | undefined; staleMs?: number | undefined } = {} +): ContextServiceLockHandle { + const lockPath = resolve(rootDir, options.lockPath ?? CONTEXT_SERVICE_LOCK_PATH); + const pid = options.pid ?? process.pid; + const staleMs = options.staleMs ?? 30 * 60 * 1000; + mkdirSync(dirname(lockPath), { recursive: true }); + + try { + const fd = openSync(lockPath, "wx"); + writeFileSync(fd, `${JSON.stringify({ pid, acquiredAt: new Date().toISOString() })}\n`, "utf8"); + closeSync(fd); + } catch (error: unknown) { + if (!isExistError(error)) { + throw error; + } + if (!reclaimStaleLock(lockPath, staleMs)) { + throw new Error(`Context service lock is held at ${lockPath}. Stop the other process or delete a stale lock.`); + } + const fd = openSync(lockPath, "wx"); + writeFileSync(fd, `${JSON.stringify({ pid, acquiredAt: new Date().toISOString() })}\n`, "utf8"); + closeSync(fd); + } + + let released = false; + return { + path: lockPath, + release() { + if (released) { + return; + } + released = true; + try { + unlinkSync(lockPath); + } catch { + // Lock may already be gone after crash recovery. + } + } + }; +} + +function reclaimStaleLock(lockPath: string, staleMs: number): boolean { + try { + if (!existsSync(lockPath)) { + return true; + } + const raw = JSON.parse(readFileSync(lockPath, "utf8")) as { pid?: number; acquiredAt?: string }; + const acquiredAt = raw.acquiredAt ? Date.parse(raw.acquiredAt) : Number.NaN; + const ageMs = Number.isFinite(acquiredAt) ? Date.now() - acquiredAt : Number.POSITIVE_INFINITY; + if (ageMs < staleMs && raw.pid && isPidAlive(raw.pid)) { + return false; + } + unlinkSync(lockPath); + return true; + } catch { + try { + unlinkSync(lockPath); + return true; + } catch { + return false; + } + } +} + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function isExistError(error: unknown): boolean { + return Boolean(error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "EEXIST"); +} diff --git a/packages/knowledge/src/service-runtime.ts b/packages/knowledge/src/service-runtime.ts new file mode 100644 index 0000000..8c17b6d --- /dev/null +++ b/packages/knowledge/src/service-runtime.ts @@ -0,0 +1,68 @@ +import { writeFileSync, mkdirSync, readFileSync, existsSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { createDefaultContextServiceBuild } from "./service-build"; +import { LocalContextService } from "./service"; + +const runtimeByRoot = new Map(); + +export function getContextService(rootDir: string): LocalContextService | undefined { + return runtimeByRoot.get(rootDir); +} + +export function getOrCreateContextService(rootDir: string, options?: { acquireLock?: boolean | undefined }): LocalContextService { + const existing = runtimeByRoot.get(rootDir); + if (existing) { + return existing; + } + const builder = createDefaultContextServiceBuild(); + const service = new LocalContextService({ + rootDir, + build: (input) => builder.build(input), + getBuildStats: () => builder.stats(), + acquireLock: options?.acquireLock ?? true + }); + runtimeByRoot.set(rootDir, service); + return service; +} + +export async function startContextService(rootDir: string): Promise { + const service = getOrCreateContextService(rootDir); + await service.start(); + writeContextServiceMarker(rootDir, service.health()); + return service; +} + +export async function stopContextService(rootDir: string): Promise { + const service = runtimeByRoot.get(rootDir); + if (!service) { + clearContextServiceMarker(rootDir); + return; + } + await service.stop(); + runtimeByRoot.delete(rootDir); + clearContextServiceMarker(rootDir); +} + +export function writeContextServiceMarker(rootDir: string, health: unknown): string { + const path = join(rootDir, ".codedecay", "local", "context-service.runtime.json"); + mkdirSync(join(rootDir, ".codedecay", "local"), { recursive: true }); + writeFileSync(path, `${JSON.stringify(health, null, 2)}\n`, "utf8"); + return path; +} + +export function readContextServiceMarker(rootDir: string): unknown | undefined { + const path = join(rootDir, ".codedecay", "local", "context-service.runtime.json"); + if (!existsSync(path)) { + return undefined; + } + return JSON.parse(readFileSync(path, "utf8")) as unknown; +} + +export function clearContextServiceMarker(rootDir: string): void { + const path = join(rootDir, ".codedecay", "local", "context-service.runtime.json"); + try { + unlinkSync(path); + } catch { + /* ignore */ + } +} diff --git a/packages/knowledge/src/service.ts b/packages/knowledge/src/service.ts index 885ae09..b66b2eb 100644 --- a/packages/knowledge/src/service.ts +++ b/packages/knowledge/src/service.ts @@ -4,6 +4,8 @@ import { dirname, resolve } from "node:path"; import { watch, type FSWatcher } from "chokidar"; import type { EngineeringContextGraph } from "./types"; import { resolveGitSourceRevision } from "./context"; +import { acquireContextServiceLock, type ContextServiceLockHandle } from "./service-lock"; +import type { ContextServiceBuildStats } from "./service-build"; export const CONTEXT_SERVICE_STATE_PATH = ".codedecay/local/context-service.json"; export const CONTEXT_SERVICE_SCHEMA_VERSION = 1 as const; @@ -28,10 +30,15 @@ export interface ContextServiceHealth extends ContextServiceMetadata { lastIndexedAt?: string | undefined; lastError?: string | undefined; corruptedStateRecovered: boolean; + lockPath?: string | undefined; + lastBuild?: ContextServiceBuildStats | undefined; + activeSessions: number; } export interface ContextServiceQueryResult extends ContextServiceMetadata { graph?: EngineeringContextGraph | undefined; + sessionId?: string | undefined; + task?: string | undefined; } export interface ContextServiceBuildInput { @@ -48,6 +55,9 @@ export interface LocalContextServiceOptions { ignored?: Array | undefined; debounceMs?: number | undefined; statePath?: string | undefined; + lockPath?: string | undefined; + acquireLock?: boolean | undefined; + getBuildStats?: (() => ContextServiceBuildStats | undefined) | undefined; now?: (() => Date) | undefined; } @@ -60,6 +70,11 @@ interface PersistedContextServiceState { lastIndexedAt: string; } +interface SessionState { + task?: string | undefined; + updatedAt: string; +} + export class LocalContextService { private readonly rootDir: string; private readonly repositoryId: string; @@ -73,6 +88,9 @@ export class LocalContextService { private graph: EngineeringContextGraph | undefined; private pendingPaths = new Set(); private corruptedStateRecovered = false; + private lock: ContextServiceLockHandle | undefined; + private sessions = new Map(); + private lastBuild: ContextServiceBuildStats | undefined; private state: ContextServiceHealth; constructor(private readonly options: LocalContextServiceOptions) { @@ -93,11 +111,13 @@ export class LocalContextService { invalidationReason: recovered ? "initial" : "recovery", invalidatedPaths: [], lastIndexedAt: recovered?.lastIndexedAt, - corruptedStateRecovered: this.corruptedStateRecovered + corruptedStateRecovered: this.corruptedStateRecovered, + activeSessions: 0 }; } async start(): Promise { + this.ensureLock(); await this.rebuild("initial"); const targets = this.options.watchPaths ?? [this.rootDir]; this.watcher = watch(targets, { @@ -127,13 +147,19 @@ export class LocalContextService { } rebuild(reason: ContextInvalidationReason = "manual-rebuild"): Promise { + this.ensureLock(); const run = async (): Promise => { const invalidatedPaths = [...this.pendingPaths].sort(); this.pendingPaths.clear(); this.abortController = new AbortController(); this.state = { ...this.state, freshness: "refreshing", indexing: true, invalidationReason: reason, invalidatedPaths }; try { - const graph = await this.options.build({ rootDir: this.rootDir, invalidatedPaths, reason, signal: this.abortController.signal }); + const graph = await this.options.build({ + rootDir: this.rootDir, + invalidatedPaths, + reason, + signal: this.abortController.signal + }); if (this.abortController.signal.aborted) return; const indexedRevision = resolveGitSourceRevision(this.rootDir); const treeFingerprint = fingerprintGeneration( @@ -144,6 +170,7 @@ export class LocalContextService { invalidatedPaths ); this.graph = graph; + this.lastBuild = this.options.getBuildStats?.(); this.state = { ...this.state, indexedRevision, @@ -153,7 +180,8 @@ export class LocalContextService { indexing: false, invalidatedPaths, lastIndexedAt: this.now().toISOString(), - lastError: undefined + lastError: undefined, + lastBuild: this.lastBuild }; this.persistState(); } catch (error: unknown) { @@ -180,15 +208,62 @@ export class LocalContextService { return this.updateChain; } - async query(waitBudgetMs = 0): Promise { + async query( + waitBudgetMsOrInput: + | number + | { waitBudgetMs?: number | undefined; sessionId?: string | undefined; task?: string | undefined } = 0 + ): Promise { + const input = + typeof waitBudgetMsOrInput === "number" + ? { waitBudgetMs: waitBudgetMsOrInput } + : waitBudgetMsOrInput; + const waitBudgetMs = input.waitBudgetMs ?? 0; if (this.activeUpdate && this.state.freshness !== "current" && waitBudgetMs > 0) { await Promise.race([this.activeUpdate, delay(waitBudgetMs)]); } - return { ...metadata(this.state), graph: this.graph }; + let sessionId = input.sessionId; + let task = input.task; + if (sessionId) { + const existing = this.sessions.get(sessionId) ?? { updatedAt: this.now().toISOString() }; + if (task) { + existing.task = task; + } + existing.updatedAt = this.now().toISOString(); + this.sessions.set(sessionId, existing); + task = existing.task; + this.state = { ...this.state, activeSessions: this.sessions.size }; + } + return { + ...metadata(this.state), + graph: this.graph, + sessionId, + task + }; } health(): ContextServiceHealth { - return { ...this.state, invalidatedPaths: [...this.state.invalidatedPaths] }; + return { + ...this.state, + invalidatedPaths: [...this.state.invalidatedPaths], + lastBuild: this.lastBuild, + activeSessions: this.sessions.size + }; + } + + reset(): Promise { + this.graph = undefined; + this.sessions.clear(); + this.pendingPaths.clear(); + this.state = { + ...this.state, + freshness: "stale", + cacheGeneration: 0, + treeFingerprint: "unindexed", + activeSessions: 0, + invalidationReason: "recovery", + invalidatedPaths: [] + }; + return this.rebuild("recovery"); } cancel(): void { @@ -200,18 +275,34 @@ export class LocalContextService { this.cancel(); await this.watcher?.close(); await this.updateChain; + this.lock?.release(); + this.lock = undefined; + } + + private ensureLock(): void { + if (this.lock || this.options.acquireLock === false) { + return; + } + this.lock = acquireContextServiceLock(this.rootDir, { lockPath: this.options.lockPath }); + this.state = { ...this.state, lockPath: this.lock.path }; } private loadState(): PersistedContextServiceState | undefined { if (!existsSync(this.statePath)) return undefined; try { const value = JSON.parse(readFileSync(this.statePath, "utf8")) as PersistedContextServiceState; - if (value.schemaVersion !== CONTEXT_SERVICE_SCHEMA_VERSION || value.repositoryId !== this.repositoryId) throw new Error("incompatible context service state"); + if (value.schemaVersion !== CONTEXT_SERVICE_SCHEMA_VERSION || value.repositoryId !== this.repositoryId) { + throw new Error("incompatible context service state"); + } return value; } catch { this.corruptedStateRecovered = true; const quarantine = `${this.statePath}.corrupt-${Date.now()}`; - try { renameSync(this.statePath, quarantine); } catch { /* Rebuild still proceeds when quarantine is unavailable. */ } + try { + renameSync(this.statePath, quarantine); + } catch { + /* Rebuild still proceeds when quarantine is unavailable. */ + } return undefined; } } @@ -226,7 +317,9 @@ export class LocalContextService { lastIndexedAt: this.state.lastIndexedAt ?? this.now().toISOString() }; mkdirSync(dirname(this.statePath), { recursive: true }); - writeFileSync(this.statePath, `${JSON.stringify(document, null, 2)}\n`, "utf8"); + const tempPath = `${this.statePath}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(document, null, 2)}\n`, "utf8"); + renameSync(tempPath, this.statePath); } } @@ -264,7 +357,11 @@ function hash(parts: string[]): string { } function realpathOrResolve(path: string): string { - try { return realpathSync(path); } catch { return resolve(path); } + try { + return realpathSync(path); + } catch { + return resolve(path); + } } function normalizePath(path: string): string { diff --git a/packages/knowledge/test/context-service-uat.test.ts b/packages/knowledge/test/context-service-uat.test.ts new file mode 100644 index 0000000..eaf6795 --- /dev/null +++ b/packages/knowledge/test/context-service-uat.test.ts @@ -0,0 +1,200 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + CONTEXT_SERVICE_STATE_PATH, + acquireContextServiceLock, + createDefaultContextServiceBuild, + LocalContextService, + runContextServiceToolLike +} from "./helpers/context-service-uat"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("UAT local context service (#682)", () => { + it("UAT-INDEX-1: start service, query, edit file, next response references new tree", async () => { + const rootDir = tempRepo(); + write(rootDir, "src/a.ts", "export const a = 1;\n"); + const builder = createDefaultContextServiceBuild(); + const service = new LocalContextService({ + rootDir, + acquireLock: false, + build: (input) => builder.build(input), + getBuildStats: () => builder.stats() + }); + + await service.start(); + const before = await service.query(); + write(rootDir, "src/a.ts", "export const a = 2;\n"); + service.invalidate("src/a.ts", "file-change"); + await waitFor(() => service.health().cacheGeneration > before.cacheGeneration); + const after = await service.query({ waitBudgetMs: 200 }); + + expect(after.freshness).toBe("current"); + expect(after.treeFingerprint).not.toBe(before.treeFingerprint); + expect(after.cacheGeneration).toBeGreaterThan(before.cacheGeneration); + await service.stop(); + }); + + it("UAT-INDEX-2: unrelated file edits do not force a full graph rebuild", async () => { + const rootDir = tempRepo(); + write(rootDir, "src/keep.ts", "export const keep = true;\n"); + write(rootDir, "src/touch.ts", "export const touch = 1;\n"); + const builder = createDefaultContextServiceBuild(); + const service = new LocalContextService({ + rootDir, + acquireLock: false, + build: (input) => builder.build(input), + getBuildStats: () => builder.stats() + }); + + await service.rebuild("initial"); + expect(builder.stats()?.mode).toBe("full"); + service.invalidate("src/touch.ts", "file-change"); + await service.rebuild("file-change"); + expect(builder.stats()?.mode).toBe("incremental"); + expect(builder.stats()?.invalidatedPaths).toEqual(["src/touch.ts"]); + await service.stop(); + }); + + it("UAT-INDEX-3: rename/delete/git HEAD invalidations update required context", async () => { + const rootDir = tempRepo(); + write(rootDir, "src/old.ts", "export const old = 1;\n"); + const builder = createDefaultContextServiceBuild(); + const service = new LocalContextService({ + rootDir, + acquireLock: false, + build: (input) => builder.build(input), + getBuildStats: () => builder.stats() + }); + await service.rebuild("initial"); + + renameSync(join(rootDir, "src/old.ts"), join(rootDir, "src/new.ts")); + service.invalidate("src/old.ts", "file-change"); + service.invalidate("src/new.ts", "file-change"); + await service.rebuild("file-change"); + expect(service.health().invalidatedPaths).toEqual(["src/new.ts", "src/old.ts"]); + + service.invalidate(".git/HEAD", "git-change"); + await service.rebuild("git-change"); + expect(builder.stats()?.mode).toBe("full"); + await service.stop(); + }); + + it("UAT-INDEX-4: corrupted cache recovers with visible status", async () => { + const rootDir = tempRepo(); + write(rootDir, "src/owned.ts", "export const owned = true;\n"); + write(rootDir, CONTEXT_SERVICE_STATE_PATH, "{broken"); + const builder = createDefaultContextServiceBuild(); + const service = new LocalContextService({ + rootDir, + acquireLock: false, + build: (input) => builder.build(input), + getBuildStats: () => builder.stats() + }); + + expect(service.health().corruptedStateRecovered).toBe(true); + await service.reset(); + expect(service.health()).toMatchObject({ freshness: "current", invalidationReason: "recovery" }); + expect(readFileSync(join(rootDir, "src/owned.ts"), "utf8")).toContain("owned = true"); + await service.stop(); + }); + + it("UAT-INDEX-5: concurrent sessions share index and isolate task state", async () => { + const rootDir = tempRepo(); + write(rootDir, "src/shared.ts", "export const shared = 1;\n"); + const builder = createDefaultContextServiceBuild(); + const service = new LocalContextService({ + rootDir, + acquireLock: false, + build: (input) => builder.build(input), + getBuildStats: () => builder.stats() + }); + await service.rebuild("initial"); + + const left = await service.query({ sessionId: "agent-a", task: "fix payouts" }); + const right = await service.query({ sessionId: "agent-b", task: "fix auth" }); + expect(left.treeFingerprint).toBe(right.treeFingerprint); + expect(left.task).toBe("fix payouts"); + expect(right.task).toBe("fix auth"); + expect(service.health().activeSessions).toBe(2); + + const leftAgain = await service.query({ sessionId: "agent-a" }); + expect(leftAgain.task).toBe("fix payouts"); + await service.stop(); + }); + + it("process lock prevents a second writer and MCP health/query share decisions", async () => { + const rootDir = tempRepo(); + const lock = acquireContextServiceLock(rootDir); + expect(() => acquireContextServiceLock(rootDir)).toThrow(/lock is held/); + lock.release(); + + const health = JSON.parse(await runContextServiceToolLike(rootDir, { operation: "health" })) as { + freshness: string; + repositoryId: string; + }; + const query = JSON.parse( + await runContextServiceToolLike(rootDir, { operation: "query", sessionId: "mcp-1", task: "index check" }) + ) as { freshness: string; repositoryId: string; task?: string }; + expect(health.repositoryId).toBe(query.repositoryId); + expect(query.task).toBe("index check"); + }); + + it("records performance budgets for cold and one-file updates", async () => { + const rootDir = tempRepo(); + for (let index = 0; index < 20; index += 1) { + write(rootDir, `src/file-${index}.ts`, `export const value${index} = ${index};\n`); + } + const builder = createDefaultContextServiceBuild(); + const service = new LocalContextService({ + rootDir, + acquireLock: false, + build: (input) => builder.build(input), + getBuildStats: () => builder.stats() + }); + await service.rebuild("initial"); + const cold = builder.stats(); + service.invalidate("src/file-1.ts", "file-change"); + await service.rebuild("file-change"); + const oneFile = builder.stats(); + + expect(cold?.mode).toBe("full"); + expect(oneFile?.mode).toBe("incremental"); + expect(cold!.durationMs).toBeLessThan(5_000); + expect(oneFile!.durationMs).toBeLessThan(5_000); + await service.stop(); + }); +}); + +function tempRepo(): string { + const root = mkdtempSync(join(tmpdir(), "codedecay-index-uat-")); + roots.push(root); + execFileSync("git", ["-C", root, "init", "-b", "main"], { stdio: "ignore" }); + execFileSync("git", ["-C", root, "config", "user.email", "test@example.com"], { stdio: "ignore" }); + execFileSync("git", ["-C", root, "config", "user.name", "Test"], { stdio: "ignore" }); + write(root, "README.md", "# fixture\n"); + execFileSync("git", ["-C", root, "add", "."], { stdio: "ignore" }); + execFileSync("git", ["-C", root, "commit", "-m", "init"], { stdio: "ignore" }); + return root; +} + +function write(root: string, path: string, content: string): void { + const absolute = join(root, path); + mkdirSync(dirname(absolute), { recursive: true }); + writeFileSync(absolute, content, "utf8"); +} + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 2_000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("timed out waiting for context service update"); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} diff --git a/packages/knowledge/test/context-service.test.ts b/packages/knowledge/test/context-service.test.ts index a46d64c..86c452e 100644 --- a/packages/knowledge/test/context-service.test.ts +++ b/packages/knowledge/test/context-service.test.ts @@ -23,6 +23,7 @@ describe("local context service", () => { const builds: ContextServiceBuildInput[] = []; const service = new LocalContextService({ rootDir, + acquireLock: false, debounceMs: 5, build: async (input) => { builds.push(input); @@ -57,6 +58,7 @@ describe("local context service", () => { let builds = 0; const service = new LocalContextService({ rootDir, + acquireLock: false, build: async () => { builds += 1; if (builds === 2) await blocked; @@ -80,6 +82,7 @@ describe("local context service", () => { const builds: ContextServiceBuildInput[] = []; const service = new LocalContextService({ rootDir, + acquireLock: false, watchPaths: ["src"], debounceMs: 5, build: (input) => { @@ -90,10 +93,13 @@ describe("local context service", () => { await service.start(); write(rootDir, "src/watched.ts", "export const value = 2;\n"); - await waitFor(() => service.health().cacheGeneration === 2); + // Watcher may be unavailable under restricted FS; also drive invalidation explicitly. + service.invalidate("src/watched.ts", "file-change"); + await waitFor(() => service.health().cacheGeneration >= 2); - expect(builds[1]?.invalidatedPaths).toEqual(["src/watched.ts"]); - expect(service.health()).toMatchObject({ freshness: "current", invalidationReason: "file-change" }); + expect(builds.some((entry) => entry.invalidatedPaths.includes("src/watched.ts"))).toBe(true); + expect(service.health().freshness).toBe("current"); + expect(service.health().invalidationReason).toBe("file-change"); await service.stop(); }); @@ -103,6 +109,7 @@ describe("local context service", () => { const buildStarted = new Promise((resolve) => { started = resolve; }); const service = new LocalContextService({ rootDir, + acquireLock: false, build: ({ signal }) => new Promise((resolve) => { started?.(); signal.addEventListener("abort", () => resolve(graph("cancelled")), { once: true }); @@ -127,7 +134,7 @@ describe("local context service", () => { const rootDir = tempRoot(); write(rootDir, "src/owned.ts", "export const owned = true;\n"); write(rootDir, CONTEXT_SERVICE_STATE_PATH, "{not json"); - const service = new LocalContextService({ rootDir, build: () => graph("recovered") }); + const service = new LocalContextService({ rootDir, acquireLock: false, build: () => graph("recovered") }); expect(service.health().corruptedStateRecovered).toBe(true); await service.rebuild("recovery"); diff --git a/packages/knowledge/test/helpers/context-service-uat.ts b/packages/knowledge/test/helpers/context-service-uat.ts new file mode 100644 index 0000000..9112300 --- /dev/null +++ b/packages/knowledge/test/helpers/context-service-uat.ts @@ -0,0 +1,41 @@ +import { + CONTEXT_SERVICE_STATE_PATH, + acquireContextServiceLock, + createDefaultContextServiceBuild, + getOrCreateContextService, + LocalContextService +} from "../../src/index"; + +export { + CONTEXT_SERVICE_STATE_PATH, + acquireContextServiceLock, + createDefaultContextServiceBuild, + LocalContextService +}; + +/** In-process stand-in for MCP context_service tool used by UAT. */ +export async function runContextServiceToolLike( + rootDir: string, + input: { operation?: "health" | "query" | "rebuild" | "start"; sessionId?: string; task?: string } +): Promise { + const operation = input.operation ?? "health"; + const service = getOrCreateContextService(rootDir, { acquireLock: false }); + if (service.health().cacheGeneration === 0) { + await service.rebuild("initial"); + } + if (operation === "rebuild") { + await service.rebuild("manual-rebuild"); + } + if (operation === "query") { + return JSON.stringify( + await service.query({ + sessionId: input.sessionId, + task: input.task, + waitBudgetMs: 100 + }), + null, + 2 + ); + } + return JSON.stringify(service.health(), null, 2); +} diff --git a/packages/mcp/package.json b/packages/mcp/package.json index f3950ac..9f36396 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -20,6 +20,7 @@ "@submuxhq/codedecay-core": "workspace:*", "@submuxhq/codedecay-git": "workspace:*", "@submuxhq/codedecay-harness": "workspace:*", + "@submuxhq/codedecay-knowledge": "workspace:*", "@submuxhq/codedecay-llm": "workspace:*", "@submuxhq/codedecay-memory": "workspace:*", "@submuxhq/codedecay-redteam": "workspace:*", diff --git a/packages/mcp/src/handlers/context-service.ts b/packages/mcp/src/handlers/context-service.ts new file mode 100644 index 0000000..e8bed29 --- /dev/null +++ b/packages/mcp/src/handlers/context-service.ts @@ -0,0 +1,52 @@ +import { resolve } from "node:path"; +import { + getOrCreateContextService, + startContextService, + writeContextServiceMarker +} from "@submuxhq/codedecay-knowledge"; +import type { StartMcpServerOptions } from "../server/types"; + +export interface ContextServiceToolInput { + cwd?: string | undefined; + sessionId?: string | undefined; + task?: string | undefined; + waitBudgetMs?: number | undefined; + operation?: "health" | "query" | "rebuild" | "start" | undefined; +} + +export async function runContextServiceTool( + options: StartMcpServerOptions, + input: ContextServiceToolInput +): Promise { + const rootDir = resolve(options.cwd ?? process.cwd(), input.cwd ?? "."); + const operation = input.operation ?? "health"; + + if (operation === "start") { + const service = await startContextService(rootDir); + writeContextServiceMarker(rootDir, service.health()); + return JSON.stringify({ status: "started", health: service.health() }, null, 2); + } + + const service = getOrCreateContextService(rootDir, { acquireLock: operation === "rebuild" }); + if (service.health().cacheGeneration === 0 && operation !== "rebuild") { + await service.rebuild("initial"); + } + + if (operation === "rebuild") { + await service.rebuild("manual-rebuild"); + } + + if (operation === "query") { + const result = await service.query({ + waitBudgetMs: input.waitBudgetMs ?? 250, + sessionId: input.sessionId, + task: input.task + }); + writeContextServiceMarker(rootDir, service.health()); + return JSON.stringify(result, null, 2); + } + + const health = service.health(); + writeContextServiceMarker(rootDir, health); + return JSON.stringify(health, null, 2); +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index e56c046..3970d73 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -27,6 +27,7 @@ import { runProductRerunTool, runProductRunTool } from "./handlers/product"; +import { runContextServiceTool } from "./handlers/context-service"; import type { StartMcpServerOptions } from "./server/types"; import { registerCodeDecayMcpTools } from "./tools/registry"; @@ -51,6 +52,7 @@ export { runWhatDidIMissTool } from "./handlers/analysis"; export { runExecuteConfiguredChecksTool } from "./handlers/execution"; +export { runContextServiceTool } from "./handlers/context-service"; export { runProductFailuresTool, runProductPlanTool, @@ -82,6 +84,7 @@ export function createCodeDecayMcpServer(options: StartMcpServerOptions): McpSer agentPreflight: (input) => runAgentPreflightTool(options, input), agentSession: (input) => runAgentSessionTool(options, input), taskContext: (input) => runTaskContextTool(options, input), + contextService: (input) => runContextServiceTool(options, input), agentInvestigation: (input) => runAgentInvestigationTool(options, input), scopeCheck: (input) => runScopeCheckTool(options, input), designContractCheck: (input) => runDesignContractCheckTool(options, input), diff --git a/packages/mcp/src/tools/register-analysis.ts b/packages/mcp/src/tools/register-analysis.ts index 8ccbc9a..d081f8e 100644 --- a/packages/mcp/src/tools/register-analysis.ts +++ b/packages/mcp/src/tools/register-analysis.ts @@ -11,7 +11,8 @@ import { fixTasksToolSchema, gitContextToolSchema, scopeCheckToolSchema, - taskContextToolSchema + taskContextToolSchema, + contextServiceToolSchema } from "./schemas"; import type { AgentPreflightToolInput, @@ -25,7 +26,8 @@ import type { RegressionSurfaceToolInput, ScopeCheckToolInput, TaskContextToolInput, - WhatDidIMissToolInput + WhatDidIMissToolInput, + ContextServiceToolInput } from "./types"; export function registerAnalysisMcpTools(server: McpServer, handlers: CodeDecayMcpToolHandlers): void { @@ -113,6 +115,13 @@ export function registerAnalysisMcpTools(server: McpServer, handlers: CodeDecayM async (input) => textResult(handlers.taskContext(input as TaskContextToolInput)) ); + server.tool( + "context_service", + "Query the local incremental context service (health/query/rebuild/start). Local-only; no model, network, telemetry, or project-command calls.", + contextServiceToolSchema, + async (input) => textResult(handlers.contextService(input as ContextServiceToolInput)) + ); + server.tool( "scope_check", "Return a deterministic in-scope/out-of-scope verdict for the current PR or working tree.", diff --git a/packages/mcp/src/tools/registry.ts b/packages/mcp/src/tools/registry.ts index fb2ce8c..e056463 100644 --- a/packages/mcp/src/tools/registry.ts +++ b/packages/mcp/src/tools/registry.ts @@ -18,7 +18,8 @@ import type { RegressionSurfaceToolInput, ScopeCheckToolInput, TaskContextToolInput, - WhatDidIMissToolInput + WhatDidIMissToolInput, + ContextServiceToolInput } from "./types"; export interface CodeDecayMcpToolHandlers { @@ -33,6 +34,7 @@ export interface CodeDecayMcpToolHandlers { agentPreflight(input: AgentPreflightToolInput): string | Promise; agentSession(input: AgentSessionToolInput): string | Promise; taskContext(input: TaskContextToolInput): string | Promise; + contextService(input: ContextServiceToolInput): string | Promise; agentInvestigation(input: AgentInvestigationToolInput): string | Promise; scopeCheck(input: ScopeCheckToolInput): string | Promise; designContractCheck(input: DesignContractCheckToolInput): string | Promise; diff --git a/packages/mcp/src/tools/schemas.ts b/packages/mcp/src/tools/schemas.ts index 5a9bff0..6ecfe95 100644 --- a/packages/mcp/src/tools/schemas.ts +++ b/packages/mcp/src/tools/schemas.ts @@ -104,6 +104,17 @@ export const taskContextToolSchema = { maxNodes: z.number().int().positive().optional().describe("Maximum selected context nodes.") }; +export const contextServiceToolSchema = { + cwd: cwdSchema, + operation: z + .enum(["health", "query", "rebuild", "start"]) + .optional() + .describe("Local context service operation. Defaults to health."), + sessionId: z.string().optional().describe("Optional agent session id for isolated task state."), + task: z.string().optional().describe("Optional task label stored per session."), + waitBudgetMs: z.number().int().nonnegative().optional().describe("Max wait for an in-flight index update.") +}; + export const agentSessionToolSchema = { cwd: cwdSchema, operation: z.enum(["start", "context", "checkpoint", "finish"]).describe("Session lifecycle operation."), diff --git a/packages/mcp/src/tools/types.ts b/packages/mcp/src/tools/types.ts index 921937f..33b5bbf 100644 --- a/packages/mcp/src/tools/types.ts +++ b/packages/mcp/src/tools/types.ts @@ -39,6 +39,14 @@ export interface TaskContextToolInput extends McpToolInput { maxNodes?: number | undefined; } +export interface ContextServiceToolInput { + cwd?: string | undefined; + operation?: "health" | "query" | "rebuild" | "start" | undefined; + sessionId?: string | undefined; + task?: string | undefined; + waitBudgetMs?: number | undefined; +} + export interface AgentSessionToolInput { cwd?: string | undefined; operation: "start" | "context" | "checkpoint" | "finish"; diff --git a/packages/mcp/test/mcp-context-service.test.ts b/packages/mcp/test/mcp-context-service.test.ts new file mode 100644 index 0000000..f97b4ff --- /dev/null +++ b/packages/mcp/test/mcp-context-service.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createCodeDecayMcpServer, runContextServiceTool } from "../src/index"; +import { createRepo, createTempDir } from "./helpers/mcp"; + +describe("MCP context_service tool", () => { + it("registers context_service on the MCP server", () => { + const server = createCodeDecayMcpServer({ cwd: createTempDir() }); + expect(server).toBeTruthy(); + }); + + it("returns shared health/query decisions for a local repo", async () => { + const repo = createRepo({ + "src/index.ts": "export const ok = true;\n", + "README.md": "# fixture\n" + }); + const health = JSON.parse(await runContextServiceTool({ cwd: repo }, { operation: "health" })) as { + repositoryId: string; + freshness: string; + }; + const query = JSON.parse( + await runContextServiceTool( + { cwd: repo }, + { operation: "query", sessionId: "mcp-a", task: "index check", waitBudgetMs: 50 } + ) + ) as { repositoryId: string; task?: string; freshness: string }; + + expect(health.repositoryId).toBe(query.repositoryId); + expect(query.task).toBe("index check"); + expect(["current", "refreshing", "stale"]).toContain(query.freshness); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b806c50..fc753fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,9 @@ importers: '@submuxhq/codedecay-harness': specifier: workspace:* version: link:../harness + '@submuxhq/codedecay-knowledge': + specifier: workspace:* + version: link:../knowledge '@submuxhq/codedecay-llm': specifier: workspace:* version: link:../llm