Skip to content

Commit 2604a68

Browse files
committed
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.
1 parent de6100f commit 2604a68

23 files changed

Lines changed: 1048 additions & 31 deletions

docs/context-service.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,32 @@ The local context service is an incremental, repository-scoped wrapper around Co
1010

1111
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.
1212

13-
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.
13+
The initial implementation uses Chokidar for watching, inspectable JSON graph
14+
artifacts, process locking via `.codedecay/local/context-service.lock`, and
15+
atomic state writes to `.codedecay/local/context-service.json`.
16+
17+
## CLI
18+
19+
```bash
20+
codedecay context serve --format json
21+
codedecay context health --format json
22+
codedecay context query --session-id agent-a --task "fix payouts" --format json
23+
codedecay context rebuild --format json
24+
codedecay context reset --format json
25+
codedecay context stop
26+
```
27+
28+
`serve` is local-only (no network bind by default). It watches the repo,
29+
coalesces invalidations, and updates only invalidated path-linked nodes for
30+
ordinary file edits. Git HEAD/index changes force a full rebuild.
31+
32+
## MCP
33+
34+
Tool: `context_service` with `operation=health|query|rebuild|start`.
35+
36+
## Remaining scale work
37+
38+
Benchmark fixtures and measured 1M+ LOC results remain recommended before
39+
claiming large-monorepo scale. Documented path to 20M LOC: shard by package
40+
workspace, persist per-package incremental graphs, and keep the current
41+
service as the orchestration layer.

packages/cli/src/commands/context.ts

Lines changed: 123 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import {
66
createEngineeringTaskContext,
77
loadImpactGraphArtifact,
88
persistEngineeringTaskContext,
9-
renderEngineeringTaskContextMarkdown
9+
renderEngineeringTaskContextMarkdown,
10+
startContextService,
11+
stopContextService,
12+
getOrCreateContextService,
13+
writeContextServiceMarker
1014
} from "@submuxhq/codedecay-knowledge";
1115
import { parseContextArgs } from "../parsers/args";
1216
import { loadNormalizedRequirementContext } from "../requirements/context";
@@ -23,14 +27,19 @@ export interface RunContextCommandDependencies {
2327
}): void;
2428
}
2529

