@martinloop/mcp 0.8.9 → 0.9.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/README.md +2 -0
- package/dist/package-version.d.ts +1 -1
- package/dist/package-version.js +1 -1
- package/dist/server-validation.js +2 -3
- package/dist/server.d.ts +1 -0
- package/dist/server.js +181 -24
- package/dist/tools/get-status.d.ts +3 -0
- package/dist/tools/get-status.js +11 -2
- package/dist/tools/logs.d.ts +2 -0
- package/dist/tools/logs.js +25 -3
- package/dist/tools/run-controls.d.ts +51 -6
- package/dist/tools/run-controls.js +905 -35
- package/dist/tools/run-loop.js +7 -0
- package/dist/vendor/adapters/process-supervisor.d.ts +12 -0
- package/dist/vendor/adapters/process-supervisor.js +40 -2
- package/dist/vendor/adapters/verifier-only.js +4 -0
- package/dist/vendor/contracts/index.d.ts +8 -0
- package/dist/vendor/contracts/index.js +1 -0
- package/dist/vendor/contracts/swarm-timing.d.ts +7 -0
- package/dist/vendor/contracts/swarm-timing.js +22 -0
- package/dist/vendor/core/index.d.ts +37 -2
- package/dist/vendor/core/index.js +120 -14
- package/dist/vendor/core/persistence/index.d.ts +1 -1
- package/dist/vendor/core/persistence/index.js +1 -1
- package/dist/vendor/core/persistence/integrity.d.ts +1 -0
- package/dist/vendor/core/persistence/integrity.js +1 -1
- package/dist/vendor/core/policy.js +11 -1
- package/dist/vendor/core/resume.d.ts +25 -1
- package/dist/vendor/core/resume.js +187 -13
- package/dist/vendor/core/rollback.d.ts +14 -0
- package/dist/vendor/core/rollback.js +91 -34
- package/dist/vendor/core/swarm/live-runtime.js +2 -0
- package/dist/vendor/core/verified-handoff.js +62 -2
- package/package.json +3 -7
- package/server.json +2 -2
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
import type { LoopRecord, RollbackBoundaryArtifact } from "../contracts/index.js";
|
|
2
2
|
export interface RunExecutionLease {
|
|
3
|
-
schemaVersion: "martin.execution-lease.
|
|
3
|
+
schemaVersion: "martin.execution-lease.v2";
|
|
4
4
|
runId: string;
|
|
5
5
|
token: string;
|
|
6
6
|
owner: string;
|
|
7
7
|
pid: number;
|
|
8
8
|
hostname: string;
|
|
9
9
|
acquiredAt: string;
|
|
10
|
+
capabilities: {
|
|
11
|
+
runControl: boolean;
|
|
12
|
+
};
|
|
13
|
+
processInstanceId: string;
|
|
14
|
+
heartbeatAt: string;
|
|
15
|
+
heartbeatSequence: number;
|
|
10
16
|
}
|
|
11
17
|
export interface AcquiredRunExecutionLease {
|
|
12
18
|
lease: RunExecutionLease;
|
|
13
19
|
path: string;
|
|
14
20
|
release(): Promise<void>;
|
|
15
21
|
}
|
|
22
|
+
export interface ActiveRunExecutionLease {
|
|
23
|
+
lease: RunExecutionLease;
|
|
24
|
+
path: string;
|
|
25
|
+
}
|
|
16
26
|
export interface ResumeVerifierFailure {
|
|
17
27
|
command: string;
|
|
18
28
|
exitCode?: number;
|
|
@@ -36,4 +46,18 @@ export declare function acquireRunExecutionLease(input: {
|
|
|
36
46
|
now?: () => string;
|
|
37
47
|
host?: string;
|
|
38
48
|
pid?: number;
|
|
49
|
+
runControlCapable?: boolean;
|
|
50
|
+
}, hooks?: {
|
|
51
|
+
beforeStaleReclaim?: () => Promise<void>;
|
|
39
52
|
}): Promise<AcquiredRunExecutionLease>;
|
|
53
|
+
/**
|
|
54
|
+
* Returns the persisted execution lease only when its schema, run identity,
|
|
55
|
+
* supported owner and local process liveness are all authoritative. Control
|
|
56
|
+
* surfaces use this to avoid accepting commands for an abandoned or incapable
|
|
57
|
+
* executor.
|
|
58
|
+
*/
|
|
59
|
+
export declare function readActiveRunExecutionLease(input: {
|
|
60
|
+
runsRoot: string;
|
|
61
|
+
runId: string;
|
|
62
|
+
host?: string;
|
|
63
|
+
}): Promise<ActiveRunExecutionLease | undefined>;
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { mkdir, open, readFile, unlink } from "node:fs/promises";
|
|
3
|
+
import { link, lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
|
|
4
4
|
import { hostname } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import {
|
|
6
|
+
import { performance } from "node:perf_hooks";
|
|
7
|
+
import { artifactDir, resolveReceiptIntegrityRoot } from "./persistence/index.js";
|
|
8
|
+
import { authenticateManagedPathExclusions, RollbackStateUnavailableError } from "./rollback.js";
|
|
9
|
+
const locallyOwnedExecutionLeases = new Map();
|
|
7
10
|
export function evaluateRunResumability(loop) {
|
|
8
11
|
if (loop.terminationEnvelope) {
|
|
9
12
|
return { allowed: false, reason: "The run already has authoritative terminal evidence." };
|
|
@@ -73,7 +76,14 @@ export async function loadResumeCandidateBoundary(runsRoot, loop) {
|
|
|
73
76
|
}
|
|
74
77
|
throw error;
|
|
75
78
|
});
|
|
76
|
-
|
|
79
|
+
const boundary = JSON.parse(raw);
|
|
80
|
+
if (loop.task.repoRoot) {
|
|
81
|
+
authenticateManagedPathExclusions(loop.task.repoRoot, boundary, [runsRoot, resolveReceiptIntegrityRoot()]);
|
|
82
|
+
}
|
|
83
|
+
else if (boundary.managedPathExclusions !== undefined) {
|
|
84
|
+
throw new RollbackStateUnavailableError("Rollback boundary managed path exclusions cannot be authenticated without a repository root.");
|
|
85
|
+
}
|
|
86
|
+
return boundary;
|
|
77
87
|
}
|
|
78
88
|
export async function loadResumeVerifierFailure(runsRoot, loop) {
|
|
79
89
|
const attempt = loop.attempts.at(-1);
|
|
@@ -106,7 +116,7 @@ export async function loadResumeVerifierFailure(runsRoot, loop) {
|
|
|
106
116
|
attemptIndex: attempt.index
|
|
107
117
|
};
|
|
108
118
|
}
|
|
109
|
-
export async function acquireRunExecutionLease(input) {
|
|
119
|
+
export async function acquireRunExecutionLease(input, hooks = {}) {
|
|
110
120
|
const now = input.now ?? (() => new Date().toISOString());
|
|
111
121
|
const host = input.host ?? hostname();
|
|
112
122
|
const pid = input.pid ?? process.pid;
|
|
@@ -115,14 +125,18 @@ export async function acquireRunExecutionLease(input) {
|
|
|
115
125
|
const path = join(directory, leaseFile);
|
|
116
126
|
await mkdir(directory, { recursive: true });
|
|
117
127
|
const token = `${pid}-${randomBytes(16).toString("hex")}`;
|
|
118
|
-
|
|
119
|
-
schemaVersion: "martin.execution-lease.
|
|
128
|
+
let lease = {
|
|
129
|
+
schemaVersion: "martin.execution-lease.v2",
|
|
120
130
|
runId: input.runId,
|
|
121
131
|
token,
|
|
122
132
|
owner: input.owner,
|
|
123
133
|
pid,
|
|
124
134
|
hostname: host,
|
|
125
|
-
acquiredAt: now()
|
|
135
|
+
acquiredAt: now(),
|
|
136
|
+
capabilities: { runControl: input.runControlCapable === true },
|
|
137
|
+
processInstanceId: randomBytes(16).toString("hex"),
|
|
138
|
+
heartbeatAt: wallClockIso(),
|
|
139
|
+
heartbeatSequence: 0
|
|
126
140
|
};
|
|
127
141
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
128
142
|
try {
|
|
@@ -134,10 +148,36 @@ export async function acquireRunExecutionLease(input) {
|
|
|
134
148
|
finally {
|
|
135
149
|
await handle.close();
|
|
136
150
|
}
|
|
151
|
+
const heartbeatPath = executionLeaseHeartbeatPath(path, lease.processInstanceId);
|
|
152
|
+
await writeLeaseAtomically(heartbeatPath, lease);
|
|
153
|
+
locallyOwnedExecutionLeases.set(path, `${lease.token}:${lease.processInstanceId}`);
|
|
154
|
+
let active = true;
|
|
155
|
+
let heartbeatPromise;
|
|
156
|
+
const heartbeat = setInterval(() => {
|
|
157
|
+
if (!active || heartbeatPromise)
|
|
158
|
+
return;
|
|
159
|
+
const nextLease = {
|
|
160
|
+
...lease,
|
|
161
|
+
heartbeatAt: wallClockIso(),
|
|
162
|
+
heartbeatSequence: lease.heartbeatSequence + 1
|
|
163
|
+
};
|
|
164
|
+
heartbeatPromise = writeLeaseAtomically(heartbeatPath, nextLease)
|
|
165
|
+
.then(() => { lease = nextLease; })
|
|
166
|
+
.catch(() => undefined)
|
|
167
|
+
.finally(() => { heartbeatPromise = undefined; });
|
|
168
|
+
}, 500);
|
|
169
|
+
heartbeat.unref();
|
|
137
170
|
return {
|
|
138
171
|
lease,
|
|
139
172
|
path,
|
|
140
173
|
async release() {
|
|
174
|
+
active = false;
|
|
175
|
+
clearInterval(heartbeat);
|
|
176
|
+
await heartbeatPromise;
|
|
177
|
+
if (locallyOwnedExecutionLeases.get(path) ===
|
|
178
|
+
`${lease.token}:${lease.processInstanceId}`) {
|
|
179
|
+
locallyOwnedExecutionLeases.delete(path);
|
|
180
|
+
}
|
|
141
181
|
try {
|
|
142
182
|
const existing = JSON.parse(await readFile(path, "utf8"));
|
|
143
183
|
if (existing.token === token)
|
|
@@ -147,29 +187,163 @@ export async function acquireRunExecutionLease(input) {
|
|
|
147
187
|
if (error.code !== "ENOENT")
|
|
148
188
|
throw error;
|
|
149
189
|
}
|
|
190
|
+
await unlink(heartbeatPath).catch((error) => {
|
|
191
|
+
if (error.code !== "ENOENT")
|
|
192
|
+
throw error;
|
|
193
|
+
});
|
|
150
194
|
}
|
|
151
195
|
};
|
|
152
196
|
}
|
|
153
197
|
catch (error) {
|
|
154
198
|
if (error.code !== "EEXIST")
|
|
155
199
|
throw error;
|
|
156
|
-
const existing = await
|
|
200
|
+
const existing = await readLeaseWithHeartbeat(path);
|
|
157
201
|
if (!existing)
|
|
158
202
|
continue;
|
|
159
203
|
if (existing.hostname !== host) {
|
|
160
204
|
throw new Error(`Run ${input.runId} is leased by ${existing.owner} on ${existing.hostname}; refusing cross-host takeover.`);
|
|
161
205
|
}
|
|
162
|
-
if (isProcessAlive(existing.pid)) {
|
|
206
|
+
if (isProcessAlive(existing.pid) && isFreshExecutionLease(existing)) {
|
|
163
207
|
throw new Error(`Run ${input.runId} is already executing under ${existing.owner} (pid ${existing.pid}).`);
|
|
164
208
|
}
|
|
165
|
-
await
|
|
166
|
-
|
|
167
|
-
throw unlinkError;
|
|
168
|
-
});
|
|
209
|
+
await hooks.beforeStaleReclaim?.();
|
|
210
|
+
await reclaimStaleExecutionLease(path, existing);
|
|
169
211
|
}
|
|
170
212
|
}
|
|
171
213
|
throw new Error(`Unable to acquire execution lease for run ${input.runId}.`);
|
|
172
214
|
}
|
|
215
|
+
async function reclaimStaleExecutionLease(path, observed) {
|
|
216
|
+
const claimPath = `${path}.reclaim-${createHash("sha256")
|
|
217
|
+
.update(`${observed.token}:${observed.processInstanceId}`)
|
|
218
|
+
.digest("hex")
|
|
219
|
+
.slice(0, 24)}`;
|
|
220
|
+
try {
|
|
221
|
+
// The deterministic hard-link claim atomically elects one reclaimer for
|
|
222
|
+
// the observed lease inode. A stale observer can never unlink a successor.
|
|
223
|
+
await link(path, claimPath);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
const code = error.code;
|
|
227
|
+
if (code === "EEXIST" || code === "ENOENT")
|
|
228
|
+
return false;
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const claimedStat = await lstat(claimPath);
|
|
233
|
+
const claimed = await readLease(claimPath);
|
|
234
|
+
const currentStat = await lstat(path).catch((error) => {
|
|
235
|
+
if (error.code === "ENOENT")
|
|
236
|
+
return undefined;
|
|
237
|
+
throw error;
|
|
238
|
+
});
|
|
239
|
+
if (!claimed ||
|
|
240
|
+
!currentStat ||
|
|
241
|
+
claimed.token !== observed.token ||
|
|
242
|
+
claimed.processInstanceId !== observed.processInstanceId ||
|
|
243
|
+
claimedStat.dev !== currentStat.dev ||
|
|
244
|
+
claimedStat.ino !== currentStat.ino) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
await unlink(path);
|
|
248
|
+
await unlink(executionLeaseHeartbeatPath(path, observed.processInstanceId)).catch((error) => {
|
|
249
|
+
if (error.code !== "ENOENT")
|
|
250
|
+
throw error;
|
|
251
|
+
});
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
await unlink(claimPath).catch(() => undefined);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Returns the persisted execution lease only when its schema, run identity,
|
|
260
|
+
* supported owner and local process liveness are all authoritative. Control
|
|
261
|
+
* surfaces use this to avoid accepting commands for an abandoned or incapable
|
|
262
|
+
* executor.
|
|
263
|
+
*/
|
|
264
|
+
export async function readActiveRunExecutionLease(input) {
|
|
265
|
+
const host = input.host ?? hostname();
|
|
266
|
+
const directory = join(input.runsRoot, ".execution-leases");
|
|
267
|
+
const leaseFile = `${createHash("sha256").update(input.runId).digest("hex")}.json`;
|
|
268
|
+
const path = join(directory, leaseFile);
|
|
269
|
+
const lease = await readLeaseWithHeartbeat(path);
|
|
270
|
+
if (!isAdmissibleActiveLease(lease, input.runId, host))
|
|
271
|
+
return undefined;
|
|
272
|
+
// Same-process executors have a stronger identity proof than a sampled
|
|
273
|
+
// heartbeat: the unguessable token/instance pair is held in live memory.
|
|
274
|
+
// A stale lease whose PID was reused cannot satisfy this binding.
|
|
275
|
+
if (lease.pid === process.pid &&
|
|
276
|
+
locallyOwnedExecutionLeases.get(path) === `${lease.token}:${lease.processInstanceId}`) {
|
|
277
|
+
return { lease, path };
|
|
278
|
+
}
|
|
279
|
+
// A PID can be reused after the former owner crashes. Require the same
|
|
280
|
+
// unguessable process instance to advance its heartbeat before admitting a
|
|
281
|
+
// mutation; PID liveness alone is never authority.
|
|
282
|
+
await new Promise((resolve) => setTimeout(resolve, 650));
|
|
283
|
+
const confirmed = await readLeaseWithHeartbeat(path);
|
|
284
|
+
if (!isAdmissibleActiveLease(confirmed, input.runId, host) ||
|
|
285
|
+
confirmed.token !== lease.token ||
|
|
286
|
+
confirmed.processInstanceId !== lease.processInstanceId ||
|
|
287
|
+
confirmed.heartbeatSequence <= lease.heartbeatSequence) {
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
return { lease: confirmed, path };
|
|
291
|
+
}
|
|
292
|
+
function isAdmissibleActiveLease(lease, runId, host) {
|
|
293
|
+
return Boolean(lease &&
|
|
294
|
+
lease.schemaVersion === "martin.execution-lease.v2" &&
|
|
295
|
+
lease.runId === runId &&
|
|
296
|
+
Boolean(lease.token) &&
|
|
297
|
+
["runMartin", "runMartin:resume"].includes(lease.owner) &&
|
|
298
|
+
lease.capabilities?.runControl === true &&
|
|
299
|
+
lease.hostname === host &&
|
|
300
|
+
isProcessAlive(lease.pid) &&
|
|
301
|
+
isFreshExecutionLease(lease));
|
|
302
|
+
}
|
|
303
|
+
function isFreshExecutionLease(lease) {
|
|
304
|
+
const heartbeat = Date.parse(lease.heartbeatAt);
|
|
305
|
+
const now = wallClockMs();
|
|
306
|
+
return Number.isFinite(heartbeat) && now - heartbeat <= 2_000 && heartbeat <= now + 5_000;
|
|
307
|
+
}
|
|
308
|
+
function wallClockMs() {
|
|
309
|
+
return performance.timeOrigin + performance.now();
|
|
310
|
+
}
|
|
311
|
+
function wallClockIso() {
|
|
312
|
+
return new Date(wallClockMs()).toISOString();
|
|
313
|
+
}
|
|
314
|
+
async function writeLeaseAtomically(path, lease) {
|
|
315
|
+
const temporaryPath = `${path}.heartbeat-${process.pid}-${randomBytes(8).toString("hex")}`;
|
|
316
|
+
const handle = await open(temporaryPath, "wx", 0o600);
|
|
317
|
+
try {
|
|
318
|
+
await handle.writeFile(`${JSON.stringify(lease, null, 2)}\n`, "utf8");
|
|
319
|
+
await handle.sync();
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
await handle.close();
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
await rename(temporaryPath, path);
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function executionLeaseHeartbeatPath(path, processInstanceId) {
|
|
332
|
+
return `${path}.${processInstanceId}.heartbeat`;
|
|
333
|
+
}
|
|
334
|
+
async function readLeaseWithHeartbeat(path) {
|
|
335
|
+
const lease = await readLease(path);
|
|
336
|
+
if (!lease || lease.schemaVersion !== "martin.execution-lease.v2" || !lease.processInstanceId) {
|
|
337
|
+
return lease;
|
|
338
|
+
}
|
|
339
|
+
const heartbeat = await readLease(executionLeaseHeartbeatPath(path, lease.processInstanceId));
|
|
340
|
+
if (!heartbeat ||
|
|
341
|
+
heartbeat.token !== lease.token ||
|
|
342
|
+
heartbeat.processInstanceId !== lease.processInstanceId) {
|
|
343
|
+
return lease;
|
|
344
|
+
}
|
|
345
|
+
return heartbeat;
|
|
346
|
+
}
|
|
173
347
|
async function readLease(path) {
|
|
174
348
|
try {
|
|
175
349
|
return JSON.parse(await readFile(path, "utf8"));
|
|
@@ -10,12 +10,16 @@ export declare class RollbackStateUnavailableError extends Error {
|
|
|
10
10
|
export declare function listAttemptChangedFilesSinceBoundary(input: {
|
|
11
11
|
repoRoot?: string;
|
|
12
12
|
boundary?: RollbackBoundaryArtifact;
|
|
13
|
+
/** Trusted, currently configured MartinLoop-owned roots. */
|
|
14
|
+
managedRoots?: string[];
|
|
13
15
|
/** Include content changes to dirty/untracked paths that existed at the boundary. */
|
|
14
16
|
includeBaselineSnapshotChanges?: boolean;
|
|
15
17
|
}): string[];
|
|
16
18
|
export declare function captureRollbackBoundary(input: {
|
|
17
19
|
repoRoot?: string;
|
|
18
20
|
capturedAt: string;
|
|
21
|
+
/** Absolute, configured MartinLoop-owned roots to exclude when contained in the repo. */
|
|
22
|
+
managedRoots?: string[];
|
|
19
23
|
}): Promise<RollbackBoundaryArtifact | undefined>;
|
|
20
24
|
/**
|
|
21
25
|
* Git-ignored files the attempt created or modified since the boundary.
|
|
@@ -24,6 +28,8 @@ export declare function captureRollbackBoundary(input: {
|
|
|
24
28
|
export declare function listIgnoredFileChangesSinceBoundary(input: {
|
|
25
29
|
repoRoot?: string;
|
|
26
30
|
boundary?: RollbackBoundaryArtifact;
|
|
31
|
+
/** Trusted, currently configured MartinLoop-owned roots. */
|
|
32
|
+
managedRoots?: string[];
|
|
27
33
|
}): IgnoredFileChanges;
|
|
28
34
|
export declare function restoreRollbackBoundary(input: {
|
|
29
35
|
repoRoot?: string;
|
|
@@ -34,9 +40,17 @@ export declare function restoreRollbackBoundary(input: {
|
|
|
34
40
|
removePaths?: string[];
|
|
35
41
|
/** Pre-existing ignored paths to restore from the in-memory boundary. */
|
|
36
42
|
restoreIgnoredPaths?: string[];
|
|
43
|
+
/** Trusted, currently configured MartinLoop-owned roots. */
|
|
44
|
+
managedRoots?: string[];
|
|
37
45
|
}): Promise<RollbackOutcomeArtifact | undefined>;
|
|
38
46
|
/**
|
|
39
47
|
* Repo-relative links (symlinks or junctions) among `paths` whose target
|
|
40
48
|
* resolves outside the repository. Paths are first collapsed to the link.
|
|
41
49
|
*/
|
|
42
50
|
export declare function findEscapingLinks(repoRoot: string, paths: string[]): string[];
|
|
51
|
+
export declare function filterManagedRollbackPaths(values: readonly string[], exclusions: readonly string[]): string[];
|
|
52
|
+
/**
|
|
53
|
+
* Re-derive exclusion authority from trusted current configuration. Persisted
|
|
54
|
+
* rollback evidence records what was authorized, but never grants authority.
|
|
55
|
+
*/
|
|
56
|
+
export declare function authenticateManagedPathExclusions(repoRoot: string, boundary: RollbackBoundaryArtifact, managedRoots: readonly string[]): string[];
|
|
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
4
4
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
-
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
5
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { DEPENDENCY_AND_BUILD_DIRECTORIES, isInsideDependencyOrBuildDirectory } from "./output-directories.js";
|
|
7
7
|
/** Past this many ignored files, further ignored directories stay collapsed. */
|
|
8
8
|
const MAX_EXPANDED_IGNORED_FILES = 20_000;
|
|
@@ -25,25 +25,29 @@ export function listAttemptChangedFilesSinceBoundary(input) {
|
|
|
25
25
|
}
|
|
26
26
|
// Diff against the boundary commit (not the live HEAD) so that changes an
|
|
27
27
|
// attempt commits are still reported as attempt changes.
|
|
28
|
-
const
|
|
28
|
+
const exclusions = input.boundary
|
|
29
|
+
? authenticateManagedPathExclusions(input.repoRoot, input.boundary, input.managedRoots ?? [])
|
|
30
|
+
: [];
|
|
31
|
+
const repoState = readRepoState(input.repoRoot, input.boundary?.headRef, exclusions);
|
|
29
32
|
if (!input.boundary) {
|
|
30
33
|
return uniqueSorted([...repoState.trackedDirtyFiles, ...repoState.untrackedFiles]);
|
|
31
34
|
}
|
|
32
|
-
const baselineTracked = new Set(input.boundary.trackedDirtyFiles);
|
|
33
|
-
const baselineUntracked = new Set(input.boundary.untrackedFiles);
|
|
34
|
-
return uniqueSorted([
|
|
35
|
+
const baselineTracked = new Set(filterManagedRollbackPaths(input.boundary.trackedDirtyFiles, exclusions));
|
|
36
|
+
const baselineUntracked = new Set(filterManagedRollbackPaths(input.boundary.untrackedFiles, exclusions));
|
|
37
|
+
return filterManagedRollbackPaths(uniqueSorted([
|
|
35
38
|
...repoState.trackedDirtyFiles.filter((filePath) => !baselineTracked.has(filePath)),
|
|
36
39
|
...repoState.untrackedFiles.filter((filePath) => !baselineUntracked.has(filePath)),
|
|
37
40
|
...(input.includeBaselineSnapshotChanges
|
|
38
|
-
? snapshotChangedPaths(input.repoRoot, input.boundary.snapshots)
|
|
41
|
+
? snapshotChangedPaths(input.repoRoot, input.boundary.snapshots.filter((snapshot) => !isManagedRollbackPath(snapshot.path, exclusions)))
|
|
39
42
|
: [])
|
|
40
|
-
]);
|
|
43
|
+
]), exclusions);
|
|
41
44
|
}
|
|
42
45
|
export async function captureRollbackBoundary(input) {
|
|
43
46
|
if (!input.repoRoot) {
|
|
44
47
|
return undefined;
|
|
45
48
|
}
|
|
46
|
-
const
|
|
49
|
+
const managedPathExclusions = resolveManagedPathExclusions(input.repoRoot, input.managedRoots ?? []);
|
|
50
|
+
const repoState = readRepoState(input.repoRoot, "HEAD", managedPathExclusions);
|
|
47
51
|
// Links are recorded by path only: their targets are never read or restored.
|
|
48
52
|
const snapshotPaths = uniqueSorted([
|
|
49
53
|
...repoState.trackedDirtyFiles,
|
|
@@ -53,7 +57,7 @@ export async function captureRollbackBoundary(input) {
|
|
|
53
57
|
for (const filePath of snapshotPaths) {
|
|
54
58
|
snapshots.push(await readRollbackSnapshot(input.repoRoot, filePath));
|
|
55
59
|
}
|
|
56
|
-
const observedIgnoredFiles = readIgnoredInventory(input.repoRoot);
|
|
60
|
+
const observedIgnoredFiles = readIgnoredInventory(input.repoRoot, managedPathExclusions);
|
|
57
61
|
const ignoredFiles = observedIgnoredFiles.map(({ path, size, mtimeMs }) => ({
|
|
58
62
|
path,
|
|
59
63
|
size,
|
|
@@ -69,7 +73,8 @@ export async function captureRollbackBoundary(input) {
|
|
|
69
73
|
trackedDirtyFiles: repoState.trackedDirtyFiles,
|
|
70
74
|
untrackedFiles: repoState.untrackedFiles,
|
|
71
75
|
snapshots,
|
|
72
|
-
ignoredFiles
|
|
76
|
+
ignoredFiles,
|
|
77
|
+
...(managedPathExclusions.length > 0 ? { managedPathExclusions } : {})
|
|
73
78
|
};
|
|
74
79
|
ignoredObservations.set(boundary, new Map(observedIgnoredFiles.map((entry) => [entry.path, entry])));
|
|
75
80
|
ignoredContentSnapshots.set(boundary, captureIgnoredContent(input.repoRoot, ignoredFiles));
|
|
@@ -80,16 +85,23 @@ export async function captureRollbackBoundary(input) {
|
|
|
80
85
|
* Boundaries recorded before the ignored inventory existed report nothing.
|
|
81
86
|
*/
|
|
82
87
|
export function listIgnoredFileChangesSinceBoundary(input) {
|
|
83
|
-
if (!input.repoRoot || !input.boundary
|
|
88
|
+
if (!input.repoRoot || !input.boundary) {
|
|
84
89
|
return makeIgnoredFileChanges([], [], []);
|
|
85
90
|
}
|
|
86
|
-
const
|
|
91
|
+
const exclusions = authenticateManagedPathExclusions(input.repoRoot, input.boundary, input.managedRoots ?? []);
|
|
92
|
+
if (!input.boundary.ignoredFiles) {
|
|
93
|
+
return makeIgnoredFileChanges([], [], []);
|
|
94
|
+
}
|
|
95
|
+
const baseline = new Map(input.boundary.ignoredFiles
|
|
96
|
+
.filter((entry) => !isManagedRollbackPath(entry.path, exclusions))
|
|
97
|
+
.map((entry) => [entry.path, entry]));
|
|
87
98
|
const baselineObservations = ignoredObservations.get(input.boundary);
|
|
88
99
|
const created = [];
|
|
89
100
|
const modified = [];
|
|
90
|
-
const currentEntries = readIgnoredInventory(input.repoRoot);
|
|
101
|
+
const currentEntries = readIgnoredInventory(input.repoRoot, exclusions);
|
|
91
102
|
const current = new Map(currentEntries.map((entry) => [entry.path, entry]));
|
|
92
103
|
const deleted = input.boundary.ignoredFiles
|
|
104
|
+
.filter((entry) => !isManagedRollbackPath(entry.path, exclusions))
|
|
93
105
|
.filter((entry) => !current.has(entry.path))
|
|
94
106
|
.map((entry) => entry.path);
|
|
95
107
|
for (const entry of currentEntries) {
|
|
@@ -141,16 +153,18 @@ export async function restoreRollbackBoundary(input) {
|
|
|
141
153
|
error: "Rollback boundary was unavailable for this attempt."
|
|
142
154
|
};
|
|
143
155
|
}
|
|
144
|
-
const
|
|
156
|
+
const exclusions = authenticateManagedPathExclusions(input.repoRoot, input.boundary, input.managedRoots ?? []);
|
|
157
|
+
const before = readRepoState(input.repoRoot, input.boundary.headRef, exclusions);
|
|
145
158
|
const currentHead = readGitScalar(input.repoRoot, ["rev-parse", "HEAD"]);
|
|
146
159
|
const headMoved = input.boundary.headRef !== undefined && currentHead !== input.boundary.headRef;
|
|
147
|
-
const removePaths = uniqueSorted(input.removePaths ?? []);
|
|
148
|
-
const restoreIgnoredPaths = uniqueSorted(input.restoreIgnoredPaths ?? []);
|
|
149
|
-
const
|
|
160
|
+
const removePaths = filterManagedRollbackPaths(uniqueSorted(input.removePaths ?? []), exclusions);
|
|
161
|
+
const restoreIgnoredPaths = filterManagedRollbackPaths(uniqueSorted(input.restoreIgnoredPaths ?? []), exclusions);
|
|
162
|
+
const rollbackSnapshots = input.boundary.snapshots.filter((snapshot) => !isManagedRollbackPath(snapshot.path, exclusions));
|
|
163
|
+
const snapshotsUnchanged = await rollbackSnapshotsMatch(input.repoRoot, rollbackSnapshots);
|
|
150
164
|
if (!headMoved &&
|
|
151
165
|
removePaths.length === 0 &&
|
|
152
166
|
restoreIgnoredPaths.length === 0 &&
|
|
153
|
-
repoStateMatchesBoundary(before, input.boundary) &&
|
|
167
|
+
repoStateMatchesBoundary(before, input.boundary, exclusions) &&
|
|
154
168
|
snapshotsUnchanged) {
|
|
155
169
|
return {
|
|
156
170
|
attempted: false,
|
|
@@ -171,8 +185,8 @@ export async function restoreRollbackBoundary(input) {
|
|
|
171
185
|
rewindAttemptCommits(input.repoRoot, input.boundary, currentHead);
|
|
172
186
|
rewoundHead = { from: currentHead, to: input.boundary.headRef };
|
|
173
187
|
}
|
|
174
|
-
const baselineTracked = new Set(input.boundary.trackedDirtyFiles);
|
|
175
|
-
const baselineUntracked = new Set(input.boundary.untrackedFiles);
|
|
188
|
+
const baselineTracked = new Set(filterManagedRollbackPaths(input.boundary.trackedDirtyFiles, exclusions));
|
|
189
|
+
const baselineUntracked = new Set(filterManagedRollbackPaths(input.boundary.untrackedFiles, exclusions));
|
|
176
190
|
for (const filePath of before.trackedDirtyFiles) {
|
|
177
191
|
if (!baselineTracked.has(filePath)) {
|
|
178
192
|
if (existsInHead(input.repoRoot, filePath)) {
|
|
@@ -192,7 +206,7 @@ export async function restoreRollbackBoundary(input) {
|
|
|
192
206
|
deletedFiles.add(filePath);
|
|
193
207
|
}
|
|
194
208
|
}
|
|
195
|
-
for (const snapshot of
|
|
209
|
+
for (const snapshot of rollbackSnapshots) {
|
|
196
210
|
await restoreRollbackSnapshot(input.repoRoot, snapshot);
|
|
197
211
|
if (snapshot.existed) {
|
|
198
212
|
restoredFiles.add(snapshot.path);
|
|
@@ -217,8 +231,8 @@ export async function restoreRollbackBoundary(input) {
|
|
|
217
231
|
await writeFile(absolutePath, contents);
|
|
218
232
|
restoredFiles.add(filePath);
|
|
219
233
|
}
|
|
220
|
-
const after = readRepoState(input.repoRoot, input.boundary.headRef);
|
|
221
|
-
const restored = repoStateMatchesBoundary(after, input.boundary) &&
|
|
234
|
+
const after = readRepoState(input.repoRoot, input.boundary.headRef, exclusions);
|
|
235
|
+
const restored = repoStateMatchesBoundary(after, input.boundary, exclusions) &&
|
|
222
236
|
(input.boundary.headRef === undefined ||
|
|
223
237
|
readGitScalar(input.repoRoot, ["rev-parse", "HEAD"]) === input.boundary.headRef);
|
|
224
238
|
return {
|
|
@@ -237,7 +251,7 @@ export async function restoreRollbackBoundary(input) {
|
|
|
237
251
|
};
|
|
238
252
|
}
|
|
239
253
|
catch (error) {
|
|
240
|
-
const after = readRepoState(input.repoRoot, input.boundary.headRef);
|
|
254
|
+
const after = readRepoState(input.repoRoot, input.boundary.headRef, exclusions);
|
|
241
255
|
return {
|
|
242
256
|
attempted: true,
|
|
243
257
|
status: "failed",
|
|
@@ -251,13 +265,13 @@ export async function restoreRollbackBoundary(input) {
|
|
|
251
265
|
};
|
|
252
266
|
}
|
|
253
267
|
}
|
|
254
|
-
function readRepoState(repoRoot, baseRef = "HEAD") {
|
|
268
|
+
function readRepoState(repoRoot, baseRef = "HEAD", managedPathExclusions = []) {
|
|
255
269
|
return {
|
|
256
|
-
trackedDirtyFiles: readGitLines(repoRoot, ["diff", "--name-only", baseRef, "--", "."]),
|
|
270
|
+
trackedDirtyFiles: filterManagedRollbackPaths(readGitLines(repoRoot, ["diff", "--name-only", baseRef, "--", "."]), managedPathExclusions),
|
|
257
271
|
// Git for Windows lists untracked files *through* junctions, so a link to a
|
|
258
272
|
// directory outside the repo would otherwise surface that directory's files as
|
|
259
273
|
// repo paths. Collapse every such path to the link itself.
|
|
260
|
-
untrackedFiles: collapsePathsThroughLinks(repoRoot, readGitLines(repoRoot, ["ls-files", "--others", "--exclude-standard", "--", "."]))
|
|
274
|
+
untrackedFiles: filterManagedRollbackPaths(collapsePathsThroughLinks(repoRoot, readGitLines(repoRoot, ["ls-files", "--others", "--exclude-standard", "--", "."])), managedPathExclusions)
|
|
261
275
|
};
|
|
262
276
|
}
|
|
263
277
|
/**
|
|
@@ -357,10 +371,10 @@ function unstagePath(repoRoot, filePath) {
|
|
|
357
371
|
throw new Error(`git rm --cached failed for ${filePath}: ${gitFailureMessage(result)}`);
|
|
358
372
|
}
|
|
359
373
|
}
|
|
360
|
-
function readIgnoredInventory(repoRoot) {
|
|
374
|
+
function readIgnoredInventory(repoRoot, managedPathExclusions = []) {
|
|
361
375
|
const collapsed = collapsePathsThroughLinks(repoRoot, readGitLines(repoRoot, [
|
|
362
376
|
"ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "--", "."
|
|
363
|
-
]));
|
|
377
|
+
])).filter((entry) => !isManagedRollbackPath(entry, managedPathExclusions));
|
|
364
378
|
const inventory = [];
|
|
365
379
|
let expanded = 0;
|
|
366
380
|
const hashBudget = { remainingBytes: MAX_HASHED_IGNORED_TOTAL_BYTES };
|
|
@@ -374,9 +388,9 @@ function readIgnoredInventory(repoRoot) {
|
|
|
374
388
|
if (isDirectory && expanded >= MAX_EXPANDED_IGNORED_FILES) {
|
|
375
389
|
throw new RollbackStateUnavailableError(`Ignored rollback inventory exceeds the ${String(MAX_EXPANDED_IGNORED_FILES)}-file observation limit at ${entry}`);
|
|
376
390
|
}
|
|
377
|
-
const files = isDirectory
|
|
391
|
+
const files = (isDirectory
|
|
378
392
|
? collapsePathsThroughLinks(repoRoot, readGitLines(repoRoot, ["ls-files", "--others", "--ignored", "--exclude-standard", "--", entry]))
|
|
379
|
-
: [entry];
|
|
393
|
+
: [entry]).filter((filePath) => !isManagedRollbackPath(filePath, managedPathExclusions));
|
|
380
394
|
for (const filePath of files) {
|
|
381
395
|
if (expanded >= MAX_EXPANDED_IGNORED_FILES) {
|
|
382
396
|
throw new RollbackStateUnavailableError(`Ignored rollback inventory exceeds the ${String(MAX_EXPANDED_IGNORED_FILES)}-file observation limit at ${entry}`);
|
|
@@ -619,9 +633,8 @@ function resolveRepoPath(repoRoot, filePath, options = {}) {
|
|
|
619
633
|
}
|
|
620
634
|
return resolvedPath;
|
|
621
635
|
}
|
|
622
|
-
function repoStateMatchesBoundary(state, boundary) {
|
|
623
|
-
return arraysEqual(state.trackedDirtyFiles, boundary.trackedDirtyFiles) &&
|
|
624
|
-
arraysEqual(state.untrackedFiles, boundary.untrackedFiles);
|
|
636
|
+
function repoStateMatchesBoundary(state, boundary, exclusions = []) {
|
|
637
|
+
return arraysEqual(state.trackedDirtyFiles, filterManagedRollbackPaths(boundary.trackedDirtyFiles, exclusions)) && arraysEqual(state.untrackedFiles, filterManagedRollbackPaths(boundary.untrackedFiles, exclusions));
|
|
625
638
|
}
|
|
626
639
|
function arraysEqual(left, right) {
|
|
627
640
|
if (left.length !== right.length) {
|
|
@@ -632,6 +645,50 @@ function arraysEqual(left, right) {
|
|
|
632
645
|
function uniqueSorted(values) {
|
|
633
646
|
return [...new Set(values.map((value) => normalizeRepoPath(value)).filter(Boolean))].sort();
|
|
634
647
|
}
|
|
648
|
+
export function filterManagedRollbackPaths(values, exclusions) {
|
|
649
|
+
return values.filter((value) => !isManagedRollbackPath(value, exclusions));
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Re-derive exclusion authority from trusted current configuration. Persisted
|
|
653
|
+
* rollback evidence records what was authorized, but never grants authority.
|
|
654
|
+
*/
|
|
655
|
+
export function authenticateManagedPathExclusions(repoRoot, boundary, managedRoots) {
|
|
656
|
+
const expected = resolveManagedPathExclusions(repoRoot, managedRoots);
|
|
657
|
+
const persistedValue = boundary.managedPathExclusions;
|
|
658
|
+
if (persistedValue !== undefined &&
|
|
659
|
+
(!Array.isArray(persistedValue) || persistedValue.some((entry) => typeof entry !== "string"))) {
|
|
660
|
+
throw new RollbackStateUnavailableError("Rollback boundary managed path exclusions are invalid and cannot be trusted.");
|
|
661
|
+
}
|
|
662
|
+
const persisted = uniqueSorted(persistedValue ?? []);
|
|
663
|
+
if (!arraysEqual(persisted, expected)) {
|
|
664
|
+
throw new RollbackStateUnavailableError("Rollback boundary managed path exclusions do not match the configured MartinLoop roots.");
|
|
665
|
+
}
|
|
666
|
+
return expected;
|
|
667
|
+
}
|
|
668
|
+
function isManagedRollbackPath(value, exclusions) {
|
|
669
|
+
const path = normalizeRepoPath(value).replace(/\/$/u, "");
|
|
670
|
+
return exclusions.some((entry) => {
|
|
671
|
+
const root = normalizeRepoPath(entry).replace(/\/$/u, "");
|
|
672
|
+
return root.length > 0 && (path === root || path.startsWith(`${root}/`));
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
function resolveManagedPathExclusions(repoRoot, roots) {
|
|
676
|
+
const absoluteRepoRoot = resolve(repoRoot);
|
|
677
|
+
return uniqueSorted(roots.flatMap((root) => {
|
|
678
|
+
const absoluteRoot = resolve(root);
|
|
679
|
+
const relativeRoot = relative(absoluteRepoRoot, absoluteRoot);
|
|
680
|
+
if (relativeRoot === "" ||
|
|
681
|
+
relativeRoot === ".." ||
|
|
682
|
+
relativeRoot.startsWith(`..${sep}`) ||
|
|
683
|
+
isAbsolute(relativeRoot)) {
|
|
684
|
+
return [];
|
|
685
|
+
}
|
|
686
|
+
// A configured link/junction is not an authenticated in-repository
|
|
687
|
+
// persistence root. Reuse rollback's link-safe resolver and fail closed.
|
|
688
|
+
resolveRepoPath(absoluteRepoRoot, relativeRoot);
|
|
689
|
+
return [relativeRoot];
|
|
690
|
+
}));
|
|
691
|
+
}
|
|
635
692
|
function normalizeRepoPath(value) {
|
|
636
693
|
return value.trim().replace(/\\/gu, "/");
|
|
637
694
|
}
|
|
@@ -656,6 +656,8 @@ export async function runLiveSwarmWithDependencies(input, dependencies) {
|
|
|
656
656
|
}
|
|
657
657
|
},
|
|
658
658
|
metadata: {
|
|
659
|
+
executionMode: "governed",
|
|
660
|
+
governanceClaimEligible: "true",
|
|
659
661
|
"swarm.parentId": record.swarmId,
|
|
660
662
|
"swarm.agentId": agent.agentId,
|
|
661
663
|
"swarm.taskIds": JSON.stringify([task.taskId]),
|