@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,554 @@
|
|
|
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 { DaemonSchedulerCounts } from "./daemon-job-scheduler.js";
|
|
7
|
+
import { ZERO_SCHEDULER_COUNTS } from "./daemon-job-scheduler.js";
|
|
8
|
+
import type { DaemonWorkerCounts } from "./daemon-worker-pool.js";
|
|
9
|
+
import type { RuntimeCausalContext } from "./runtime-causal-context.js";
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_DAEMON_CAPABILITY_PROFILE,
|
|
12
|
+
resolveWorkspaceRequest,
|
|
13
|
+
type ResolvedWorkspace,
|
|
14
|
+
type WorkspaceBindRequest,
|
|
15
|
+
type WorkspaceCapabilityProfile,
|
|
16
|
+
type WorkspaceStatus,
|
|
17
|
+
} from "./workspace-router.js";
|
|
18
|
+
|
|
19
|
+
const CONTROL_PLANE_DIR = "control-plane";
|
|
20
|
+
const AUTHORIZED_WORKSPACES_FILE = "authorized-workspaces.json";
|
|
21
|
+
|
|
22
|
+
const workspaceCapabilityProfileSchema = z.object({
|
|
23
|
+
boundedReads: z.boolean(),
|
|
24
|
+
structuralTools: z.boolean(),
|
|
25
|
+
precisionTools: z.boolean(),
|
|
26
|
+
stateBookmarks: z.boolean(),
|
|
27
|
+
runtimeLogs: z.literal("session_local_only"),
|
|
28
|
+
runCapture: z.boolean(),
|
|
29
|
+
}).strict();
|
|
30
|
+
|
|
31
|
+
const authorizedWorkspaceRecordSchema = z.object({
|
|
32
|
+
repoId: z.string(),
|
|
33
|
+
worktreeId: z.string(),
|
|
34
|
+
worktreeRoot: z.string(),
|
|
35
|
+
gitCommonDir: z.string(),
|
|
36
|
+
capabilityProfile: workspaceCapabilityProfileSchema,
|
|
37
|
+
authorizedAt: z.string(),
|
|
38
|
+
lastBoundAt: z.string().nullable(),
|
|
39
|
+
}).strict();
|
|
40
|
+
|
|
41
|
+
const persistedControlPlaneStateSchema = z.object({
|
|
42
|
+
version: z.literal(1),
|
|
43
|
+
workspaces: z.array(authorizedWorkspaceRecordSchema),
|
|
44
|
+
}).strict();
|
|
45
|
+
|
|
46
|
+
type PersistedControlPlaneState = z.infer<typeof persistedControlPlaneStateSchema>;
|
|
47
|
+
|
|
48
|
+
interface RegisteredSession {
|
|
49
|
+
readonly sessionId: string;
|
|
50
|
+
readonly startedAt: string;
|
|
51
|
+
readonly getWorkspaceStatus: () => WorkspaceStatus;
|
|
52
|
+
readonly getRuntimeCausalContext: () => RuntimeCausalContext | null;
|
|
53
|
+
lastActivityAt: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AuthorizedWorkspaceRecord {
|
|
57
|
+
readonly repoId: string;
|
|
58
|
+
readonly worktreeId: string;
|
|
59
|
+
readonly worktreeRoot: string;
|
|
60
|
+
readonly gitCommonDir: string;
|
|
61
|
+
readonly capabilityProfile: WorkspaceCapabilityProfile;
|
|
62
|
+
readonly authorizedAt: string;
|
|
63
|
+
readonly lastBoundAt: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface AuthorizedWorkspaceView extends AuthorizedWorkspaceRecord {
|
|
67
|
+
readonly activeSessions: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface WorkspaceAuthorizeRequest extends WorkspaceBindRequest {
|
|
71
|
+
readonly runCapture?: boolean | undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface WorkspaceAuthorizeResult {
|
|
75
|
+
readonly ok: boolean;
|
|
76
|
+
readonly changed: boolean;
|
|
77
|
+
readonly authorization?: AuthorizedWorkspaceView;
|
|
78
|
+
readonly errorCode?: string;
|
|
79
|
+
readonly error?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface WorkspaceRevokeResult {
|
|
83
|
+
readonly ok: boolean;
|
|
84
|
+
readonly revoked: boolean;
|
|
85
|
+
readonly repoId?: string | null;
|
|
86
|
+
readonly worktreeId?: string | null;
|
|
87
|
+
readonly worktreeRoot?: string | null;
|
|
88
|
+
readonly activeSessions?: number;
|
|
89
|
+
readonly errorCode?: string;
|
|
90
|
+
readonly error?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface DaemonSessionView {
|
|
94
|
+
readonly sessionId: string;
|
|
95
|
+
readonly sessionMode: "daemon";
|
|
96
|
+
readonly bindState: WorkspaceStatus["bindState"];
|
|
97
|
+
readonly repoId: string | null;
|
|
98
|
+
readonly worktreeId: string | null;
|
|
99
|
+
readonly worktreeRoot: string | null;
|
|
100
|
+
readonly causalSessionId: string | null;
|
|
101
|
+
readonly checkoutEpochId: string | null;
|
|
102
|
+
readonly capabilityProfile: WorkspaceCapabilityProfile | null;
|
|
103
|
+
readonly startedAt: string;
|
|
104
|
+
readonly lastActivityAt: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface DaemonRuntimeDescriptor {
|
|
108
|
+
readonly transport: "unix_socket" | "named_pipe";
|
|
109
|
+
readonly sameUserOnly: true;
|
|
110
|
+
readonly socketPath: string;
|
|
111
|
+
readonly mcpPath: string;
|
|
112
|
+
readonly healthPath: string;
|
|
113
|
+
readonly activeWarpRepos: number;
|
|
114
|
+
readonly startedAt: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface SharedAttachSource {
|
|
118
|
+
readonly sourceSessionId: string;
|
|
119
|
+
readonly causalSessionId: string;
|
|
120
|
+
readonly strandId: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface DaemonMonitorCounts {
|
|
124
|
+
readonly totalMonitors: number;
|
|
125
|
+
readonly runningMonitors: number;
|
|
126
|
+
readonly pausedMonitors: number;
|
|
127
|
+
readonly stoppedMonitors: number;
|
|
128
|
+
readonly failingMonitors: number;
|
|
129
|
+
readonly backlogMonitors: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface DaemonStatusView extends DaemonRuntimeDescriptor {
|
|
133
|
+
readonly ok: true;
|
|
134
|
+
readonly sessionMode: "daemon";
|
|
135
|
+
readonly activeSessions: number;
|
|
136
|
+
readonly boundSessions: number;
|
|
137
|
+
readonly unboundSessions: number;
|
|
138
|
+
readonly authorizedWorkspaces: number;
|
|
139
|
+
readonly authorizedRepos: number;
|
|
140
|
+
readonly workspaceBindRequiresAuthorization: true;
|
|
141
|
+
readonly defaultCapabilityProfile: WorkspaceCapabilityProfile;
|
|
142
|
+
readonly totalMonitors: number;
|
|
143
|
+
readonly runningMonitors: number;
|
|
144
|
+
readonly pausedMonitors: number;
|
|
145
|
+
readonly stoppedMonitors: number;
|
|
146
|
+
readonly failingMonitors: number;
|
|
147
|
+
readonly backlogMonitors: number;
|
|
148
|
+
readonly scheduler: DaemonSchedulerCounts;
|
|
149
|
+
readonly workers: DaemonWorkerCounts;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface DaemonControlPlaneOptions {
|
|
153
|
+
readonly fs: FileSystem;
|
|
154
|
+
readonly codec: JsonCodec;
|
|
155
|
+
readonly git: GitClient;
|
|
156
|
+
readonly graftDir: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function cloneCapabilityProfile(
|
|
160
|
+
profile: WorkspaceCapabilityProfile,
|
|
161
|
+
): WorkspaceCapabilityProfile {
|
|
162
|
+
return { ...profile };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function capabilityProfilesEqual(
|
|
166
|
+
left: WorkspaceCapabilityProfile,
|
|
167
|
+
right: WorkspaceCapabilityProfile,
|
|
168
|
+
): boolean {
|
|
169
|
+
return left.boundedReads === right.boundedReads
|
|
170
|
+
&& left.structuralTools === right.structuralTools
|
|
171
|
+
&& left.precisionTools === right.precisionTools
|
|
172
|
+
&& left.stateBookmarks === right.stateBookmarks
|
|
173
|
+
&& left.runCapture === right.runCapture;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function resolveCapabilityProfile(
|
|
177
|
+
current: WorkspaceCapabilityProfile | undefined,
|
|
178
|
+
runCapture: boolean | undefined,
|
|
179
|
+
): WorkspaceCapabilityProfile {
|
|
180
|
+
return {
|
|
181
|
+
...(current ?? DEFAULT_DAEMON_CAPABILITY_PROFILE),
|
|
182
|
+
...(runCapture !== undefined ? { runCapture } : {}),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function sortByWorktreeRoot(records: readonly AuthorizedWorkspaceRecord[]): AuthorizedWorkspaceRecord[] {
|
|
187
|
+
return [...records].sort((left, right) => left.worktreeRoot.localeCompare(right.worktreeRoot));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function cloneAuthorizedWorkspaceRecord(record: AuthorizedWorkspaceRecord): AuthorizedWorkspaceRecord {
|
|
191
|
+
return {
|
|
192
|
+
...record,
|
|
193
|
+
capabilityProfile: cloneCapabilityProfile(record.capabilityProfile),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const ZERO_MONITOR_COUNTS: DaemonMonitorCounts = Object.freeze({
|
|
198
|
+
totalMonitors: 0,
|
|
199
|
+
runningMonitors: 0,
|
|
200
|
+
pausedMonitors: 0,
|
|
201
|
+
stoppedMonitors: 0,
|
|
202
|
+
failingMonitors: 0,
|
|
203
|
+
backlogMonitors: 0,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
export class DaemonControlPlane {
|
|
207
|
+
private readonly statePath: string;
|
|
208
|
+
private readonly authorizedWorkspaces = new Map<string, AuthorizedWorkspaceRecord>();
|
|
209
|
+
private readonly sessions = new Map<string, RegisteredSession>();
|
|
210
|
+
private loadPromise: Promise<void> | null = null;
|
|
211
|
+
|
|
212
|
+
constructor(private readonly options: DaemonControlPlaneOptions) {
|
|
213
|
+
this.statePath = path.join(
|
|
214
|
+
path.resolve(options.graftDir),
|
|
215
|
+
CONTROL_PLANE_DIR,
|
|
216
|
+
AUTHORIZED_WORKSPACES_FILE,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
initialize(): Promise<void> {
|
|
221
|
+
return this.ensureLoaded();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
registerSession(
|
|
225
|
+
sessionId: string,
|
|
226
|
+
getWorkspaceStatus: () => WorkspaceStatus,
|
|
227
|
+
getRuntimeCausalContext: () => RuntimeCausalContext | null,
|
|
228
|
+
): void {
|
|
229
|
+
const now = new Date().toISOString();
|
|
230
|
+
this.sessions.set(sessionId, {
|
|
231
|
+
sessionId,
|
|
232
|
+
startedAt: now,
|
|
233
|
+
lastActivityAt: now,
|
|
234
|
+
getWorkspaceStatus,
|
|
235
|
+
getRuntimeCausalContext,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
touchSession(sessionId: string): void {
|
|
240
|
+
const session = this.sessions.get(sessionId);
|
|
241
|
+
if (session === undefined) return;
|
|
242
|
+
session.lastActivityAt = new Date().toISOString();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
unregisterSession(sessionId: string): void {
|
|
246
|
+
this.sessions.delete(sessionId);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
resolveSharedAttachSource(input: {
|
|
250
|
+
readonly sessionId: string;
|
|
251
|
+
readonly repoId: string;
|
|
252
|
+
readonly worktreeId: string;
|
|
253
|
+
}): SharedAttachSource | null {
|
|
254
|
+
const candidates = [...this.sessions.values()]
|
|
255
|
+
.filter((session) => session.sessionId !== input.sessionId)
|
|
256
|
+
.flatMap((session) => {
|
|
257
|
+
const status = this.readWorkspaceStatus(session);
|
|
258
|
+
if (
|
|
259
|
+
status.bindState !== "bound" ||
|
|
260
|
+
status.repoId !== input.repoId ||
|
|
261
|
+
status.worktreeId !== input.worktreeId
|
|
262
|
+
) {
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
const causalContext = this.readRuntimeCausalContext(session);
|
|
266
|
+
if (causalContext === null) {
|
|
267
|
+
return [];
|
|
268
|
+
}
|
|
269
|
+
return [{
|
|
270
|
+
sourceSessionId: session.sessionId,
|
|
271
|
+
causalSessionId: causalContext.causalSessionId,
|
|
272
|
+
strandId: causalContext.strandId,
|
|
273
|
+
lastActivityAt: session.lastActivityAt,
|
|
274
|
+
}];
|
|
275
|
+
})
|
|
276
|
+
.sort((left, right) => right.lastActivityAt.localeCompare(left.lastActivityAt));
|
|
277
|
+
|
|
278
|
+
if (candidates.length !== 1) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const [source] = candidates;
|
|
283
|
+
if (source === undefined) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
sourceSessionId: source.sourceSessionId,
|
|
289
|
+
causalSessionId: source.causalSessionId,
|
|
290
|
+
strandId: source.strandId,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async authorizeWorkspace(request: WorkspaceAuthorizeRequest): Promise<WorkspaceAuthorizeResult> {
|
|
295
|
+
const resolved = await resolveWorkspaceRequest(this.options.git, request);
|
|
296
|
+
if ("code" in resolved) {
|
|
297
|
+
return {
|
|
298
|
+
ok: false,
|
|
299
|
+
changed: false,
|
|
300
|
+
errorCode: resolved.code,
|
|
301
|
+
error: resolved.message,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
await this.ensureLoaded();
|
|
306
|
+
const current = this.authorizedWorkspaces.get(resolved.worktreeId);
|
|
307
|
+
const nextCapabilityProfile = resolveCapabilityProfile(current?.capabilityProfile, request.runCapture);
|
|
308
|
+
const next: AuthorizedWorkspaceRecord = {
|
|
309
|
+
...resolved,
|
|
310
|
+
capabilityProfile: nextCapabilityProfile,
|
|
311
|
+
authorizedAt: current?.authorizedAt ?? new Date().toISOString(),
|
|
312
|
+
lastBoundAt: current?.lastBoundAt ?? null,
|
|
313
|
+
};
|
|
314
|
+
const changed = current === undefined
|
|
315
|
+
? true
|
|
316
|
+
: current.repoId !== next.repoId
|
|
317
|
+
|| current.worktreeRoot !== next.worktreeRoot
|
|
318
|
+
|| current.gitCommonDir !== next.gitCommonDir
|
|
319
|
+
|| !capabilityProfilesEqual(current.capabilityProfile, next.capabilityProfile);
|
|
320
|
+
|
|
321
|
+
this.authorizedWorkspaces.set(next.worktreeId, next);
|
|
322
|
+
await this.persist();
|
|
323
|
+
return {
|
|
324
|
+
ok: true,
|
|
325
|
+
changed,
|
|
326
|
+
authorization: this.toAuthorizedWorkspaceView(next),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async revokeWorkspace(request: WorkspaceBindRequest): Promise<WorkspaceRevokeResult> {
|
|
331
|
+
const resolved = await resolveWorkspaceRequest(this.options.git, request);
|
|
332
|
+
if ("code" in resolved) {
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
revoked: false,
|
|
336
|
+
errorCode: resolved.code,
|
|
337
|
+
error: resolved.message,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
await this.ensureLoaded();
|
|
342
|
+
const current = this.authorizedWorkspaces.get(resolved.worktreeId);
|
|
343
|
+
if (current === undefined) {
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
revoked: false,
|
|
347
|
+
repoId: resolved.repoId,
|
|
348
|
+
worktreeId: resolved.worktreeId,
|
|
349
|
+
worktreeRoot: resolved.worktreeRoot,
|
|
350
|
+
activeSessions: this.activeSessionsFor(resolved.worktreeId),
|
|
351
|
+
errorCode: "WORKSPACE_NOT_AUTHORIZED",
|
|
352
|
+
error: `Workspace ${resolved.worktreeRoot} is not authorized.`,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
this.authorizedWorkspaces.delete(resolved.worktreeId);
|
|
357
|
+
await this.persist();
|
|
358
|
+
return {
|
|
359
|
+
ok: true,
|
|
360
|
+
revoked: true,
|
|
361
|
+
repoId: current.repoId,
|
|
362
|
+
worktreeId: current.worktreeId,
|
|
363
|
+
worktreeRoot: current.worktreeRoot,
|
|
364
|
+
activeSessions: this.activeSessionsFor(current.worktreeId),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async getCapabilityProfile(resolved: ResolvedWorkspace): Promise<WorkspaceCapabilityProfile | null> {
|
|
369
|
+
await this.ensureLoaded();
|
|
370
|
+
const record = this.authorizedWorkspaces.get(resolved.worktreeId);
|
|
371
|
+
return record === undefined ? null : cloneCapabilityProfile(record.capabilityProfile);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async noteBound(resolved: ResolvedWorkspace): Promise<void> {
|
|
375
|
+
await this.ensureLoaded();
|
|
376
|
+
const current = this.authorizedWorkspaces.get(resolved.worktreeId);
|
|
377
|
+
if (current === undefined) return;
|
|
378
|
+
this.authorizedWorkspaces.set(resolved.worktreeId, {
|
|
379
|
+
...current,
|
|
380
|
+
lastBoundAt: new Date().toISOString(),
|
|
381
|
+
});
|
|
382
|
+
await this.persist();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async listAuthorizedWorkspaces(): Promise<readonly AuthorizedWorkspaceView[]> {
|
|
386
|
+
await this.ensureLoaded();
|
|
387
|
+
return sortByWorktreeRoot([...this.authorizedWorkspaces.values()]).map((record) => {
|
|
388
|
+
return this.toAuthorizedWorkspaceView(record);
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async listAuthorizedWorkspaceRecords(): Promise<readonly AuthorizedWorkspaceRecord[]> {
|
|
393
|
+
await this.ensureLoaded();
|
|
394
|
+
return sortByWorktreeRoot([...this.authorizedWorkspaces.values()]).map(cloneAuthorizedWorkspaceRecord);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async getAuthorizedWorkspace(request: WorkspaceBindRequest): Promise<AuthorizedWorkspaceRecord | null> {
|
|
398
|
+
const resolved = await resolveWorkspaceRequest(this.options.git, request);
|
|
399
|
+
if ("code" in resolved) return null;
|
|
400
|
+
await this.ensureLoaded();
|
|
401
|
+
const record = this.authorizedWorkspaces.get(resolved.worktreeId);
|
|
402
|
+
return record === undefined ? null : cloneAuthorizedWorkspaceRecord(record);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async getAuthorizedWorkspaceForRepo(
|
|
406
|
+
repoId: string,
|
|
407
|
+
preferredWorktreeRoot?: string,
|
|
408
|
+
): Promise<AuthorizedWorkspaceRecord | null> {
|
|
409
|
+
await this.ensureLoaded();
|
|
410
|
+
const matches = [...this.authorizedWorkspaces.values()].filter((record) => record.repoId === repoId);
|
|
411
|
+
if (matches.length === 0) return null;
|
|
412
|
+
if (preferredWorktreeRoot !== undefined) {
|
|
413
|
+
const preferred = matches.find((record) => record.worktreeRoot === preferredWorktreeRoot);
|
|
414
|
+
if (preferred !== undefined) {
|
|
415
|
+
return cloneAuthorizedWorkspaceRecord(preferred);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const [first] = sortByWorktreeRoot(matches);
|
|
419
|
+
if (first === undefined) return null;
|
|
420
|
+
return cloneAuthorizedWorkspaceRecord(first);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
listSessions(): readonly DaemonSessionView[] {
|
|
424
|
+
return [...this.sessions.values()]
|
|
425
|
+
.map((session) => this.toDaemonSessionView(session))
|
|
426
|
+
.sort((left, right) => left.startedAt.localeCompare(right.startedAt));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
getStatus(
|
|
430
|
+
runtime: DaemonRuntimeDescriptor,
|
|
431
|
+
monitorCounts: DaemonMonitorCounts = ZERO_MONITOR_COUNTS,
|
|
432
|
+
schedulerCounts: DaemonSchedulerCounts = ZERO_SCHEDULER_COUNTS,
|
|
433
|
+
workerCounts: DaemonWorkerCounts,
|
|
434
|
+
): DaemonStatusView {
|
|
435
|
+
const sessions = this.listSessions();
|
|
436
|
+
const boundSessions = sessions.filter((session) => session.bindState === "bound").length;
|
|
437
|
+
return {
|
|
438
|
+
ok: true,
|
|
439
|
+
sessionMode: "daemon",
|
|
440
|
+
activeSessions: sessions.length,
|
|
441
|
+
boundSessions,
|
|
442
|
+
unboundSessions: sessions.length - boundSessions,
|
|
443
|
+
authorizedWorkspaces: this.authorizedWorkspaces.size,
|
|
444
|
+
authorizedRepos: new Set([...this.authorizedWorkspaces.values()].map((record) => record.repoId)).size,
|
|
445
|
+
workspaceBindRequiresAuthorization: true,
|
|
446
|
+
defaultCapabilityProfile: cloneCapabilityProfile(DEFAULT_DAEMON_CAPABILITY_PROFILE),
|
|
447
|
+
totalMonitors: monitorCounts.totalMonitors,
|
|
448
|
+
runningMonitors: monitorCounts.runningMonitors,
|
|
449
|
+
pausedMonitors: monitorCounts.pausedMonitors,
|
|
450
|
+
stoppedMonitors: monitorCounts.stoppedMonitors,
|
|
451
|
+
failingMonitors: monitorCounts.failingMonitors,
|
|
452
|
+
backlogMonitors: monitorCounts.backlogMonitors,
|
|
453
|
+
scheduler: schedulerCounts,
|
|
454
|
+
workers: workerCounts,
|
|
455
|
+
...runtime,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private async ensureLoaded(): Promise<void> {
|
|
460
|
+
if (this.loadPromise !== null) {
|
|
461
|
+
await this.loadPromise;
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
this.loadPromise = (async () => {
|
|
466
|
+
const raw = await this.options.fs.readFile(this.statePath, "utf-8").catch((error: unknown) => {
|
|
467
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
throw error;
|
|
471
|
+
});
|
|
472
|
+
if (raw === null) return;
|
|
473
|
+
|
|
474
|
+
const decoded = persistedControlPlaneStateSchema.parse(this.options.codec.decode(raw));
|
|
475
|
+
for (const record of decoded.workspaces) {
|
|
476
|
+
this.authorizedWorkspaces.set(record.worktreeId, {
|
|
477
|
+
repoId: record.repoId,
|
|
478
|
+
worktreeId: record.worktreeId,
|
|
479
|
+
worktreeRoot: record.worktreeRoot,
|
|
480
|
+
gitCommonDir: record.gitCommonDir,
|
|
481
|
+
capabilityProfile: cloneCapabilityProfile(record.capabilityProfile),
|
|
482
|
+
authorizedAt: record.authorizedAt,
|
|
483
|
+
lastBoundAt: record.lastBoundAt,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
})();
|
|
487
|
+
|
|
488
|
+
await this.loadPromise;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private async persist(): Promise<void> {
|
|
492
|
+
const payload: PersistedControlPlaneState = {
|
|
493
|
+
version: 1,
|
|
494
|
+
workspaces: sortByWorktreeRoot([...this.authorizedWorkspaces.values()]),
|
|
495
|
+
};
|
|
496
|
+
await this.options.fs.mkdir(path.dirname(this.statePath), { recursive: true });
|
|
497
|
+
await this.options.fs.writeFile(this.statePath, this.options.codec.encode(payload), "utf-8");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private activeSessionsFor(worktreeId: string): number {
|
|
501
|
+
return this.listSessions().filter((session) => session.worktreeId === worktreeId).length;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private toAuthorizedWorkspaceView(record: AuthorizedWorkspaceRecord): AuthorizedWorkspaceView {
|
|
505
|
+
return {
|
|
506
|
+
...record,
|
|
507
|
+
capabilityProfile: cloneCapabilityProfile(record.capabilityProfile),
|
|
508
|
+
activeSessions: this.activeSessionsFor(record.worktreeId),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private toDaemonSessionView(session: RegisteredSession): DaemonSessionView {
|
|
513
|
+
const status = this.readWorkspaceStatus(session);
|
|
514
|
+
const causalContext = this.readRuntimeCausalContext(session);
|
|
515
|
+
return {
|
|
516
|
+
sessionId: session.sessionId,
|
|
517
|
+
sessionMode: "daemon",
|
|
518
|
+
bindState: status.bindState,
|
|
519
|
+
repoId: status.repoId,
|
|
520
|
+
worktreeId: status.worktreeId,
|
|
521
|
+
worktreeRoot: status.worktreeRoot,
|
|
522
|
+
causalSessionId: causalContext?.causalSessionId ?? null,
|
|
523
|
+
checkoutEpochId: causalContext?.checkoutEpochId ?? null,
|
|
524
|
+
capabilityProfile: status.capabilityProfile === null ? null : cloneCapabilityProfile(status.capabilityProfile),
|
|
525
|
+
startedAt: session.startedAt,
|
|
526
|
+
lastActivityAt: session.lastActivityAt,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private readWorkspaceStatus(session: RegisteredSession): WorkspaceStatus {
|
|
531
|
+
try {
|
|
532
|
+
return session.getWorkspaceStatus();
|
|
533
|
+
} catch {
|
|
534
|
+
return {
|
|
535
|
+
sessionMode: "daemon",
|
|
536
|
+
bindState: "unbound",
|
|
537
|
+
repoId: null,
|
|
538
|
+
worktreeId: null,
|
|
539
|
+
worktreeRoot: null,
|
|
540
|
+
gitCommonDir: null,
|
|
541
|
+
graftDir: null,
|
|
542
|
+
capabilityProfile: null,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private readRuntimeCausalContext(session: RegisteredSession): RuntimeCausalContext | null {
|
|
548
|
+
try {
|
|
549
|
+
return session.getRuntimeCausalContext();
|
|
550
|
+
} catch {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|