26-
export function runContextCommand(
30+
export async function runContextCommand(
2731
context: CliCommandContext,
2832
dependencies: RunContextCommandDependencies
29-
): void {
33+
): Promise<void> {
3034
const options = parseContextArgs(context.args);
35+
if (options.serviceAction) {
36+
await runContextServiceCommand(context, dependencies, options);
37+
return;
38+
}
39+
3140
const task = options.task?.trim();
3241
if (!task) {
33-
throw new Error("context requires --task <description>.");
42+
throw new Error('context requires --task <description>, or a service subcommand such as "serve".');
3443
}
3544

3645
const cwd = resolve(context.runtimeCwd, options.cwd ?? ".");
@@ -71,3 +80,113 @@ export function runContextCommand(
7180
runtime: context.runtime
7281
});
7382
}
83+
84+
async function runContextServiceCommand(
85+
context: CliCommandContext,
86+
dependencies: RunContextCommandDependencies,
87+
options: ContextOptions
88+
): Promise<void> {
89+
const cwd = resolve(context.runtimeCwd, options.cwd ?? ".");
90+
const rootDir = dependencies.resolveRepoRoot(cwd, options);
91+
const action = options.serviceAction!;
92+
93+
if (action === "serve") {
94+
const service = await startContextService(rootDir);
95+
const health = service.health();
96+
writeContextServiceMarker(rootDir, health);
97+
dependencies.writeOutput({
98+
cwd,
99+
output: options.output,
100+
rendered:
101+
options.format === "json"
102+
? `${JSON.stringify({ status: "started", health }, null, 2)}\n`
103+
: renderServiceMarkdown("started", health),
104+
runtime: context.runtime
105+
});
106+
// Long-lived local serve: keep the process until interrupt.
107+
await new Promise<void>((resolvePromise) => {
108+
const stop = async () => {
109+
await stopContextService(rootDir);
110+
resolvePromise();
111+
};
112+
process.once("SIGINT", () => void stop());
113+
process.once("SIGTERM", () => void stop());
114+
});
115+
return;
116+
}
117+
118+
if (action === "stop") {
119+
await stopContextService(rootDir);
120+
dependencies.writeOutput({
121+
cwd,
122+
output: options.output,
123+
rendered: options.format === "json" ? `${JSON.stringify({ status: "stopped" }, null, 2)}\n` : "## Context Service\n\nStopped.\n",
124+
runtime: context.runtime
125+
});
126+
return;
127+
}
128+
129+
const service = getOrCreateContextService(rootDir, { acquireLock: action === "rebuild" || action === "reset" });
130+
if (action === "rebuild") {
131+
await service.rebuild("manual-rebuild");
132+
} else if (action === "reset") {
133+
await service.reset();
134+
} else if (action === "query") {
135+
if (service.health().cacheGeneration === 0) {
136+
await service.rebuild("initial");
137+
}
138+
const result = await service.query({
139+
waitBudgetMs: options.waitBudgetMs ?? 250,
140+
sessionId: options.sessionId,
141+
task: options.task
142+
});
143+
writeContextServiceMarker(rootDir, service.health());
144+
dependencies.writeOutput({
145+
cwd,
146+
output: options.output,
147+
rendered: `${JSON.stringify(result, null, 2)}\n`,
148+
runtime: context.runtime
149+
});
150+
return;
151+
}
152+
153+
if (service.health().cacheGeneration === 0 && action === "health") {
154+
await service.rebuild("initial");
155+
}
156+
const health = service.health();
157+
writeContextServiceMarker(rootDir, health);
158+
dependencies.writeOutput({
159+
cwd,
160+
output: options.output,
161+
rendered: options.format === "json" ? `${JSON.stringify(health, null, 2)}\n` : renderServiceMarkdown(action, health),
162+
runtime: context.runtime
163+
});
164+
}
165+
166+
function renderServiceMarkdown(action: string, health: {
167+
repositoryId: string;
168+
freshness: string;
169+
treeFingerprint: string;
170+
cacheGeneration: number;
171+
indexedRevision: string;
172+
lastBuild?: { mode: string; durationMs: number } | undefined;
173+
activeSessions: number;
174+
}): string {
175+
return [
176+
"## CodeDecay Context Service",
177+
"",
178+
`**Action:** ${action}`,
179+
`**Repository:** \`${health.repositoryId}\``,
180+
`**Freshness:** ${health.freshness}`,
181+
`**Revision:** \`${health.indexedRevision}\``,
182+
`**Tree fingerprint:** \`${health.treeFingerprint}\``,
183+
`**Cache generation:** ${health.cacheGeneration}`,
184+
`**Active sessions:** ${health.activeSessions}`,
185+
health.lastBuild
186+
? `**Last build:** ${health.lastBuild.mode} (${health.lastBuild.durationMs}ms)`
187+
: "**Last build:** none",
188+
"",
189+
"Local-only. No model, network, telemetry, install, or project-command calls.",
190+
""
191+
].join("\n");
192+
}

