@flyingrobots/graft 0.3.5 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +386 -0
- package/CHANGELOG.md +69 -0
- package/CODE_OF_CONDUCT.md +65 -0
- package/README.md +153 -17
- package/bin/graft.js +4 -11
- package/docs/ADVANCED_GUIDE.md +49 -0
- package/docs/CLI.md +43 -0
- package/docs/GUIDE.md +321 -32
- package/docs/MCP.md +44 -0
- package/package.json +17 -4
- package/src/adapters/node-fs.ts +4 -0
- package/src/adapters/node-git.ts +47 -0
- package/src/adapters/node-process-runner.ts +27 -0
- package/src/cli/index-cmd.ts +86 -0
- package/src/cli/init.ts +808 -57
- package/src/cli/main.ts +437 -0
- package/src/contracts/capabilities.ts +341 -0
- package/src/contracts/causal-ontology.ts +622 -0
- package/src/contracts/causal-surface-next-action.ts +18 -0
- package/src/contracts/output-schemas.ts +1169 -0
- package/src/git/diff.ts +25 -21
- package/src/git/target-git-hook-bootstrap.ts +56 -0
- package/src/hooks/posttooluse-read.ts +21 -74
- package/src/hooks/pretooluse-read.ts +20 -56
- package/src/hooks/read-governor.ts +95 -0
- package/src/hooks/read-messages.ts +53 -0
- package/src/mcp/burden.ts +123 -0
- package/src/mcp/cache.ts +51 -0
- package/src/mcp/cached-file.ts +10 -8
- package/src/mcp/context.ts +67 -2
- package/src/mcp/daemon-control-plane.ts +554 -0
- package/src/mcp/daemon-job-scheduler.ts +279 -0
- package/src/mcp/daemon-repos.ts +216 -0
- package/src/mcp/daemon-server.ts +396 -0
- package/src/mcp/daemon-worker-pool.ts +310 -0
- package/src/mcp/daemon-worker-process.ts +52 -0
- package/src/mcp/metrics.ts +108 -1
- package/src/mcp/monitor-tick-job.ts +99 -0
- package/src/mcp/persisted-local-history.ts +1246 -0
- package/src/mcp/persistent-monitor-runtime.ts +549 -0
- package/src/mcp/policy.ts +84 -0
- package/src/mcp/receipt.ts +82 -12
- package/src/mcp/repo-concurrency.ts +318 -0
- package/src/mcp/repo-state.ts +777 -0
- package/src/mcp/repo-tool-job.ts +302 -0
- package/src/mcp/run-capture-config.ts +33 -0
- package/src/mcp/runtime-causal-context.ts +72 -0
- package/src/mcp/runtime-observability.ts +219 -0
- package/src/mcp/runtime-staged-target.ts +161 -0
- package/src/mcp/runtime-workspace-overlay.ts +255 -0
- package/src/mcp/semantic-transition-guidance.ts +60 -0
- package/src/mcp/semantic-transition-summary.ts +130 -0
- package/src/mcp/server.ts +704 -45
- package/src/mcp/stdio-server.ts +12 -0
- package/src/mcp/stdio.ts +2 -5
- package/src/mcp/tools/activity-view.ts +325 -0
- package/src/mcp/tools/causal-attach.ts +67 -0
- package/src/mcp/tools/causal-status.ts +58 -0
- package/src/mcp/tools/changed-since.ts +13 -11
- package/src/mcp/tools/code-find.ts +164 -0
- package/src/mcp/tools/code-refs.ts +466 -0
- package/src/mcp/tools/code-show.ts +252 -0
- package/src/mcp/tools/daemon-monitors.ts +14 -0
- package/src/mcp/tools/daemon-repos.ts +22 -0
- package/src/mcp/tools/daemon-sessions.ts +14 -0
- package/src/mcp/tools/daemon-status.ts +12 -0
- package/src/mcp/tools/doctor.ts +45 -2
- package/src/mcp/tools/explain.ts +4 -0
- package/src/mcp/tools/file-outline.ts +7 -3
- package/src/mcp/tools/git-files.ts +73 -0
- package/src/mcp/tools/graft-diff.ts +12 -4
- package/src/mcp/tools/map.ts +136 -0
- package/src/mcp/tools/monitor-pause.ts +18 -0
- package/src/mcp/tools/monitor-resume.ts +18 -0
- package/src/mcp/tools/monitor-start.ts +20 -0
- package/src/mcp/tools/monitor-stop.ts +18 -0
- package/src/mcp/tools/precision-match.ts +51 -0
- package/src/mcp/tools/precision-query.ts +127 -0
- package/src/mcp/tools/precision.ts +312 -0
- package/src/mcp/tools/run-capture.ts +126 -44
- package/src/mcp/tools/safe-read.ts +14 -12
- package/src/mcp/tools/since.ts +49 -0
- package/src/mcp/tools/state.ts +11 -3
- package/src/mcp/tools/stats.ts +5 -1
- package/src/mcp/tools/workspace-authorizations.ts +14 -0
- package/src/mcp/tools/workspace-authorize.ts +20 -0
- package/src/mcp/tools/workspace-bind.ts +25 -0
- package/src/mcp/tools/workspace-rebind.ts +25 -0
- package/src/mcp/tools/workspace-revoke.ts +18 -0
- package/src/mcp/tools/workspace-status.ts +12 -0
- package/src/mcp/warp-pool.ts +36 -0
- package/src/mcp/workspace-router.ts +984 -0
- package/src/operations/file-outline.ts +12 -2
- package/src/operations/graft-diff.ts +56 -10
- package/src/operations/safe-read.ts +27 -4
- package/src/operations/state.ts +6 -9
- package/src/parser/lang.ts +19 -3
- package/src/parser/outline.ts +191 -2
- package/src/parser/types.ts +9 -1
- package/src/policy/types.ts +4 -3
- package/src/ports/filesystem.ts +1 -0
- package/src/ports/git.ts +16 -0
- package/src/ports/process-runner.ts +22 -0
- package/src/release/security-gate.ts +102 -0
- package/src/session/tracker.ts +31 -0
- package/src/version.ts +3 -0
- package/src/warp/indexer.ts +513 -0
- package/src/warp/observers.ts +105 -0
- package/src/warp/open.ts +31 -0
- package/src/warp/plumbing.d.ts +15 -0
- package/src/warp/writer-id.ts +30 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CanonicalJsonCodec } from "../adapters/canonical-json.js";
|
|
3
|
+
import { nodeFs } from "../adapters/node-fs.js";
|
|
4
|
+
import { nodeGit } from "../adapters/node-git.js";
|
|
5
|
+
import { nodeProcessRunner } from "../adapters/node-process-runner.js";
|
|
6
|
+
import { openWarp } from "../warp/open.js";
|
|
7
|
+
import { SessionTracker, type SessionTrackerSnapshot } from "../session/tracker.js";
|
|
8
|
+
import { buildReceiptResult, type McpToolReceipt, type McpToolResult } from "./receipt.js";
|
|
9
|
+
import { createPathResolver, type ToolContext, type ToolDefinition, type ToolHandler } from "./context.js";
|
|
10
|
+
import { resolveRunCaptureConfig } from "./run-capture-config.js";
|
|
11
|
+
import { resolveRuntimeObservabilityState } from "./runtime-observability.js";
|
|
12
|
+
import { Metrics, diffMetrics, type MetricsDelta, type MetricsSnapshot } from "./metrics.js";
|
|
13
|
+
import { ObservationCache, type ObservationSnapshot } from "./cache.js";
|
|
14
|
+
import type { RepoObservation } from "./repo-state.js";
|
|
15
|
+
import type { WorkspaceCapabilityProfile, WorkspaceStatus } from "./workspace-router.js";
|
|
16
|
+
import { evaluateMcpPolicy } from "./policy.js";
|
|
17
|
+
import { RefusedResult } from "../policy/types.js";
|
|
18
|
+
import { graftDiffTool } from "./tools/graft-diff.js";
|
|
19
|
+
import { sinceTool } from "./tools/since.js";
|
|
20
|
+
import { mapTool } from "./tools/map.js";
|
|
21
|
+
import { codeRefsTool } from "./tools/code-refs.js";
|
|
22
|
+
import { codeFindTool, runCodeFind } from "./tools/code-find.js";
|
|
23
|
+
import { codeShowTool, runCodeShow } from "./tools/code-show.js";
|
|
24
|
+
import { safeReadTool } from "./tools/safe-read.js";
|
|
25
|
+
import { fileOutlineTool } from "./tools/file-outline.js";
|
|
26
|
+
import { changedSinceTool } from "./tools/changed-since.js";
|
|
27
|
+
import { buildRuntimeCausalContext } from "./runtime-causal-context.js";
|
|
28
|
+
|
|
29
|
+
const codeFindLiveWorkerTool: ToolDefinition = {
|
|
30
|
+
...codeFindTool,
|
|
31
|
+
createHandler(ctx: ToolContext): ToolHandler {
|
|
32
|
+
return (args) => runCodeFind(ctx, args, { allowWarp: false });
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const codeShowLiveWorkerTool: ToolDefinition = {
|
|
37
|
+
...codeShowTool,
|
|
38
|
+
createHandler(ctx: ToolContext): ToolHandler {
|
|
39
|
+
return (args) => runCodeShow(ctx, args, { allowWarp: false });
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const OFFLOADED_REPO_TOOL_DEFINITIONS = Object.freeze({
|
|
44
|
+
safe_read: safeReadTool,
|
|
45
|
+
file_outline: fileOutlineTool,
|
|
46
|
+
changed_since: changedSinceTool,
|
|
47
|
+
graft_diff: graftDiffTool,
|
|
48
|
+
graft_since: sinceTool,
|
|
49
|
+
graft_map: mapTool,
|
|
50
|
+
code_refs: codeRefsTool,
|
|
51
|
+
code_find_live: codeFindLiveWorkerTool,
|
|
52
|
+
code_show_live: codeShowLiveWorkerTool,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export type OffloadedRepoToolName = keyof typeof OFFLOADED_REPO_TOOL_DEFINITIONS;
|
|
56
|
+
|
|
57
|
+
export const OFFLOADED_DAEMON_REPO_TOOL_NAMES = Object.freeze(
|
|
58
|
+
Object.keys(OFFLOADED_REPO_TOOL_DEFINITIONS) as OffloadedRepoToolName[],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
export interface RepoToolWorkerJob {
|
|
62
|
+
readonly sessionId: string;
|
|
63
|
+
readonly workspaceSliceId: string;
|
|
64
|
+
readonly traceId: string;
|
|
65
|
+
readonly seq: number;
|
|
66
|
+
readonly startedAtMs: number;
|
|
67
|
+
readonly tool: OffloadedRepoToolName;
|
|
68
|
+
readonly args: Record<string, unknown>;
|
|
69
|
+
readonly projectRoot: string;
|
|
70
|
+
readonly graftDir: string;
|
|
71
|
+
readonly graftignorePatterns: readonly string[];
|
|
72
|
+
readonly repoId: string;
|
|
73
|
+
readonly worktreeId: string;
|
|
74
|
+
readonly gitCommonDir: string;
|
|
75
|
+
readonly writerId: string;
|
|
76
|
+
readonly capabilityProfile: WorkspaceCapabilityProfile;
|
|
77
|
+
readonly repoState: RepoObservation;
|
|
78
|
+
readonly sessionSnapshot: SessionTrackerSnapshot;
|
|
79
|
+
readonly metricsSnapshot: MetricsSnapshot;
|
|
80
|
+
readonly cacheSnapshots?: Readonly<Record<string, ObservationSnapshot>>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface RepoToolWorkerResult {
|
|
84
|
+
readonly result: McpToolResult;
|
|
85
|
+
readonly textBytes: number;
|
|
86
|
+
readonly receipt: McpToolReceipt;
|
|
87
|
+
readonly tripwireSignals: readonly string[];
|
|
88
|
+
readonly metricsDelta: MetricsDelta;
|
|
89
|
+
readonly cacheUpdates: readonly {
|
|
90
|
+
path: string;
|
|
91
|
+
observation: ObservationSnapshot | null;
|
|
92
|
+
}[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function unsupported(name: string): never {
|
|
96
|
+
throw new Error(`${name} is not available in repo-tool worker mode`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function wrapWithPolicyCheck(toolName: ToolDefinition["name"], inner: ToolHandler, ctx: ToolContext): ToolHandler {
|
|
100
|
+
return async (args: Record<string, unknown>) => {
|
|
101
|
+
const rawPath = args["path"];
|
|
102
|
+
if (typeof rawPath !== "string") return inner(args);
|
|
103
|
+
|
|
104
|
+
const filePath = ctx.resolvePath(rawPath);
|
|
105
|
+
let content: string;
|
|
106
|
+
try {
|
|
107
|
+
content = await ctx.fs.readFile(filePath, "utf-8");
|
|
108
|
+
} catch {
|
|
109
|
+
return inner(args);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const actual = { lines: content.split("\n").length, bytes: Buffer.byteLength(content) };
|
|
113
|
+
const policy = evaluateMcpPolicy(ctx, filePath, actual);
|
|
114
|
+
if (policy instanceof RefusedResult) {
|
|
115
|
+
ctx.metrics.recordRefusal();
|
|
116
|
+
return ctx.respond(toolName, {
|
|
117
|
+
path: filePath,
|
|
118
|
+
projection: "refused",
|
|
119
|
+
reason: policy.reason,
|
|
120
|
+
reasonDetail: policy.reasonDetail,
|
|
121
|
+
next: [...policy.next],
|
|
122
|
+
actual,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return inner(args);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function workerStatus(job: RepoToolWorkerJob): WorkspaceStatus {
|
|
130
|
+
return {
|
|
131
|
+
sessionMode: "daemon",
|
|
132
|
+
bindState: "bound",
|
|
133
|
+
repoId: job.repoId,
|
|
134
|
+
worktreeId: job.worktreeId,
|
|
135
|
+
worktreeRoot: job.projectRoot,
|
|
136
|
+
gitCommonDir: job.gitCommonDir,
|
|
137
|
+
graftDir: job.graftDir,
|
|
138
|
+
capabilityProfile: job.capabilityProfile,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildWorkerContext(
|
|
143
|
+
job: RepoToolWorkerJob,
|
|
144
|
+
): { ctx: ToolContext; metrics: Metrics; session: SessionTracker; takeResponse: () => RepoToolWorkerResult } {
|
|
145
|
+
const codec = new CanonicalJsonCodec();
|
|
146
|
+
const session = SessionTracker.fromSnapshot(job.sessionSnapshot);
|
|
147
|
+
const metrics = Metrics.fromSnapshot(job.metricsSnapshot);
|
|
148
|
+
const cache = ObservationCache.fromSnapshots(job.cacheSnapshots);
|
|
149
|
+
const trackedCachePaths = new Set<string>(Object.keys(job.cacheSnapshots ?? {}));
|
|
150
|
+
if (typeof job.args["path"] === "string") {
|
|
151
|
+
trackedCachePaths.add(createPathResolver(job.projectRoot)(job.args["path"]));
|
|
152
|
+
}
|
|
153
|
+
let response: Omit<RepoToolWorkerResult, "metricsDelta" | "cacheUpdates"> | null = null;
|
|
154
|
+
|
|
155
|
+
const ctx: ToolContext = {
|
|
156
|
+
projectRoot: job.projectRoot,
|
|
157
|
+
graftDir: job.graftDir,
|
|
158
|
+
graftignorePatterns: job.graftignorePatterns,
|
|
159
|
+
session,
|
|
160
|
+
cache,
|
|
161
|
+
metrics,
|
|
162
|
+
fs: nodeFs,
|
|
163
|
+
codec,
|
|
164
|
+
process: nodeProcessRunner,
|
|
165
|
+
git: nodeGit,
|
|
166
|
+
runCapture: resolveRunCaptureConfig({}),
|
|
167
|
+
observability: resolveRuntimeObservabilityState({ graftDir: job.graftDir }),
|
|
168
|
+
respond(tool, data) {
|
|
169
|
+
const tripwires = session.checkTripwires();
|
|
170
|
+
const built = buildReceiptResult(tool, data, {
|
|
171
|
+
sessionId: job.sessionId,
|
|
172
|
+
traceId: job.traceId,
|
|
173
|
+
seq: job.seq,
|
|
174
|
+
latencyMs: Date.now() - job.startedAtMs,
|
|
175
|
+
codec,
|
|
176
|
+
metrics: metrics.snapshot(),
|
|
177
|
+
tripwires,
|
|
178
|
+
budget: session.getBudget(),
|
|
179
|
+
});
|
|
180
|
+
response = {
|
|
181
|
+
result: built.result,
|
|
182
|
+
textBytes: built.textBytes,
|
|
183
|
+
receipt: built.receipt,
|
|
184
|
+
tripwireSignals: tripwires.map((wire) => wire.signal),
|
|
185
|
+
};
|
|
186
|
+
return built.result;
|
|
187
|
+
},
|
|
188
|
+
resolvePath: createPathResolver(job.projectRoot),
|
|
189
|
+
getWarp() {
|
|
190
|
+
return openWarp({ cwd: job.projectRoot, writerId: job.writerId });
|
|
191
|
+
},
|
|
192
|
+
getRepoState() {
|
|
193
|
+
return job.repoState;
|
|
194
|
+
},
|
|
195
|
+
getCausalContext() {
|
|
196
|
+
return buildRuntimeCausalContext({
|
|
197
|
+
transportSessionId: job.sessionId,
|
|
198
|
+
workspaceSliceId: job.workspaceSliceId,
|
|
199
|
+
repoId: job.repoId,
|
|
200
|
+
worktreeId: job.worktreeId,
|
|
201
|
+
checkoutEpoch: job.repoState.checkoutEpoch,
|
|
202
|
+
warpWriterId: job.writerId,
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
getPersistedLocalHistorySummary() {
|
|
206
|
+
return unsupported("getPersistedLocalHistorySummary");
|
|
207
|
+
},
|
|
208
|
+
getPersistedLocalActivityWindow() {
|
|
209
|
+
return unsupported("getPersistedLocalActivityWindow");
|
|
210
|
+
},
|
|
211
|
+
getRepoConcurrencySummary() {
|
|
212
|
+
return Promise.resolve(null);
|
|
213
|
+
},
|
|
214
|
+
getWorkspaceOverlayFooting() {
|
|
215
|
+
return unsupported("getWorkspaceOverlayFooting");
|
|
216
|
+
},
|
|
217
|
+
declareCausalAttach() {
|
|
218
|
+
return unsupported("declareCausalAttach");
|
|
219
|
+
},
|
|
220
|
+
getWorkspaceStatus() {
|
|
221
|
+
return workerStatus(job);
|
|
222
|
+
},
|
|
223
|
+
bindWorkspace() {
|
|
224
|
+
return unsupported("bindWorkspace");
|
|
225
|
+
},
|
|
226
|
+
rebindWorkspace() {
|
|
227
|
+
return unsupported("rebindWorkspace");
|
|
228
|
+
},
|
|
229
|
+
getDaemonStatus() {
|
|
230
|
+
return unsupported("getDaemonStatus");
|
|
231
|
+
},
|
|
232
|
+
listDaemonRepos() {
|
|
233
|
+
return unsupported("listDaemonRepos");
|
|
234
|
+
},
|
|
235
|
+
listDaemonSessions() {
|
|
236
|
+
return unsupported("listDaemonSessions");
|
|
237
|
+
},
|
|
238
|
+
listDaemonMonitors() {
|
|
239
|
+
return unsupported("listDaemonMonitors");
|
|
240
|
+
},
|
|
241
|
+
startMonitor() {
|
|
242
|
+
return unsupported("startMonitor");
|
|
243
|
+
},
|
|
244
|
+
pauseMonitor() {
|
|
245
|
+
return unsupported("pauseMonitor");
|
|
246
|
+
},
|
|
247
|
+
resumeMonitor() {
|
|
248
|
+
return unsupported("resumeMonitor");
|
|
249
|
+
},
|
|
250
|
+
stopMonitor() {
|
|
251
|
+
return unsupported("stopMonitor");
|
|
252
|
+
},
|
|
253
|
+
listWorkspaceAuthorizations() {
|
|
254
|
+
return unsupported("listWorkspaceAuthorizations");
|
|
255
|
+
},
|
|
256
|
+
authorizeWorkspace() {
|
|
257
|
+
return unsupported("authorizeWorkspace");
|
|
258
|
+
},
|
|
259
|
+
revokeWorkspace() {
|
|
260
|
+
return unsupported("revokeWorkspace");
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
ctx,
|
|
266
|
+
metrics,
|
|
267
|
+
session,
|
|
268
|
+
takeResponse() {
|
|
269
|
+
if (response === null) {
|
|
270
|
+
throw new Error(`repo-tool worker ${job.tool} completed without calling respond()`);
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
...response,
|
|
274
|
+
metricsDelta: diffMetrics(job.metricsSnapshot, metrics.snapshot()),
|
|
275
|
+
cacheUpdates: [...trackedCachePaths].map((filePath) => {
|
|
276
|
+
return {
|
|
277
|
+
path: filePath,
|
|
278
|
+
observation: cache.snapshotEntry(filePath),
|
|
279
|
+
};
|
|
280
|
+
}),
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function resolveRepoToolDefinition(tool: OffloadedRepoToolName): ToolDefinition {
|
|
287
|
+
return OFFLOADED_REPO_TOOL_DEFINITIONS[tool];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function runRepoToolJob(job: RepoToolWorkerJob): Promise<RepoToolWorkerResult> {
|
|
291
|
+
const definition = resolveRepoToolDefinition(job.tool);
|
|
292
|
+
const { ctx, takeResponse } = buildWorkerContext(job);
|
|
293
|
+
const rawHandler = definition.createHandler(ctx);
|
|
294
|
+
const handler = definition.policyCheck === true ? wrapWithPolicyCheck(definition.name, rawHandler, ctx) : rawHandler;
|
|
295
|
+
|
|
296
|
+
if (definition.schema !== undefined) {
|
|
297
|
+
z.object(definition.schema).strict().parse(job.args);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
await handler(job.args);
|
|
301
|
+
return takeResponse();
|
|
302
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface RunCaptureConfig {
|
|
2
|
+
readonly enabled: boolean;
|
|
3
|
+
readonly persistLogs: boolean;
|
|
4
|
+
readonly redactLogs: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ResolveRunCaptureConfigOptions {
|
|
8
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
9
|
+
readonly overrides?: Partial<RunCaptureConfig>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parseBooleanFlag(value: string | undefined, fallback: boolean): boolean {
|
|
13
|
+
if (value === undefined) return fallback;
|
|
14
|
+
const normalized = value.trim().toLowerCase();
|
|
15
|
+
if ([ "0", "false", "no", "off" ].includes(normalized)) return false;
|
|
16
|
+
if ([ "1", "true", "yes", "on" ].includes(normalized)) return true;
|
|
17
|
+
return fallback;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveRunCaptureConfig(
|
|
21
|
+
options: ResolveRunCaptureConfigOptions = {},
|
|
22
|
+
): RunCaptureConfig {
|
|
23
|
+
const env = options.env ?? process.env;
|
|
24
|
+
const base: RunCaptureConfig = {
|
|
25
|
+
enabled: parseBooleanFlag(env["GRAFT_ENABLE_RUN_CAPTURE"], true),
|
|
26
|
+
persistLogs: parseBooleanFlag(env["GRAFT_RUN_CAPTURE_PERSIST"], true),
|
|
27
|
+
redactLogs: parseBooleanFlag(env["GRAFT_RUN_CAPTURE_REDACT"], true),
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
...base,
|
|
31
|
+
...options.overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export interface RuntimeCausalContext {
|
|
4
|
+
readonly transportSessionId: string;
|
|
5
|
+
readonly workspaceSliceId: string;
|
|
6
|
+
readonly causalSessionId: string;
|
|
7
|
+
readonly strandId: string;
|
|
8
|
+
readonly checkoutEpochId: string;
|
|
9
|
+
readonly warpWriterId: string;
|
|
10
|
+
readonly stability: "runtime_local";
|
|
11
|
+
readonly provenanceLevel: "artifact_history";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function stableId(prefix: string, input: string): string {
|
|
15
|
+
return `${prefix}:${crypto.createHash("sha256").update(input).digest("hex").slice(0, 16)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildCheckoutEpochId(
|
|
19
|
+
repoId: string,
|
|
20
|
+
worktreeId: string,
|
|
21
|
+
checkoutEpoch: number,
|
|
22
|
+
): string {
|
|
23
|
+
return stableId("epoch", `${repoId}:${worktreeId}:${String(checkoutEpoch)}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildCausalSessionId(
|
|
27
|
+
repoId: string,
|
|
28
|
+
worktreeId: string,
|
|
29
|
+
transportSessionId: string,
|
|
30
|
+
workspaceSliceId: string,
|
|
31
|
+
): string {
|
|
32
|
+
return stableId("causal", `${repoId}:${worktreeId}:${transportSessionId}:${workspaceSliceId}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildStrandId(
|
|
36
|
+
causalSessionId: string,
|
|
37
|
+
checkoutEpochId: string,
|
|
38
|
+
warpWriterId: string,
|
|
39
|
+
): string {
|
|
40
|
+
return stableId("strand", `${causalSessionId}:${checkoutEpochId}:${warpWriterId}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildRuntimeCausalContext(input: {
|
|
44
|
+
readonly transportSessionId: string;
|
|
45
|
+
readonly workspaceSliceId: string;
|
|
46
|
+
readonly repoId: string;
|
|
47
|
+
readonly worktreeId: string;
|
|
48
|
+
readonly checkoutEpoch: number;
|
|
49
|
+
readonly warpWriterId: string;
|
|
50
|
+
}): RuntimeCausalContext {
|
|
51
|
+
const checkoutEpochId = buildCheckoutEpochId(
|
|
52
|
+
input.repoId,
|
|
53
|
+
input.worktreeId,
|
|
54
|
+
input.checkoutEpoch,
|
|
55
|
+
);
|
|
56
|
+
const causalSessionId = buildCausalSessionId(
|
|
57
|
+
input.repoId,
|
|
58
|
+
input.worktreeId,
|
|
59
|
+
input.transportSessionId,
|
|
60
|
+
input.workspaceSliceId,
|
|
61
|
+
);
|
|
62
|
+
return {
|
|
63
|
+
transportSessionId: input.transportSessionId,
|
|
64
|
+
workspaceSliceId: input.workspaceSliceId,
|
|
65
|
+
causalSessionId,
|
|
66
|
+
strandId: buildStrandId(causalSessionId, checkoutEpochId, input.warpWriterId),
|
|
67
|
+
checkoutEpochId,
|
|
68
|
+
warpWriterId: input.warpWriterId,
|
|
69
|
+
stability: "runtime_local",
|
|
70
|
+
provenanceLevel: "artifact_history",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { ZodError } from "zod";
|
|
3
|
+
import type { JsonCodec } from "../ports/codec.js";
|
|
4
|
+
import type { FileSystem } from "../ports/filesystem.js";
|
|
5
|
+
import type { MetricsSnapshot } from "./metrics.js";
|
|
6
|
+
import type { BurdenKind } from "./burden.js";
|
|
7
|
+
import type { SessionDepth } from "../session/types.js";
|
|
8
|
+
|
|
9
|
+
export type RuntimeLogPolicy = "metadata_only";
|
|
10
|
+
|
|
11
|
+
export interface RuntimeObservabilityState {
|
|
12
|
+
readonly enabled: boolean;
|
|
13
|
+
readonly logPath: string;
|
|
14
|
+
readonly maxBytes: number;
|
|
15
|
+
readonly logPolicy: RuntimeLogPolicy;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ResolveRuntimeObservabilityOptions {
|
|
19
|
+
readonly graftDir: string;
|
|
20
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
21
|
+
readonly overrides?: Partial<RuntimeObservabilityState>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_MAX_BYTES = 1_048_576;
|
|
25
|
+
const DEFAULT_POLICY: RuntimeLogPolicy = "metadata_only";
|
|
26
|
+
|
|
27
|
+
function parseBooleanFlag(value: string | undefined, fallback: boolean): boolean {
|
|
28
|
+
if (value === undefined) return fallback;
|
|
29
|
+
const normalized = value.trim().toLowerCase();
|
|
30
|
+
if ([ "0", "false", "no", "off" ].includes(normalized)) return false;
|
|
31
|
+
if ([ "1", "true", "yes", "on" ].includes(normalized)) return true;
|
|
32
|
+
return fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parsePositiveInteger(value: string | undefined, fallback: number): number {
|
|
36
|
+
if (value === undefined) return fallback;
|
|
37
|
+
const parsed = Number.parseInt(value, 10);
|
|
38
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resolveRuntimeObservabilityState(
|
|
42
|
+
options: ResolveRuntimeObservabilityOptions,
|
|
43
|
+
): RuntimeObservabilityState {
|
|
44
|
+
const env = options.env ?? process.env;
|
|
45
|
+
const base: RuntimeObservabilityState = {
|
|
46
|
+
enabled: parseBooleanFlag(env["GRAFT_ENABLE_MCP_RUNTIME_LOG"], true),
|
|
47
|
+
logPath: env["GRAFT_MCP_RUNTIME_LOG_PATH"]?.trim() ?? path.join(options.graftDir, "logs", "mcp-runtime.ndjson"),
|
|
48
|
+
maxBytes: parsePositiveInteger(env["GRAFT_MCP_RUNTIME_LOG_MAX_BYTES"], DEFAULT_MAX_BYTES),
|
|
49
|
+
logPolicy: DEFAULT_POLICY,
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
...base,
|
|
53
|
+
...options.overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RuntimeSessionStartedEvent {
|
|
58
|
+
readonly event: "session_started";
|
|
59
|
+
readonly sessionId: string;
|
|
60
|
+
readonly logPath: string;
|
|
61
|
+
readonly logPolicy: RuntimeLogPolicy;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface RuntimeToolCallStartedEvent {
|
|
65
|
+
readonly event: "tool_call_started";
|
|
66
|
+
readonly sessionId: string;
|
|
67
|
+
readonly traceId: string;
|
|
68
|
+
readonly tool: string;
|
|
69
|
+
readonly argKeys: readonly string[];
|
|
70
|
+
readonly sessionDepth: SessionDepth;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RuntimeToolCallCompletedEvent {
|
|
74
|
+
readonly event: "tool_call_completed";
|
|
75
|
+
readonly sessionId: string;
|
|
76
|
+
readonly traceId: string;
|
|
77
|
+
readonly seq: number;
|
|
78
|
+
readonly tool: string;
|
|
79
|
+
readonly latencyMs: number;
|
|
80
|
+
readonly projection: string;
|
|
81
|
+
readonly reason: string;
|
|
82
|
+
readonly burdenKind: BurdenKind;
|
|
83
|
+
readonly nonReadBurden: boolean;
|
|
84
|
+
readonly returnedBytes: number;
|
|
85
|
+
readonly fileBytes: number | null;
|
|
86
|
+
readonly sessionDepth: SessionDepth;
|
|
87
|
+
readonly tripwireSignals: readonly string[];
|
|
88
|
+
readonly metrics: MetricsSnapshot;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type RuntimeFailureKind = "validation_error" | "tool_error" | "unknown_error";
|
|
92
|
+
|
|
93
|
+
export interface RuntimeToolCallFailedEvent {
|
|
94
|
+
readonly event: "tool_call_failed";
|
|
95
|
+
readonly sessionId: string;
|
|
96
|
+
readonly traceId: string;
|
|
97
|
+
readonly tool: string;
|
|
98
|
+
readonly latencyMs: number;
|
|
99
|
+
readonly sessionDepth: SessionDepth;
|
|
100
|
+
readonly argKeys: readonly string[];
|
|
101
|
+
readonly errorKind: RuntimeFailureKind;
|
|
102
|
+
readonly errorName: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type RuntimeEvent =
|
|
106
|
+
| RuntimeSessionStartedEvent
|
|
107
|
+
| RuntimeToolCallStartedEvent
|
|
108
|
+
| RuntimeToolCallCompletedEvent
|
|
109
|
+
| RuntimeToolCallFailedEvent;
|
|
110
|
+
|
|
111
|
+
export interface RuntimeEventLoggerOptions {
|
|
112
|
+
readonly fs: FileSystem;
|
|
113
|
+
readonly codec: JsonCodec;
|
|
114
|
+
readonly logPath: string;
|
|
115
|
+
readonly maxBytes: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function ensureGraftDirExcluded(
|
|
119
|
+
projectRoot: string,
|
|
120
|
+
graftDir: string,
|
|
121
|
+
fs: FileSystem,
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
const relative = path.relative(projectRoot, graftDir);
|
|
124
|
+
if (relative.length === 0 || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const entry = relative.replaceAll(path.sep, "/").replace(/\/?$/, "/");
|
|
129
|
+
const excludePath = path.join(projectRoot, ".git", "info", "exclude");
|
|
130
|
+
|
|
131
|
+
let existing: string;
|
|
132
|
+
try {
|
|
133
|
+
existing = fs.readFileSync(excludePath, "utf-8");
|
|
134
|
+
} catch {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const entries = existing
|
|
139
|
+
.split(/\r?\n/u)
|
|
140
|
+
.map((line) => line.trim())
|
|
141
|
+
.filter((line) => line.length > 0);
|
|
142
|
+
|
|
143
|
+
if (entries.includes(entry)) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const prefix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
148
|
+
await fs.appendFile(excludePath, `${prefix}${entry}\n`, "utf-8");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function classifyRuntimeFailure(error: unknown): {
|
|
152
|
+
kind: RuntimeFailureKind;
|
|
153
|
+
name: string;
|
|
154
|
+
} {
|
|
155
|
+
if (error instanceof ZodError) {
|
|
156
|
+
return { kind: "validation_error", name: error.name };
|
|
157
|
+
}
|
|
158
|
+
if (error instanceof Error) {
|
|
159
|
+
return { kind: "tool_error", name: error.name };
|
|
160
|
+
}
|
|
161
|
+
return { kind: "unknown_error", name: "UnknownError" };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function sanitizeArgKeys(args: Record<string, unknown>): string[] {
|
|
165
|
+
return Object.keys(args).sort();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export class RuntimeEventLogger {
|
|
169
|
+
private readonly fs: FileSystem;
|
|
170
|
+
private readonly codec: JsonCodec;
|
|
171
|
+
private readonly logPath: string;
|
|
172
|
+
private readonly maxBytes: number;
|
|
173
|
+
private queue: Promise<void> = Promise.resolve();
|
|
174
|
+
|
|
175
|
+
constructor(options: RuntimeEventLoggerOptions) {
|
|
176
|
+
this.fs = options.fs;
|
|
177
|
+
this.codec = options.codec;
|
|
178
|
+
this.logPath = options.logPath;
|
|
179
|
+
this.maxBytes = options.maxBytes;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
log(event: RuntimeEvent): Promise<void> {
|
|
183
|
+
const prior = this.queue.catch((_error: unknown) => undefined);
|
|
184
|
+
this.queue = prior
|
|
185
|
+
.then(async () => {
|
|
186
|
+
const line = this.codec.encode({ ts: new Date().toISOString(), ...event }) + "\n";
|
|
187
|
+
const dir = path.dirname(this.logPath);
|
|
188
|
+
await this.fs.mkdir(dir, { recursive: true });
|
|
189
|
+
await this.fs.appendFile(this.logPath, line, "utf-8");
|
|
190
|
+
await this.rotateIfNeeded();
|
|
191
|
+
});
|
|
192
|
+
return this.queue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async rotateIfNeeded(): Promise<void> {
|
|
196
|
+
let stat: { size: number };
|
|
197
|
+
try {
|
|
198
|
+
stat = await this.fs.stat(this.logPath);
|
|
199
|
+
} catch {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (stat.size <= this.maxBytes) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const content = await this.fs.readFile(this.logPath, "utf-8");
|
|
208
|
+
const lines = content.trimEnd().split("\n");
|
|
209
|
+
let kept = lines.slice(Math.ceil(lines.length / 2));
|
|
210
|
+
let result = kept.join("\n") + "\n";
|
|
211
|
+
|
|
212
|
+
while (Buffer.byteLength(result, "utf-8") > this.maxBytes && kept.length > 1) {
|
|
213
|
+
kept = kept.slice(Math.ceil(kept.length / 2));
|
|
214
|
+
result = kept.join("\n") + "\n";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await this.fs.writeFile(this.logPath, result, "utf-8");
|
|
218
|
+
}
|
|
219
|
+
}
|