@flyingrobots/graft 0.4.0 → 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 +47 -0
- package/CODE_OF_CONDUCT.md +65 -0
- package/README.md +153 -17
- package/bin/graft.js +4 -14
- 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 +15 -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 +75 -11
- 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 +65 -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 +696 -55
- 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 +92 -38
- 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 +7 -2
- 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 +171 -56
- package/src/warp/observers.ts +1 -1
- package/src/warp/open.ts +4 -3
- package/src/warp/plumbing.d.ts +5 -1
- package/src/warp/writer-id.ts +30 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { JsonCodec } from "../ports/codec.js";
|
|
4
|
+
import type { FileSystem } from "../ports/filesystem.js";
|
|
5
|
+
import type { GitClient } from "../ports/git.js";
|
|
6
|
+
import type { DaemonJobScheduler } from "./daemon-job-scheduler.js";
|
|
7
|
+
import type { DaemonWorkerPool } from "./daemon-worker-pool.js";
|
|
8
|
+
import type { DaemonControlPlane, DaemonMonitorCounts } from "./daemon-control-plane.js";
|
|
9
|
+
import {
|
|
10
|
+
resolveWorkspaceRequest,
|
|
11
|
+
type WorkspaceBindRequest,
|
|
12
|
+
} from "./workspace-router.js";
|
|
13
|
+
import { buildMonitorWarpWriterId } from "../warp/writer-id.js";
|
|
14
|
+
|
|
15
|
+
const CONTROL_PLANE_DIR = "control-plane";
|
|
16
|
+
const MONITORS_FILE = "monitors.json";
|
|
17
|
+
const DEFAULT_POLL_INTERVAL_MS = 5_000;
|
|
18
|
+
|
|
19
|
+
export type MonitorLifecycleState = "running" | "paused" | "stopped";
|
|
20
|
+
export type MonitorHealth = "ok" | "lagging" | "error" | "unauthorized" | "paused" | "stopped";
|
|
21
|
+
export type MonitorWorkerKind = "git_poll_indexer";
|
|
22
|
+
export type MonitorAction = "start" | "pause" | "resume" | "stop";
|
|
23
|
+
|
|
24
|
+
const persistedMonitorSchema = z.object({
|
|
25
|
+
repoId: z.string(),
|
|
26
|
+
gitCommonDir: z.string(),
|
|
27
|
+
anchorWorktreeRoot: z.string(),
|
|
28
|
+
workerKind: z.literal("git_poll_indexer"),
|
|
29
|
+
lifecycleState: z.enum(["running", "paused", "stopped"]),
|
|
30
|
+
health: z.enum(["ok", "lagging", "error", "unauthorized", "paused", "stopped"]),
|
|
31
|
+
pollIntervalMs: z.number().int().positive(),
|
|
32
|
+
lastStartedAt: z.string().nullable(),
|
|
33
|
+
lastTickAt: z.string().nullable(),
|
|
34
|
+
lastSuccessAt: z.string().nullable(),
|
|
35
|
+
lastError: z.string().nullable(),
|
|
36
|
+
lastIndexedCommit: z.string().nullable(),
|
|
37
|
+
lastHeadCommit: z.string().nullable(),
|
|
38
|
+
backlogCommits: z.number().int().nonnegative(),
|
|
39
|
+
lastRunCommitsIndexed: z.number().int().nonnegative(),
|
|
40
|
+
lastRunPatchesWritten: z.number().int().nonnegative(),
|
|
41
|
+
}).strict();
|
|
42
|
+
|
|
43
|
+
const persistedMonitorStateSchema = z.object({
|
|
44
|
+
version: z.literal(1),
|
|
45
|
+
monitors: z.array(persistedMonitorSchema),
|
|
46
|
+
}).strict();
|
|
47
|
+
|
|
48
|
+
type PersistedMonitorRecord = z.infer<typeof persistedMonitorSchema>;
|
|
49
|
+
type PersistedMonitorState = z.infer<typeof persistedMonitorStateSchema>;
|
|
50
|
+
|
|
51
|
+
interface MonitorTimerEntry {
|
|
52
|
+
readonly timeout: ReturnType<typeof setTimeout>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface MonitorStatusView {
|
|
56
|
+
readonly repoId: string;
|
|
57
|
+
readonly gitCommonDir: string;
|
|
58
|
+
readonly anchorWorktreeRoot: string;
|
|
59
|
+
readonly authorizedWorkspaces: number;
|
|
60
|
+
readonly workerKind: MonitorWorkerKind;
|
|
61
|
+
readonly lifecycleState: MonitorLifecycleState;
|
|
62
|
+
readonly health: MonitorHealth;
|
|
63
|
+
readonly pollIntervalMs: number;
|
|
64
|
+
readonly lastStartedAt: string | null;
|
|
65
|
+
readonly lastTickAt: string | null;
|
|
66
|
+
readonly lastSuccessAt: string | null;
|
|
67
|
+
readonly lastError: string | null;
|
|
68
|
+
readonly lastIndexedCommit: string | null;
|
|
69
|
+
readonly lastHeadCommit: string | null;
|
|
70
|
+
readonly backlogCommits: number;
|
|
71
|
+
readonly lastRunCommitsIndexed: number;
|
|
72
|
+
readonly lastRunPatchesWritten: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface MonitorActionResult {
|
|
76
|
+
readonly ok: boolean;
|
|
77
|
+
readonly action: MonitorAction;
|
|
78
|
+
readonly created: boolean;
|
|
79
|
+
readonly changed: boolean;
|
|
80
|
+
readonly status?: MonitorStatusView;
|
|
81
|
+
readonly errorCode?: string;
|
|
82
|
+
readonly error?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface MonitorStartRequest extends WorkspaceBindRequest {
|
|
86
|
+
readonly pollIntervalMs?: number | undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface PersistentMonitorRuntimeOptions {
|
|
90
|
+
readonly fs: FileSystem;
|
|
91
|
+
readonly codec: JsonCodec;
|
|
92
|
+
readonly git: GitClient;
|
|
93
|
+
readonly graftDir: string;
|
|
94
|
+
readonly controlPlane: DaemonControlPlane;
|
|
95
|
+
readonly scheduler: DaemonJobScheduler;
|
|
96
|
+
readonly workerPool: DaemonWorkerPool;
|
|
97
|
+
readonly defaultPollIntervalMs?: number | undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizePollIntervalMs(value: number | undefined, fallback: number): number {
|
|
101
|
+
if (value === undefined) return fallback;
|
|
102
|
+
const normalized = Math.trunc(value);
|
|
103
|
+
if (!Number.isFinite(normalized) || normalized <= 0) {
|
|
104
|
+
throw new Error("pollIntervalMs must be a positive integer");
|
|
105
|
+
}
|
|
106
|
+
return normalized;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sortStatuses(statuses: readonly MonitorStatusView[]): readonly MonitorStatusView[] {
|
|
110
|
+
return [...statuses].sort((left, right) => left.gitCommonDir.localeCompare(right.gitCommonDir));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function cloneRecord(record: PersistedMonitorRecord): PersistedMonitorRecord {
|
|
114
|
+
return { ...record };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class PersistentMonitorRuntime {
|
|
118
|
+
private readonly statePath: string;
|
|
119
|
+
private readonly records = new Map<string, PersistedMonitorRecord>();
|
|
120
|
+
private readonly timers = new Map<string, MonitorTimerEntry>();
|
|
121
|
+
private readonly runningTicks = new Map<string, Promise<void>>();
|
|
122
|
+
private readonly defaultPollIntervalMs: number;
|
|
123
|
+
private loadPromise: Promise<void> | null = null;
|
|
124
|
+
private closing = false;
|
|
125
|
+
|
|
126
|
+
constructor(private readonly options: PersistentMonitorRuntimeOptions) {
|
|
127
|
+
this.statePath = path.join(
|
|
128
|
+
path.resolve(options.graftDir),
|
|
129
|
+
CONTROL_PLANE_DIR,
|
|
130
|
+
MONITORS_FILE,
|
|
131
|
+
);
|
|
132
|
+
this.defaultPollIntervalMs = options.defaultPollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async initialize(): Promise<void> {
|
|
136
|
+
await this.ensureLoaded();
|
|
137
|
+
for (const record of this.records.values()) {
|
|
138
|
+
if (record.lifecycleState === "running") {
|
|
139
|
+
this.schedule(record.repoId, 0);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async close(): Promise<void> {
|
|
145
|
+
this.closing = true;
|
|
146
|
+
for (const timer of this.timers.values()) {
|
|
147
|
+
clearTimeout(timer.timeout);
|
|
148
|
+
}
|
|
149
|
+
this.timers.clear();
|
|
150
|
+
await Promise.all([...this.runningTicks.values()].map((tick) => tick.catch(() => undefined)));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
getCounts(): DaemonMonitorCounts {
|
|
154
|
+
let runningMonitors = 0;
|
|
155
|
+
let pausedMonitors = 0;
|
|
156
|
+
let stoppedMonitors = 0;
|
|
157
|
+
let failingMonitors = 0;
|
|
158
|
+
let backlogMonitors = 0;
|
|
159
|
+
|
|
160
|
+
for (const record of this.records.values()) {
|
|
161
|
+
if (record.lifecycleState === "running") runningMonitors++;
|
|
162
|
+
if (record.lifecycleState === "paused") pausedMonitors++;
|
|
163
|
+
if (record.lifecycleState === "stopped") stoppedMonitors++;
|
|
164
|
+
if (record.health === "error" || record.health === "unauthorized") failingMonitors++;
|
|
165
|
+
if (record.backlogCommits > 0) backlogMonitors++;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
totalMonitors: this.records.size,
|
|
170
|
+
runningMonitors,
|
|
171
|
+
pausedMonitors,
|
|
172
|
+
stoppedMonitors,
|
|
173
|
+
failingMonitors,
|
|
174
|
+
backlogMonitors,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async listStatuses(): Promise<readonly MonitorStatusView[]> {
|
|
179
|
+
await this.ensureLoaded();
|
|
180
|
+
const authorized = await this.options.controlPlane.listAuthorizedWorkspaceRecords();
|
|
181
|
+
return sortStatuses([...this.records.values()].map((record) => {
|
|
182
|
+
const authorizedWorkspaces = authorized.filter((workspace) => workspace.repoId === record.repoId).length;
|
|
183
|
+
return {
|
|
184
|
+
repoId: record.repoId,
|
|
185
|
+
gitCommonDir: record.gitCommonDir,
|
|
186
|
+
anchorWorktreeRoot: record.anchorWorktreeRoot,
|
|
187
|
+
authorizedWorkspaces,
|
|
188
|
+
workerKind: record.workerKind,
|
|
189
|
+
lifecycleState: record.lifecycleState,
|
|
190
|
+
health: record.health,
|
|
191
|
+
pollIntervalMs: record.pollIntervalMs,
|
|
192
|
+
lastStartedAt: record.lastStartedAt,
|
|
193
|
+
lastTickAt: record.lastTickAt,
|
|
194
|
+
lastSuccessAt: record.lastSuccessAt,
|
|
195
|
+
lastError: record.lastError,
|
|
196
|
+
lastIndexedCommit: record.lastIndexedCommit,
|
|
197
|
+
lastHeadCommit: record.lastHeadCommit,
|
|
198
|
+
backlogCommits: record.backlogCommits,
|
|
199
|
+
lastRunCommitsIndexed: record.lastRunCommitsIndexed,
|
|
200
|
+
lastRunPatchesWritten: record.lastRunPatchesWritten,
|
|
201
|
+
};
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async startMonitor(request: MonitorStartRequest): Promise<MonitorActionResult> {
|
|
206
|
+
const authorizedWorkspace = await this.options.controlPlane.getAuthorizedWorkspace(request);
|
|
207
|
+
if (authorizedWorkspace === null) {
|
|
208
|
+
return {
|
|
209
|
+
ok: false,
|
|
210
|
+
action: "start",
|
|
211
|
+
created: false,
|
|
212
|
+
changed: false,
|
|
213
|
+
errorCode: "WORKSPACE_NOT_AUTHORIZED",
|
|
214
|
+
error: `Workspace ${request.cwd} is not authorized for monitoring. Call workspace_authorize first.`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await this.ensureLoaded();
|
|
219
|
+
const current = this.records.get(authorizedWorkspace.repoId);
|
|
220
|
+
const pollIntervalMs = normalizePollIntervalMs(
|
|
221
|
+
request.pollIntervalMs,
|
|
222
|
+
current?.pollIntervalMs ?? this.defaultPollIntervalMs,
|
|
223
|
+
);
|
|
224
|
+
const next: PersistedMonitorRecord = {
|
|
225
|
+
repoId: authorizedWorkspace.repoId,
|
|
226
|
+
gitCommonDir: authorizedWorkspace.gitCommonDir,
|
|
227
|
+
anchorWorktreeRoot: authorizedWorkspace.worktreeRoot,
|
|
228
|
+
workerKind: "git_poll_indexer",
|
|
229
|
+
lifecycleState: "running",
|
|
230
|
+
health: "lagging",
|
|
231
|
+
pollIntervalMs,
|
|
232
|
+
lastStartedAt: new Date().toISOString(),
|
|
233
|
+
lastTickAt: current?.lastTickAt ?? null,
|
|
234
|
+
lastSuccessAt: current?.lastSuccessAt ?? null,
|
|
235
|
+
lastError: current?.lastError ?? null,
|
|
236
|
+
lastIndexedCommit: current?.lastIndexedCommit ?? null,
|
|
237
|
+
lastHeadCommit: current?.lastHeadCommit ?? null,
|
|
238
|
+
backlogCommits: current?.backlogCommits ?? 0,
|
|
239
|
+
lastRunCommitsIndexed: current?.lastRunCommitsIndexed ?? 0,
|
|
240
|
+
lastRunPatchesWritten: current?.lastRunPatchesWritten ?? 0,
|
|
241
|
+
};
|
|
242
|
+
const created = current === undefined;
|
|
243
|
+
const changed = created
|
|
244
|
+
|| current.lifecycleState !== "running"
|
|
245
|
+
|| current.anchorWorktreeRoot !== next.anchorWorktreeRoot
|
|
246
|
+
|| current.pollIntervalMs !== next.pollIntervalMs;
|
|
247
|
+
|
|
248
|
+
this.records.set(next.repoId, next);
|
|
249
|
+
await this.persist();
|
|
250
|
+
await this.runTick(next.repoId).catch(() => {
|
|
251
|
+
return undefined;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
ok: true,
|
|
256
|
+
action: "start",
|
|
257
|
+
created,
|
|
258
|
+
changed,
|
|
259
|
+
status: await this.getStatusByRepoId(next.repoId),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async pauseMonitor(request: WorkspaceBindRequest): Promise<MonitorActionResult> {
|
|
264
|
+
return this.transitionMonitor("pause", request, "paused");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async resumeMonitor(request: WorkspaceBindRequest): Promise<MonitorActionResult> {
|
|
268
|
+
const transition = await this.transitionMonitor("resume", request, "running");
|
|
269
|
+
if (!transition.ok || transition.status === undefined) {
|
|
270
|
+
return transition;
|
|
271
|
+
}
|
|
272
|
+
const current = this.records.get(transition.status.repoId);
|
|
273
|
+
if (current !== undefined) {
|
|
274
|
+
await this.runTick(current.repoId).catch(() => {
|
|
275
|
+
return undefined;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
...transition,
|
|
280
|
+
status: await this.getStatusByRepoId(transition.status.repoId),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async stopMonitor(request: WorkspaceBindRequest): Promise<MonitorActionResult> {
|
|
285
|
+
return this.transitionMonitor("stop", request, "stopped");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private async transitionMonitor(
|
|
289
|
+
action: Exclude<MonitorAction, "start">,
|
|
290
|
+
request: WorkspaceBindRequest,
|
|
291
|
+
lifecycleState: MonitorLifecycleState,
|
|
292
|
+
): Promise<MonitorActionResult> {
|
|
293
|
+
await this.ensureLoaded();
|
|
294
|
+
const resolved = await this.resolveAuthorizedRepo(request);
|
|
295
|
+
if ("errorCode" in resolved) {
|
|
296
|
+
return {
|
|
297
|
+
ok: false,
|
|
298
|
+
action,
|
|
299
|
+
created: false,
|
|
300
|
+
changed: false,
|
|
301
|
+
errorCode: resolved.errorCode,
|
|
302
|
+
error: resolved.error,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const current = this.records.get(resolved.repoId);
|
|
307
|
+
if (current === undefined) {
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
action,
|
|
311
|
+
created: false,
|
|
312
|
+
changed: false,
|
|
313
|
+
errorCode: "MONITOR_NOT_FOUND",
|
|
314
|
+
error: `No monitor exists for repo ${resolved.repoId}.`,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (lifecycleState !== "running") {
|
|
319
|
+
this.clearTimer(current.repoId);
|
|
320
|
+
}
|
|
321
|
+
const nextHealth = lifecycleState === "paused"
|
|
322
|
+
? "paused"
|
|
323
|
+
: lifecycleState === "stopped"
|
|
324
|
+
? "stopped"
|
|
325
|
+
: "lagging";
|
|
326
|
+
this.records.set(current.repoId, {
|
|
327
|
+
...current,
|
|
328
|
+
lifecycleState,
|
|
329
|
+
health: nextHealth,
|
|
330
|
+
lastError: lifecycleState === "stopped" ? null : current.lastError,
|
|
331
|
+
});
|
|
332
|
+
await this.persist();
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
ok: true,
|
|
336
|
+
action,
|
|
337
|
+
created: false,
|
|
338
|
+
changed: current.lifecycleState !== lifecycleState,
|
|
339
|
+
status: await this.getStatusByRepoId(current.repoId),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private async runTick(repoId: string): Promise<void> {
|
|
344
|
+
const existing = this.runningTicks.get(repoId);
|
|
345
|
+
if (existing !== undefined) {
|
|
346
|
+
await existing;
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const tick = this.options.scheduler.enqueue({
|
|
351
|
+
sessionId: null,
|
|
352
|
+
sliceId: null,
|
|
353
|
+
repoId,
|
|
354
|
+
worktreeId: null,
|
|
355
|
+
tool: "monitor_tick",
|
|
356
|
+
kind: "persistent_monitor",
|
|
357
|
+
priority: "background",
|
|
358
|
+
writerId: buildMonitorWarpWriterId(repoId),
|
|
359
|
+
}, async () => {
|
|
360
|
+
const record = this.records.get(repoId);
|
|
361
|
+
if (record?.lifecycleState !== "running" || this.closing) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const tickStartedAt = new Date().toISOString();
|
|
366
|
+
const anchor = await this.options.controlPlane.getAuthorizedWorkspaceForRepo(repoId, record.anchorWorktreeRoot);
|
|
367
|
+
if (anchor === null) {
|
|
368
|
+
const latest = this.records.get(repoId) ?? record;
|
|
369
|
+
const health = latest.lifecycleState === "paused"
|
|
370
|
+
? "paused"
|
|
371
|
+
: latest.lifecycleState === "stopped"
|
|
372
|
+
? "stopped"
|
|
373
|
+
: "unauthorized";
|
|
374
|
+
this.records.set(repoId, {
|
|
375
|
+
...latest,
|
|
376
|
+
lastTickAt: tickStartedAt,
|
|
377
|
+
health,
|
|
378
|
+
lastError: "No authorized workspace is currently available for this repo monitor.",
|
|
379
|
+
backlogCommits: 0,
|
|
380
|
+
});
|
|
381
|
+
await this.persist();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
const result = await this.options.workerPool.runMonitorTick({
|
|
387
|
+
repoId,
|
|
388
|
+
worktreeRoot: anchor.worktreeRoot,
|
|
389
|
+
writerId: buildMonitorWarpWriterId(repoId),
|
|
390
|
+
lastIndexedCommit: record.lastIndexedCommit,
|
|
391
|
+
});
|
|
392
|
+
const latest = this.records.get(repoId) ?? record;
|
|
393
|
+
const nextHealth = latest.lifecycleState === "paused"
|
|
394
|
+
? "paused"
|
|
395
|
+
: latest.lifecycleState === "stopped"
|
|
396
|
+
? "stopped"
|
|
397
|
+
: result.backlogCommits > 0
|
|
398
|
+
? "lagging"
|
|
399
|
+
: "ok";
|
|
400
|
+
if (result.ok) {
|
|
401
|
+
this.records.set(repoId, {
|
|
402
|
+
...latest,
|
|
403
|
+
anchorWorktreeRoot: anchor.worktreeRoot,
|
|
404
|
+
gitCommonDir: anchor.gitCommonDir,
|
|
405
|
+
lastTickAt: tickStartedAt,
|
|
406
|
+
lastSuccessAt: new Date().toISOString(),
|
|
407
|
+
lastError: null,
|
|
408
|
+
lastIndexedCommit: result.lastIndexedCommit,
|
|
409
|
+
lastHeadCommit: result.currentHead,
|
|
410
|
+
backlogCommits: result.backlogCommits,
|
|
411
|
+
health: nextHealth,
|
|
412
|
+
lastRunCommitsIndexed: result.commitsIndexed,
|
|
413
|
+
lastRunPatchesWritten: result.patchesWritten,
|
|
414
|
+
});
|
|
415
|
+
} else {
|
|
416
|
+
this.records.set(repoId, {
|
|
417
|
+
...latest,
|
|
418
|
+
anchorWorktreeRoot: anchor.worktreeRoot,
|
|
419
|
+
gitCommonDir: anchor.gitCommonDir,
|
|
420
|
+
lastTickAt: tickStartedAt,
|
|
421
|
+
lastError: result.error,
|
|
422
|
+
lastHeadCommit: result.currentHead,
|
|
423
|
+
backlogCommits: result.backlogCommits,
|
|
424
|
+
health: latest.lifecycleState === "paused"
|
|
425
|
+
? "paused"
|
|
426
|
+
: latest.lifecycleState === "stopped"
|
|
427
|
+
? "stopped"
|
|
428
|
+
: "error",
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
} catch (error) {
|
|
432
|
+
const latest = this.records.get(repoId) ?? record;
|
|
433
|
+
this.records.set(repoId, {
|
|
434
|
+
...latest,
|
|
435
|
+
anchorWorktreeRoot: anchor.worktreeRoot,
|
|
436
|
+
gitCommonDir: anchor.gitCommonDir,
|
|
437
|
+
lastTickAt: tickStartedAt,
|
|
438
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
439
|
+
health: latest.lifecycleState === "paused"
|
|
440
|
+
? "paused"
|
|
441
|
+
: latest.lifecycleState === "stopped"
|
|
442
|
+
? "stopped"
|
|
443
|
+
: "error",
|
|
444
|
+
});
|
|
445
|
+
} finally {
|
|
446
|
+
await this.persist();
|
|
447
|
+
}
|
|
448
|
+
}).finally(() => {
|
|
449
|
+
this.runningTicks.delete(repoId);
|
|
450
|
+
const current = this.records.get(repoId);
|
|
451
|
+
if (current?.lifecycleState === "running" && !this.closing) {
|
|
452
|
+
this.schedule(repoId, current.pollIntervalMs);
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
this.runningTicks.set(repoId, tick);
|
|
457
|
+
await tick;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private schedule(repoId: string, delayMs: number): void {
|
|
461
|
+
if (this.closing) return;
|
|
462
|
+
const record = this.records.get(repoId);
|
|
463
|
+
if (record?.lifecycleState !== "running") return;
|
|
464
|
+
this.clearTimer(repoId);
|
|
465
|
+
this.timers.set(repoId, {
|
|
466
|
+
timeout: setTimeout(() => {
|
|
467
|
+
this.timers.delete(repoId);
|
|
468
|
+
void this.runTick(repoId);
|
|
469
|
+
}, delayMs),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private clearTimer(repoId: string): void {
|
|
474
|
+
const timer = this.timers.get(repoId);
|
|
475
|
+
if (timer === undefined) return;
|
|
476
|
+
clearTimeout(timer.timeout);
|
|
477
|
+
this.timers.delete(repoId);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private async getStatusByRepoId(repoId: string): Promise<MonitorStatusView> {
|
|
481
|
+
const statuses = await this.listStatuses();
|
|
482
|
+
const status = statuses.find((candidate) => candidate.repoId === repoId);
|
|
483
|
+
if (status === undefined) {
|
|
484
|
+
throw new Error(`Missing monitor status for repo ${repoId}`);
|
|
485
|
+
}
|
|
486
|
+
return status;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private resolveRepo(
|
|
490
|
+
request: WorkspaceBindRequest,
|
|
491
|
+
): Promise<{ repoId: string } | { errorCode: string; error: string }> {
|
|
492
|
+
return resolveWorkspaceRequest(this.options.git, request).then((resolved) => {
|
|
493
|
+
if ("code" in resolved) {
|
|
494
|
+
return {
|
|
495
|
+
errorCode: resolved.code,
|
|
496
|
+
error: resolved.message,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
errorCode: "WORKSPACE_NOT_AUTHORIZED",
|
|
501
|
+
error: `Workspace ${resolved.worktreeRoot} is not authorized for monitoring. Call workspace_authorize first.`,
|
|
502
|
+
};
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
private async resolveAuthorizedRepo(
|
|
507
|
+
request: WorkspaceBindRequest,
|
|
508
|
+
): Promise<{ repoId: string } | { errorCode: string; error: string }> {
|
|
509
|
+
const authorized = await this.options.controlPlane.getAuthorizedWorkspace(request);
|
|
510
|
+
if (authorized !== null) {
|
|
511
|
+
return { repoId: authorized.repoId };
|
|
512
|
+
}
|
|
513
|
+
return this.resolveRepo(request);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private async ensureLoaded(): Promise<void> {
|
|
517
|
+
if (this.loadPromise !== null) {
|
|
518
|
+
await this.loadPromise;
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
this.loadPromise = (async () => {
|
|
523
|
+
const raw = await this.options.fs.readFile(this.statePath, "utf-8").catch((error: unknown) => {
|
|
524
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
throw error;
|
|
528
|
+
});
|
|
529
|
+
if (raw === null) return;
|
|
530
|
+
const decoded = persistedMonitorStateSchema.parse(this.options.codec.decode(raw));
|
|
531
|
+
for (const record of decoded.monitors) {
|
|
532
|
+
this.records.set(record.repoId, cloneRecord(record));
|
|
533
|
+
}
|
|
534
|
+
})();
|
|
535
|
+
|
|
536
|
+
await this.loadPromise;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private async persist(): Promise<void> {
|
|
540
|
+
const payload: PersistedMonitorState = {
|
|
541
|
+
version: 1,
|
|
542
|
+
monitors: [...this.records.values()]
|
|
543
|
+
.map(cloneRecord)
|
|
544
|
+
.sort((left, right) => left.gitCommonDir.localeCompare(right.gitCommonDir)),
|
|
545
|
+
};
|
|
546
|
+
await this.options.fs.mkdir(path.dirname(this.statePath), { recursive: true });
|
|
547
|
+
await this.options.fs.writeFile(this.statePath, this.options.codec.encode(payload), "utf-8");
|
|
548
|
+
}
|
|
549
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { evaluatePolicy } from "../policy/evaluate.js";
|
|
3
|
+
import { loadGraftignore } from "../policy/graftignore.js";
|
|
4
|
+
import { RefusedResult, type PolicyOptions, type PolicyResult } from "../policy/types.js";
|
|
5
|
+
import type { FileSystem } from "../ports/filesystem.js";
|
|
6
|
+
import type { SessionTracker } from "../session/tracker.js";
|
|
7
|
+
|
|
8
|
+
export interface McpPolicyContext {
|
|
9
|
+
readonly projectRoot: string;
|
|
10
|
+
readonly graftignorePatterns: readonly string[];
|
|
11
|
+
readonly session: SessionTracker;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface McpPolicyRefusal {
|
|
15
|
+
path: string;
|
|
16
|
+
reason: string;
|
|
17
|
+
reasonDetail: string;
|
|
18
|
+
next: readonly string[];
|
|
19
|
+
actual: { lines: number; bytes: number };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadProjectGraftignore(
|
|
23
|
+
fs: Pick<FileSystem, "readFileSync">,
|
|
24
|
+
projectRoot: string,
|
|
25
|
+
): string[] {
|
|
26
|
+
try {
|
|
27
|
+
return loadGraftignore(fs.readFileSync(path.join(projectRoot, ".graftignore"), "utf-8"));
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function toPolicyPath(projectRoot: string, filePath: string): string {
|
|
34
|
+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(projectRoot, filePath);
|
|
35
|
+
const relativePath = path.relative(projectRoot, absolutePath);
|
|
36
|
+
if (relativePath.length === 0 || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
37
|
+
return absolutePath;
|
|
38
|
+
}
|
|
39
|
+
return relativePath.split(path.sep).join("/");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildMcpPolicyOptions(
|
|
43
|
+
ctx: Pick<McpPolicyContext, "graftignorePatterns" | "session">,
|
|
44
|
+
): PolicyOptions {
|
|
45
|
+
return {
|
|
46
|
+
graftignorePatterns: ctx.graftignorePatterns.length > 0 ? [...ctx.graftignorePatterns] : undefined,
|
|
47
|
+
sessionDepth: ctx.session.getSessionDepth(),
|
|
48
|
+
budgetRemaining: ctx.session.getBudget()?.remaining,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function evaluateMcpPolicy(
|
|
53
|
+
ctx: McpPolicyContext,
|
|
54
|
+
filePath: string,
|
|
55
|
+
actual: { lines: number; bytes: number },
|
|
56
|
+
): PolicyResult {
|
|
57
|
+
return evaluatePolicy(
|
|
58
|
+
{
|
|
59
|
+
path: toPolicyPath(ctx.projectRoot, filePath),
|
|
60
|
+
lines: actual.lines,
|
|
61
|
+
bytes: actual.bytes,
|
|
62
|
+
},
|
|
63
|
+
buildMcpPolicyOptions(ctx),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function evaluateMcpRefusal(
|
|
68
|
+
ctx: McpPolicyContext,
|
|
69
|
+
filePath: string,
|
|
70
|
+
actual: { lines: number; bytes: number },
|
|
71
|
+
): McpPolicyRefusal | null {
|
|
72
|
+
const policy = evaluateMcpPolicy(ctx, filePath, actual);
|
|
73
|
+
if (!(policy instanceof RefusedResult)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
path: filePath,
|
|
79
|
+
reason: policy.reason,
|
|
80
|
+
reasonDetail: policy.reasonDetail,
|
|
81
|
+
next: [...policy.next],
|
|
82
|
+
actual,
|
|
83
|
+
};
|
|
84
|
+
}
|