packages/cli/src/docs/command-docs/orchestration.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,20 @@ export const ORCHESTRATION_COMMAND_DOCS: Record<string, CommandDoc> = {
4242
},
4343
context: {
4444
name: "context",
45-
summary: "Retrieve bounded task-scoped engineering context for user-owned agents.",
46-
usage: ["codedecay context --task <description> [options]"],
45+
summary: "Retrieve bounded task-scoped engineering context or run the local incremental context service.",
46+
usage: [
47+
"codedecay context --task <description> [options]",
48+
"codedecay context serve|health|query|rebuild|reset|stop [options]"
49+
],
4750
description: [
4851
"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.",
49-
"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."
52+
"Service subcommands start/query a long-lived local incremental context index (no network bind, no model/network/telemetry calls)."
5053
],
5154
options: [
52-
{ flag: "--task <text>", description: "Required task/change description used for deterministic retrieval" },
55+
{ flag: "--task <text>", description: "Required for one-shot retrieval; optional for service query sessions" },
56+
{ flag: "serve|health|query|rebuild|reset|stop", description: "Local context service operations" },
57+
{ flag: "--session-id <id>", description: "Isolate task state for concurrent agent sessions over a shared index" },
58+
{ flag: "--wait-budget-ms <n>", description: "Max wait for an in-flight index update during query" },
5359
{ flag: "--requirements <path>", description: "Optional repo-local JSON, YAML, or Markdown requirements artifact" },
5460
{ flag: "--base <ref>", description: "Base git ref to compare from when a diff should influence context" },
5561
{ flag: "--head <ref>", description: "Head git ref to compare to when a diff should influence context" },
@@ -60,13 +66,13 @@ export const ORCHESTRATION_COMMAND_DOCS: Record<string, CommandDoc> = {
6066
],
6167
examples: [
6268
"codedecay context --task \"Allow finance admins to retry failed payouts\" --format markdown",
63-
"codedecay context --task \"Change payout retry formatting\" --base main --head HEAD --format json",
64-
"codedecay context --task \"Add billing export\" --requirements .codedecay/requirements.yml --max-nodes 16"
69+
"codedecay context serve --format json",
70+
"codedecay context query --session-id agent-a --task \"fix payouts\" --format json",
71+
"codedecay context health --format json"
6572
],
6673
notes: [
6774
"Context retrieval is deterministic lexical plus graph-neighbor ranking. It does not call models, embeddings, hosted services, network APIs, or telemetry.",
68-
"The command refreshes local analysis artifacts but does not execute configured project commands or tool adapters.",
69-
"Memory and documents are shown with trust class and limitations; they cannot become trusted proof without current-revision evidence.",
75+
"The local context service reuses knowledge-graph artifacts, process-locks updates, and never returns stale evidence labeled as current.",
7076
"The inspectable artifact is written to `.codedecay/local/task-context.json`."
7177
]
7278
},

packages/cli/src/parsers/context.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,21 @@ export function parseContextArgs(args: string[]): ContextOptions {
77
format: "markdown"
88
};
99
const valueParsers = createContextValueParsers(options);
10+
let index = 0;
11+
const first = args[0];
12+
if (
13+
first === "serve" ||
14+
first === "health" ||
15+
first === "query" ||
16+
first === "rebuild" ||
17+
first === "reset" ||
18+
first === "stop"
19+
) {
20+
options.serviceAction = first;
21+
index = 1;
22+
}
1023

11-
for (let index = 0; index < args.length; index += 1) {
24+
for (; index < args.length; index += 1) {
1225
const arg = args[index];
1326

1427
if (!arg) {
@@ -62,6 +75,12 @@ function createContextValueParsers(options: ContextOptions): Record<string, (val
6275
},
6376
"--task": (value) => {
6477
options.task = value;
78+
},
79+
"--session-id": (value) => {
80+
options.sessionId = value;
81+
},
82+
"--wait-budget-ms": (value) => {
83+
options.waitBudgetMs = parsePositiveInteger(value, "--wait-budget-ms");
6584
}
6685
};
6786
}

packages/cli/src/types/context.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,7 @@ export interface ContextOptions {
99
task?: string | undefined;
1010
requirements?: string | undefined;
1111
maxNodes?: number | undefined;
12+
serviceAction?: "serve" | "health" | "query" | "rebuild" | "reset" | "stop" | undefined;
13+
sessionId?: string | undefined;
14+
waitBudgetMs?: number | undefined;
1215
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, expect, it } from "vitest";
2+
import { parseContextArgs } from "../src/parsers/context";
3+
4+
describe("parseContextArgs service subcommands", () => {
5+
it("parses serve/health/query with session options", () => {
6+
expect(parseContextArgs(["serve", "--format", "json"])).toMatchObject({
7+
serviceAction: "serve",
8+
format: "json"
9+
});
10+
expect(
11+
parseContextArgs(["query", "--session-id", "agent-a", "--task", "fix payouts", "--wait-budget-ms", "100"])
12+
).toMatchObject({
13+
serviceAction: "query",
14+
sessionId: "agent-a",
15+
task: "fix payouts",
16+
waitBudgetMs: 100
17+
});
18+
expect(parseContextArgs(["health"])).toMatchObject({ serviceAction: "health", format: "markdown" });
19+
});
20+
});

packages/knowledge/src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ export {
3030
CONTEXT_SERVICE_STATE_PATH,
3131
LocalContextService
3232
} from "./service";
33+
export { CONTEXT_SERVICE_LOCK_PATH, acquireContextServiceLock } from "./service-lock";
34+
export { createDefaultContextServiceBuild } from "./service-build";
35+
export type { ContextServiceBuildMode, ContextServiceBuildStats, DefaultContextServiceBuild } from "./service-build";
36+
export type { ContextServiceLockHandle } from "./service-lock";
37+
export {
38+
clearContextServiceMarker,
39+
getContextService,
40+
getOrCreateContextService,
41+
readContextServiceMarker,
42+
startContextService,
43+
stopContextService,
44+
writeContextServiceMarker
45+
} from "./service-runtime";
3346
export {
3447
SERVICE_TOPOLOGY_ARTIFACT_PATH,
3548
loadServiceTopologyManifest,

0 commit comments

Comments
 (0)