@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,85 +1,955 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { link, lstat, open, realpath, unlink } from "node:fs/promises";
|
|
4
|
+
import { hostname } from "node:os";
|
|
2
5
|
import path from "node:path";
|
|
3
|
-
import {
|
|
6
|
+
import { readExitSignal, readActiveRunExecutionLease, writeExitSignal } from "../vendor/core/index.js";
|
|
7
|
+
import { invalidArgumentsError, storeUnreadableError, unsupportedOperationError } from "./tool-errors.js";
|
|
4
8
|
import { loadDetailedLoopRecord } from "./run-store.js";
|
|
5
|
-
export async function createRunControlReceipt(action, input) {
|
|
6
|
-
|
|
9
|
+
export async function createRunControlReceipt(action, input, testHooks) {
|
|
10
|
+
if (input.authority?.transport !== "local_stdio") {
|
|
11
|
+
throw unsupportedOperationError("Run controls are available only over the local stdio transport.", "Use a local MartinLoop MCP stdio session; remote HTTP control requires authenticated principal support.");
|
|
12
|
+
}
|
|
13
|
+
const initialDetail = await loadDetailedLoopRecord(input);
|
|
14
|
+
if (!initialDetail.canonicalRunDirectory) {
|
|
15
|
+
throw unsupportedOperationError("Run control receipts require a canonical run directory.", "Use a canonical loopId-backed Martin run before writing control receipts.");
|
|
16
|
+
}
|
|
17
|
+
return withRunControlLock(initialDetail.canonicalRunDirectory, async () => {
|
|
18
|
+
await testHooks?.beforeLockedReload?.();
|
|
19
|
+
const detail = await loadDetailedLoopRecord(input);
|
|
20
|
+
if (detail.canonicalRunDirectory !== initialDetail.canonicalRunDirectory) {
|
|
21
|
+
throw controlReceiptIntegrityError();
|
|
22
|
+
}
|
|
23
|
+
return createRunControlReceiptLocked(action, input, detail, testHooks);
|
|
24
|
+
}, testHooks?.lockMaxRetries);
|
|
25
|
+
}
|
|
26
|
+
async function createRunControlReceiptLocked(action, input, detail, _testHooks) {
|
|
7
27
|
if (!detail.canonicalRunDirectory) {
|
|
8
28
|
throw unsupportedOperationError("Run control receipts require a canonical run directory.", "Use a canonical loopId-backed Martin run before writing control receipts.");
|
|
9
29
|
}
|
|
10
|
-
|
|
30
|
+
if (isTerminalRun(detail.loop)) {
|
|
31
|
+
throw unsupportedOperationError(`Run ${detail.loop.loopId} is already terminal and cannot accept ${action}.`, "Inspect the terminal run evidence instead of recording a new control request.");
|
|
32
|
+
}
|
|
33
|
+
if (await readRunTerminalIntent(detail.canonicalRunDirectory, detail.loop.loopId)) {
|
|
34
|
+
throw unsupportedOperationError(`Run ${detail.loop.loopId} is entering terminal persistence and cannot accept ${action}.`, "Inspect the terminal run evidence instead of recording a new control request.");
|
|
35
|
+
}
|
|
36
|
+
const executionLease = await readActiveRunExecutionLease({
|
|
37
|
+
runsRoot: detail.runsRoot,
|
|
38
|
+
runId: detail.loop.loopId
|
|
39
|
+
}).catch(() => undefined);
|
|
40
|
+
if (!executionLease) {
|
|
41
|
+
throw unsupportedOperationError(`Run ${detail.loop.loopId} has no live control-capable executor lease.`, "Start or resume the run through a supported MartinLoop executor before requesting controls.");
|
|
42
|
+
}
|
|
43
|
+
const priorState = await readRunControlStateUnlocked(detail);
|
|
44
|
+
const priorCancellation = priorState.receipts.find((candidate) => candidate.action === "cancel");
|
|
45
|
+
if (action === "cancel" && priorCancellation) {
|
|
46
|
+
return publishCancellation(detail, priorCancellation, priorState, _testHooks);
|
|
47
|
+
}
|
|
48
|
+
if (action === "pause" && priorState.requestedState === "paused") {
|
|
49
|
+
throw unsupportedOperationError("The run already has a pending pause request.", "Continue the existing pause before requesting another pause.");
|
|
50
|
+
}
|
|
51
|
+
if (action === "pause" && priorState.appliedState === "paused") {
|
|
52
|
+
throw unsupportedOperationError("The previous pause is still applied by the executor.", "Wait until the matching continue application is visible before requesting another pause.");
|
|
53
|
+
}
|
|
54
|
+
if (action !== "cancel" && priorState.requestedState === "cancellation_requested") {
|
|
55
|
+
throw unsupportedOperationError("The run already has a cancellation request.", "Wait for the active executor to record terminal cancellation evidence.");
|
|
56
|
+
}
|
|
57
|
+
const expectedPreviousControlId = action === "continue"
|
|
58
|
+
? requirePendingPauseControlId(priorState)
|
|
59
|
+
: undefined;
|
|
60
|
+
const unsignedReceipt = {
|
|
61
|
+
schemaVersion: "martin.run-control.v2",
|
|
11
62
|
controlId: `ctl_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
|
12
63
|
loopId: detail.loop.loopId,
|
|
13
64
|
action,
|
|
14
|
-
requestedAt:
|
|
65
|
+
requestedAt: nextControlRequestedAt(priorState),
|
|
66
|
+
authorityBoundary: "local_os_user",
|
|
67
|
+
principal: "local_mcp_client",
|
|
68
|
+
executorLeaseToken: executionLease.lease.token,
|
|
69
|
+
...(typeof detail.loop.workspaceId === "string" ? { workspaceId: detail.loop.workspaceId } : {}),
|
|
70
|
+
...(expectedPreviousControlId ? { expectedPreviousControlId } : {}),
|
|
15
71
|
...(input.reason ? { reason: input.reason } : {}),
|
|
16
|
-
...(
|
|
72
|
+
...(priorState.receipts.at(-1)?.receiptHash
|
|
73
|
+
? { previousReceiptHash: priorState.receipts.at(-1).receiptHash }
|
|
74
|
+
: {})
|
|
75
|
+
};
|
|
76
|
+
const receipt = {
|
|
77
|
+
...unsignedReceipt,
|
|
78
|
+
receiptHash: hashCanonical(unsignedReceipt)
|
|
17
79
|
};
|
|
80
|
+
const currentDetail = await loadDetailedLoopRecord(input);
|
|
81
|
+
if (currentDetail.canonicalRunDirectory !== detail.canonicalRunDirectory ||
|
|
82
|
+
isTerminalRun(currentDetail.loop) ||
|
|
83
|
+
await readRunTerminalIntent(detail.canonicalRunDirectory, detail.loop.loopId)) {
|
|
84
|
+
throw unsupportedOperationError(`Run ${detail.loop.loopId} is already terminal and cannot accept ${action}.`, "Inspect the terminal run evidence instead of recording a new control request.");
|
|
85
|
+
}
|
|
86
|
+
const currentLease = await readActiveRunExecutionLease({
|
|
87
|
+
runsRoot: currentDetail.runsRoot,
|
|
88
|
+
runId: currentDetail.loop.loopId
|
|
89
|
+
});
|
|
90
|
+
if (currentLease?.lease.token !== executionLease.lease.token) {
|
|
91
|
+
throw controlReceiptIntegrityError();
|
|
92
|
+
}
|
|
18
93
|
const receiptPath = path.join(detail.canonicalRunDirectory, "controls.jsonl");
|
|
19
|
-
await
|
|
20
|
-
const
|
|
94
|
+
await safeAppendJournal(detail.canonicalRunDirectory, receiptPath, `${JSON.stringify(receipt)}\n`, _testHooks?.afterJournalOpen);
|
|
95
|
+
const receiptState = await readRunControlStateUnlocked(detail);
|
|
96
|
+
if (action === "cancel") {
|
|
97
|
+
return publishCancellation(detail, receipt, receiptState, _testHooks);
|
|
98
|
+
}
|
|
99
|
+
const latestDetail = await loadDetailedLoopRecord(input);
|
|
100
|
+
const state = await readRunControlStateUnlocked(latestDetail);
|
|
101
|
+
const terminal = isTerminalRun(latestDetail.loop);
|
|
21
102
|
return {
|
|
22
103
|
ok: true,
|
|
23
|
-
summary:
|
|
24
|
-
? `
|
|
104
|
+
summary: terminal
|
|
105
|
+
? `Run ${detail.loop.loopId} became terminal while the control was recorded; terminal evidence wins.`
|
|
25
106
|
: action === "pause"
|
|
26
|
-
? `Pause
|
|
27
|
-
: `Continue request
|
|
107
|
+
? `Pause requested for ${detail.loop.loopId}; application waits for the next safe attempt boundary.`
|
|
108
|
+
: `Continue request bound to pause ${expectedPreviousControlId} for ${detail.loop.loopId}.`,
|
|
28
109
|
loopId: detail.loop.loopId,
|
|
29
110
|
requestedAction: action,
|
|
30
111
|
state,
|
|
31
112
|
action: receipt.action,
|
|
32
113
|
controlId: receipt.controlId,
|
|
33
114
|
requestedAt: receipt.requestedAt,
|
|
115
|
+
publicationState: "receipt_recorded",
|
|
116
|
+
applicationState: terminal ? "terminal_reconciled" : "requested_only",
|
|
34
117
|
...(receipt.reason ? { reason: receipt.reason } : {}),
|
|
35
|
-
|
|
118
|
+
authorityBoundary: receipt.authorityBoundary,
|
|
119
|
+
principal: receipt.principal,
|
|
120
|
+
receiptHash: receipt.receiptHash
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
async function publishCancellation(detail, receipt, state, testHooks) {
|
|
124
|
+
const existingPublication = state.publications.find((candidate) => candidate.controlId === receipt.controlId);
|
|
125
|
+
const expectedSignal = {
|
|
126
|
+
schemaVersion: "exit-signal/1",
|
|
127
|
+
kind: "human_interrupt",
|
|
128
|
+
runId: detail.loop.loopId,
|
|
129
|
+
requestedAt: receipt.requestedAt,
|
|
130
|
+
requestedBy: "local_mcp_client",
|
|
131
|
+
reason: receipt.reason ?? "Cancellation requested through martin_cancel."
|
|
132
|
+
};
|
|
133
|
+
// A publication receipt never substitutes for the canonical signal. This
|
|
134
|
+
// repairs a missing signal and fails closed if first-writer-wins preserved a
|
|
135
|
+
// different signal payload in the canonical slot.
|
|
136
|
+
const signalState = await writeExitSignal(detail.runsRoot, expectedSignal);
|
|
137
|
+
const durableSignal = await readExitSignal(detail.runsRoot, detail.loop.loopId, "human_interrupt");
|
|
138
|
+
if (!durableSignal ||
|
|
139
|
+
durableSignal.schemaVersion !== expectedSignal.schemaVersion ||
|
|
140
|
+
durableSignal.kind !== expectedSignal.kind ||
|
|
141
|
+
durableSignal.runId !== expectedSignal.runId ||
|
|
142
|
+
durableSignal.requestedAt !== expectedSignal.requestedAt ||
|
|
143
|
+
durableSignal.requestedBy !== expectedSignal.requestedBy ||
|
|
144
|
+
durableSignal.reason !== expectedSignal.reason) {
|
|
145
|
+
throw controlReceiptIntegrityError();
|
|
146
|
+
}
|
|
147
|
+
let publicationState = "already_requested";
|
|
148
|
+
if (!existingPublication) {
|
|
149
|
+
const previous = state.publications.at(-1);
|
|
150
|
+
const unsigned = {
|
|
151
|
+
schemaVersion: "martin.run-control-publication.v1",
|
|
152
|
+
loopId: receipt.loopId,
|
|
153
|
+
controlId: receipt.controlId,
|
|
154
|
+
receiptHash: receipt.receiptHash,
|
|
155
|
+
signalState,
|
|
156
|
+
recordedAt: new Date().toISOString(),
|
|
157
|
+
...(previous ? { previousPublicationHash: previous.publicationHash } : {})
|
|
158
|
+
};
|
|
159
|
+
const publication = {
|
|
160
|
+
...unsigned,
|
|
161
|
+
publicationHash: hashCanonical(unsigned)
|
|
162
|
+
};
|
|
163
|
+
try {
|
|
164
|
+
await safeAppendJournal(detail.canonicalRunDirectory, path.join(detail.canonicalRunDirectory, "control-publications.jsonl"), `${JSON.stringify(publication)}\n`, testHooks?.afterJournalOpen);
|
|
165
|
+
publicationState = signalState === "created" ? "published" : "already_requested";
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
publicationState = "published_audit_pending";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const currentDetail = await loadDetailedLoopRecord({
|
|
172
|
+
loopId: detail.loop.loopId,
|
|
173
|
+
runsDir: detail.runsRoot
|
|
174
|
+
});
|
|
175
|
+
const currentState = await readRunControlStateUnlocked(currentDetail);
|
|
176
|
+
const terminal = isTerminalRun(currentDetail.loop) || currentState.requestedState === "terminal";
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
summary: terminal
|
|
180
|
+
? `Run ${detail.loop.loopId} is terminal; terminal evidence wins over pending cancellation state.`
|
|
181
|
+
: publicationState === "published_audit_pending"
|
|
182
|
+
? `Cancellation signal is live for ${detail.loop.loopId}; publication audit is pending a safe retry.`
|
|
183
|
+
: `Cancellation signal is authoritative for ${detail.loop.loopId}; executor observation is pending.`,
|
|
184
|
+
loopId: detail.loop.loopId,
|
|
185
|
+
requestedAction: "cancel",
|
|
186
|
+
state: currentState,
|
|
187
|
+
action: "cancel",
|
|
188
|
+
controlId: receipt.controlId,
|
|
189
|
+
requestedAt: receipt.requestedAt,
|
|
190
|
+
publicationState,
|
|
191
|
+
applicationState: terminal ? "terminal_reconciled" : "pending_observation",
|
|
192
|
+
...(receipt.reason ? { reason: receipt.reason } : {}),
|
|
193
|
+
authorityBoundary: receipt.authorityBoundary,
|
|
194
|
+
principal: receipt.principal,
|
|
195
|
+
receiptHash: receipt.receiptHash
|
|
36
196
|
};
|
|
37
197
|
}
|
|
38
198
|
export async function readRunControlState(detailOrInput) {
|
|
39
199
|
const detail = isDetailedLoopSource(detailOrInput)
|
|
40
200
|
? detailOrInput
|
|
41
201
|
: await loadDetailedLoopRecord(detailOrInput);
|
|
202
|
+
return detail.canonicalRunDirectory
|
|
203
|
+
? withRunControlLock(detail.canonicalRunDirectory, () => readRunControlStateUnlocked(detail))
|
|
204
|
+
: readRunControlStateUnlocked(detail);
|
|
205
|
+
}
|
|
206
|
+
async function readRunControlStateUnlocked(detail) {
|
|
42
207
|
const receiptPath = detail.canonicalRunDirectory
|
|
43
208
|
? path.join(detail.canonicalRunDirectory, "controls.jsonl")
|
|
44
209
|
: undefined;
|
|
45
|
-
const receipts = receiptPath
|
|
210
|
+
const receipts = receiptPath
|
|
211
|
+
? await readControlReceipts(receiptPath, detail.loop.loopId)
|
|
212
|
+
: [];
|
|
213
|
+
const applicationPath = detail.canonicalRunDirectory
|
|
214
|
+
? path.join(detail.canonicalRunDirectory, "control-applications.jsonl")
|
|
215
|
+
: undefined;
|
|
216
|
+
const applications = applicationPath
|
|
217
|
+
? await readRunControlApplications(applicationPath, detail.loop.loopId)
|
|
218
|
+
: [];
|
|
219
|
+
const publicationPath = detail.canonicalRunDirectory
|
|
220
|
+
? path.join(detail.canonicalRunDirectory, "control-publications.jsonl")
|
|
221
|
+
: undefined;
|
|
222
|
+
const publications = publicationPath
|
|
223
|
+
? await readRunControlPublications(publicationPath, detail.loop.loopId)
|
|
224
|
+
: [];
|
|
225
|
+
const terminalIntent = detail.canonicalRunDirectory
|
|
226
|
+
? await readRunTerminalIntent(detail.canonicalRunDirectory, detail.loop.loopId)
|
|
227
|
+
: undefined;
|
|
46
228
|
const latestReceipt = receipts.at(-1);
|
|
229
|
+
const snapshot = resolveRunControlSnapshot(receipts);
|
|
230
|
+
const reconciledAppliedState = reconcileRunControlApplications(receipts, applications);
|
|
231
|
+
reconcileRunControlPublications(receipts, publications);
|
|
47
232
|
return {
|
|
48
|
-
requestedState:
|
|
49
|
-
? "
|
|
233
|
+
requestedState: isTerminalRun(detail.loop) || terminalIntent !== undefined
|
|
234
|
+
? "terminal"
|
|
50
235
|
: latestReceipt?.action === "cancel"
|
|
51
236
|
? "cancellation_requested"
|
|
52
|
-
:
|
|
237
|
+
: snapshot.pause
|
|
238
|
+
? "paused"
|
|
239
|
+
: "active",
|
|
53
240
|
...(latestReceipt ? { latestReceipt } : {}),
|
|
54
|
-
approvalState:
|
|
241
|
+
approvalState: "not_required",
|
|
242
|
+
appliedState: isTerminalRun(detail.loop) || terminalIntent !== undefined
|
|
243
|
+
? "terminal"
|
|
244
|
+
: latestReceipt?.action === "cancel"
|
|
245
|
+
? "cancellation_pending"
|
|
246
|
+
: reconciledAppliedState,
|
|
55
247
|
receipts,
|
|
56
|
-
|
|
248
|
+
applications,
|
|
249
|
+
publications,
|
|
250
|
+
...(receiptPath ? { receiptPath } : {}),
|
|
251
|
+
...(applicationPath ? { applicationPath } : {})
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function reconcileRunControlPublications(receipts, publications) {
|
|
255
|
+
const receiptsById = new Map(receipts.map((receipt) => [receipt.controlId, receipt]));
|
|
256
|
+
const seen = new Set();
|
|
257
|
+
for (const publication of publications) {
|
|
258
|
+
if (seen.has(publication.controlId))
|
|
259
|
+
throw controlReceiptIntegrityError();
|
|
260
|
+
seen.add(publication.controlId);
|
|
261
|
+
const receipt = receiptsById.get(publication.controlId);
|
|
262
|
+
if (receipt?.action !== "cancel" ||
|
|
263
|
+
publication.loopId !== receipt.loopId ||
|
|
264
|
+
publication.receiptHash !== receipt.receiptHash) {
|
|
265
|
+
throw controlReceiptIntegrityError();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
export function createFileRunControlSource(runsRoot) {
|
|
270
|
+
return {
|
|
271
|
+
async poll(runId) {
|
|
272
|
+
const runDirectory = path.join(runsRoot, runId);
|
|
273
|
+
const runDirectoryEntry = await lstat(runDirectory).catch((error) => {
|
|
274
|
+
if (isNodeError(error, "ENOENT"))
|
|
275
|
+
return undefined;
|
|
276
|
+
throw storeUnreadableError();
|
|
277
|
+
});
|
|
278
|
+
if (!runDirectoryEntry)
|
|
279
|
+
return {};
|
|
280
|
+
if (!runDirectoryEntry.isDirectory() || runDirectoryEntry.isSymbolicLink()) {
|
|
281
|
+
throw controlReceiptIntegrityError();
|
|
282
|
+
}
|
|
283
|
+
return withRunControlLock(runDirectory, async () => {
|
|
284
|
+
if (await readRunTerminalIntent(runDirectory, runId))
|
|
285
|
+
return {};
|
|
286
|
+
const receipts = await readControlReceipts(path.join(runDirectory, "controls.jsonl"), runId);
|
|
287
|
+
return resolveRunControlSnapshot(receipts);
|
|
288
|
+
});
|
|
289
|
+
},
|
|
290
|
+
async recordApplication(application) {
|
|
291
|
+
const runDirectory = path.join(runsRoot, application.loopId);
|
|
292
|
+
await withRunControlLock(runDirectory, async () => {
|
|
293
|
+
const receiptPath = path.join(runDirectory, "controls.jsonl");
|
|
294
|
+
const applicationPath = path.join(runDirectory, "control-applications.jsonl");
|
|
295
|
+
const receipts = await readControlReceipts(receiptPath, application.loopId);
|
|
296
|
+
resolveRunControlSnapshot(receipts);
|
|
297
|
+
const existing = await readRunControlApplications(applicationPath, application.loopId);
|
|
298
|
+
reconcileRunControlApplications(receipts, existing);
|
|
299
|
+
if (existing.some((candidate) => candidate.controlId === application.controlId &&
|
|
300
|
+
candidate.action === application.action)) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const previousApplicationHash = existing.at(-1)?.applicationHash;
|
|
304
|
+
const unsigned = {
|
|
305
|
+
schemaVersion: "martin.run-control-application.v2",
|
|
306
|
+
...application,
|
|
307
|
+
...(previousApplicationHash ? { previousApplicationHash } : {})
|
|
308
|
+
};
|
|
309
|
+
const stored = {
|
|
310
|
+
...unsigned,
|
|
311
|
+
applicationHash: hashCanonical(unsigned)
|
|
312
|
+
};
|
|
313
|
+
reconcileRunControlApplications(receipts, [...existing, stored]);
|
|
314
|
+
await safeAppendJournal(runDirectory, applicationPath, `${JSON.stringify(stored)}\n`);
|
|
315
|
+
});
|
|
316
|
+
},
|
|
317
|
+
async recordTerminalIntent(runId) {
|
|
318
|
+
const runDirectory = path.join(runsRoot, runId);
|
|
319
|
+
await withRunControlLock(runDirectory, async () => {
|
|
320
|
+
if (await readRunTerminalIntent(runDirectory, runId))
|
|
321
|
+
return;
|
|
322
|
+
const unsigned = {
|
|
323
|
+
schemaVersion: "martin.run-terminal-intent.v1",
|
|
324
|
+
loopId: runId,
|
|
325
|
+
recordedAt: new Date().toISOString()
|
|
326
|
+
};
|
|
327
|
+
const intent = {
|
|
328
|
+
...unsigned,
|
|
329
|
+
intentHash: hashCanonical(unsigned)
|
|
330
|
+
};
|
|
331
|
+
await safeAppendJournal(runDirectory, path.join(runDirectory, "terminal-intent.jsonl"), `${JSON.stringify(intent)}\n`);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
export function reconcileRunControlApplications(receipts, applications) {
|
|
337
|
+
const receiptPositions = new Map(receipts.map((receipt, index) => [receipt.controlId, { receipt, index }]));
|
|
338
|
+
const seenApplications = new Set();
|
|
339
|
+
let appliedPauseId;
|
|
340
|
+
let lastReceiptPosition = -1;
|
|
341
|
+
let lastAppliedAt = -Infinity;
|
|
342
|
+
for (const application of applications) {
|
|
343
|
+
const identity = `${application.action}:${application.controlId}`;
|
|
344
|
+
if (seenApplications.has(identity))
|
|
345
|
+
throw controlReceiptIntegrityError();
|
|
346
|
+
seenApplications.add(identity);
|
|
347
|
+
const matched = receiptPositions.get(application.controlId);
|
|
348
|
+
if (!matched || matched.index < lastReceiptPosition)
|
|
349
|
+
throw controlReceiptIntegrityError();
|
|
350
|
+
if (application.receiptHash !== matched.receipt.receiptHash) {
|
|
351
|
+
throw controlReceiptIntegrityError();
|
|
352
|
+
}
|
|
353
|
+
const appliedAt = Date.parse(application.appliedAt);
|
|
354
|
+
if (appliedAt < Date.parse(matched.receipt.requestedAt) || appliedAt < lastAppliedAt) {
|
|
355
|
+
throw controlReceiptIntegrityError();
|
|
356
|
+
}
|
|
357
|
+
if (receipts
|
|
358
|
+
.slice(matched.index + 1)
|
|
359
|
+
.some((receipt) => Date.parse(receipt.requestedAt) <= appliedAt)) {
|
|
360
|
+
throw controlReceiptIntegrityError();
|
|
361
|
+
}
|
|
362
|
+
if (application.action === "pause_applied") {
|
|
363
|
+
if (matched.receipt.action !== "pause" ||
|
|
364
|
+
application.relatedControlId !== undefined ||
|
|
365
|
+
appliedPauseId !== undefined) {
|
|
366
|
+
throw controlReceiptIntegrityError();
|
|
367
|
+
}
|
|
368
|
+
appliedPauseId = matched.receipt.controlId;
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
if (matched.receipt.action !== "continue" ||
|
|
372
|
+
matched.receipt.expectedPreviousControlId !== application.relatedControlId ||
|
|
373
|
+
appliedPauseId !== application.relatedControlId) {
|
|
374
|
+
throw controlReceiptIntegrityError();
|
|
375
|
+
}
|
|
376
|
+
appliedPauseId = undefined;
|
|
377
|
+
}
|
|
378
|
+
lastReceiptPosition = matched.index;
|
|
379
|
+
lastAppliedAt = appliedAt;
|
|
380
|
+
}
|
|
381
|
+
return appliedPauseId ? "paused" : "active";
|
|
382
|
+
}
|
|
383
|
+
export function resolveRunControlSnapshot(receipts) {
|
|
384
|
+
let pause;
|
|
385
|
+
let continuedBy;
|
|
386
|
+
let cancellationRequested = false;
|
|
387
|
+
const controlIds = new Set();
|
|
388
|
+
let lastRequestedAt = -Infinity;
|
|
389
|
+
for (const receipt of receipts) {
|
|
390
|
+
if (controlIds.has(receipt.controlId))
|
|
391
|
+
throw controlReceiptIntegrityError();
|
|
392
|
+
controlIds.add(receipt.controlId);
|
|
393
|
+
const requestedAt = Date.parse(receipt.requestedAt);
|
|
394
|
+
if (requestedAt < lastRequestedAt)
|
|
395
|
+
throw controlReceiptIntegrityError();
|
|
396
|
+
lastRequestedAt = requestedAt;
|
|
397
|
+
if (receipt.action === "cancel") {
|
|
398
|
+
cancellationRequested = true;
|
|
399
|
+
pause = undefined;
|
|
400
|
+
continuedBy = undefined;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (cancellationRequested) {
|
|
404
|
+
throw controlReceiptIntegrityError();
|
|
405
|
+
}
|
|
406
|
+
if (receipt.action === "pause") {
|
|
407
|
+
if (pause)
|
|
408
|
+
throw controlReceiptIntegrityError();
|
|
409
|
+
pause = receipt;
|
|
410
|
+
continuedBy = undefined;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (!pause || receipt.expectedPreviousControlId !== pause.controlId) {
|
|
414
|
+
throw controlReceiptIntegrityError();
|
|
415
|
+
}
|
|
416
|
+
continuedBy = receipt;
|
|
417
|
+
pause = undefined;
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
...(pause ? { pause } : {}),
|
|
421
|
+
...(!pause && continuedBy ? { continuedBy } : {})
|
|
57
422
|
};
|
|
58
423
|
}
|
|
59
|
-
export async function
|
|
424
|
+
export async function readRunControlApplications(applicationPath, expectedLoopId) {
|
|
425
|
+
let contents;
|
|
426
|
+
try {
|
|
427
|
+
contents = await safeReadJournal(path.dirname(applicationPath), applicationPath);
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
if (isFileNotFoundError(error))
|
|
431
|
+
return [];
|
|
432
|
+
throw controlReceiptIntegrityError();
|
|
433
|
+
}
|
|
434
|
+
const applications = [];
|
|
435
|
+
for (const line of contents.split(/\r?\n/u).map((value) => value.trim()).filter(Boolean)) {
|
|
436
|
+
let candidate;
|
|
437
|
+
try {
|
|
438
|
+
candidate = JSON.parse(line);
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
throw controlReceiptIntegrityError();
|
|
442
|
+
}
|
|
443
|
+
if (!isRunControlApplication(candidate, expectedLoopId)) {
|
|
444
|
+
throw controlReceiptIntegrityError();
|
|
445
|
+
}
|
|
446
|
+
const previous = applications.at(-1);
|
|
447
|
+
if (candidate.previousApplicationHash !== previous?.applicationHash) {
|
|
448
|
+
throw controlReceiptIntegrityError();
|
|
449
|
+
}
|
|
450
|
+
applications.push(candidate);
|
|
451
|
+
}
|
|
452
|
+
return applications;
|
|
453
|
+
}
|
|
454
|
+
export async function readControlReceipts(receiptPath, expectedLoopId) {
|
|
455
|
+
let contents;
|
|
456
|
+
try {
|
|
457
|
+
contents = await safeReadJournal(path.dirname(receiptPath), receiptPath);
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
if (isFileNotFoundError(error)) {
|
|
461
|
+
return [];
|
|
462
|
+
}
|
|
463
|
+
throw storeUnreadableError();
|
|
464
|
+
}
|
|
465
|
+
const receipts = [];
|
|
466
|
+
for (const line of contents.split(/\r?\n/u).map((value) => value.trim()).filter(Boolean)) {
|
|
467
|
+
let candidate;
|
|
468
|
+
try {
|
|
469
|
+
candidate = JSON.parse(line);
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
throw controlReceiptIntegrityError();
|
|
473
|
+
}
|
|
474
|
+
if (!isRunControlReceipt(candidate)) {
|
|
475
|
+
throw controlReceiptIntegrityError();
|
|
476
|
+
}
|
|
477
|
+
if (expectedLoopId !== undefined && candidate.loopId !== expectedLoopId) {
|
|
478
|
+
throw controlReceiptIntegrityError();
|
|
479
|
+
}
|
|
480
|
+
const previous = receipts.at(-1);
|
|
481
|
+
if (candidate.previousReceiptHash !== previous?.receiptHash) {
|
|
482
|
+
throw controlReceiptIntegrityError();
|
|
483
|
+
}
|
|
484
|
+
receipts.push(candidate);
|
|
485
|
+
}
|
|
486
|
+
return receipts;
|
|
487
|
+
}
|
|
488
|
+
async function readRunControlPublications(publicationPath, expectedLoopId) {
|
|
489
|
+
let contents;
|
|
60
490
|
try {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
491
|
+
contents = await safeReadJournal(path.dirname(publicationPath), publicationPath);
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
if (isFileNotFoundError(error))
|
|
495
|
+
return [];
|
|
496
|
+
throw controlReceiptIntegrityError();
|
|
497
|
+
}
|
|
498
|
+
const publications = [];
|
|
499
|
+
for (const line of contents.split(/\r?\n/u).map((value) => value.trim()).filter(Boolean)) {
|
|
500
|
+
let candidate;
|
|
501
|
+
try {
|
|
502
|
+
candidate = JSON.parse(line);
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
throw controlReceiptIntegrityError();
|
|
506
|
+
}
|
|
507
|
+
if (!isRunControlPublication(candidate, expectedLoopId)) {
|
|
508
|
+
throw controlReceiptIntegrityError();
|
|
509
|
+
}
|
|
510
|
+
const previous = publications.at(-1);
|
|
511
|
+
if (candidate.previousPublicationHash !== previous?.publicationHash) {
|
|
512
|
+
throw controlReceiptIntegrityError();
|
|
513
|
+
}
|
|
514
|
+
publications.push(candidate);
|
|
515
|
+
}
|
|
516
|
+
return publications;
|
|
517
|
+
}
|
|
518
|
+
async function readRunTerminalIntent(runDirectory, expectedLoopId) {
|
|
519
|
+
const intentPath = path.join(runDirectory, "terminal-intent.jsonl");
|
|
520
|
+
let contents;
|
|
521
|
+
try {
|
|
522
|
+
contents = await safeReadJournal(runDirectory, intentPath);
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
if (isFileNotFoundError(error))
|
|
526
|
+
return undefined;
|
|
527
|
+
throw controlReceiptIntegrityError();
|
|
528
|
+
}
|
|
529
|
+
const lines = contents.split(/\r?\n/u).map((value) => value.trim()).filter(Boolean);
|
|
530
|
+
if (lines.length !== 1)
|
|
531
|
+
throw controlReceiptIntegrityError();
|
|
532
|
+
let candidate;
|
|
533
|
+
try {
|
|
534
|
+
candidate = JSON.parse(lines[0]);
|
|
68
535
|
}
|
|
69
536
|
catch {
|
|
70
|
-
|
|
537
|
+
throw controlReceiptIntegrityError();
|
|
71
538
|
}
|
|
539
|
+
if (typeof candidate !== "object" || candidate === null)
|
|
540
|
+
throw controlReceiptIntegrityError();
|
|
541
|
+
const intent = candidate;
|
|
542
|
+
if (!hasOnlyOwnKeys(intent, ["schemaVersion", "loopId", "recordedAt", "intentHash"]) ||
|
|
543
|
+
intent.schemaVersion !== "martin.run-terminal-intent.v1" ||
|
|
544
|
+
intent.loopId !== expectedLoopId ||
|
|
545
|
+
!isIsoTimestamp(intent.recordedAt) ||
|
|
546
|
+
!isSha256(intent.intentHash)) {
|
|
547
|
+
throw controlReceiptIntegrityError();
|
|
548
|
+
}
|
|
549
|
+
const { intentHash, ...unsigned } = intent;
|
|
550
|
+
if (intentHash !== hashCanonical(unsigned))
|
|
551
|
+
throw controlReceiptIntegrityError();
|
|
552
|
+
return intent;
|
|
72
553
|
}
|
|
73
554
|
function isDetailedLoopSource(value) {
|
|
74
555
|
return typeof value === "object" && value !== null && "loop" in value && "runsRoot" in value;
|
|
75
556
|
}
|
|
557
|
+
function isTerminalRun(loop) {
|
|
558
|
+
return (loop.terminationEnvelope !== undefined ||
|
|
559
|
+
!["queued", "running", "verifying"].includes(loop.status) ||
|
|
560
|
+
!["created", "running", "verifying"].includes(loop.lifecycleState));
|
|
561
|
+
}
|
|
76
562
|
function isRunControlReceipt(value) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
563
|
+
if (typeof value !== "object" || value === null) {
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
const candidate = value;
|
|
567
|
+
const structurallyValid = (hasOnlyOwnKeys(candidate, [
|
|
568
|
+
"schemaVersion",
|
|
569
|
+
"controlId",
|
|
570
|
+
"loopId",
|
|
571
|
+
"action",
|
|
572
|
+
"requestedAt",
|
|
573
|
+
"authorityBoundary",
|
|
574
|
+
"principal",
|
|
575
|
+
"executorLeaseToken",
|
|
576
|
+
"workspaceId",
|
|
577
|
+
"projectId",
|
|
578
|
+
"expectedPreviousControlId",
|
|
579
|
+
"reason",
|
|
580
|
+
"previousReceiptHash",
|
|
581
|
+
"receiptHash"
|
|
582
|
+
]) &&
|
|
583
|
+
candidate.schemaVersion === "martin.run-control.v2" &&
|
|
584
|
+
isNonEmptyString(candidate.controlId) &&
|
|
585
|
+
isNonEmptyString(candidate.loopId) &&
|
|
586
|
+
isControlAction(candidate.action) &&
|
|
587
|
+
isIsoTimestamp(candidate.requestedAt) &&
|
|
588
|
+
(candidate.expectedPreviousControlId === undefined ||
|
|
589
|
+
isNonEmptyString(candidate.expectedPreviousControlId)) &&
|
|
590
|
+
(candidate.action === "continue"
|
|
591
|
+
? isNonEmptyString(candidate.expectedPreviousControlId)
|
|
592
|
+
: candidate.expectedPreviousControlId === undefined) &&
|
|
593
|
+
candidate.authorityBoundary === "local_os_user" &&
|
|
594
|
+
candidate.principal === "local_mcp_client" &&
|
|
595
|
+
isNonEmptyString(candidate.executorLeaseToken) &&
|
|
596
|
+
(candidate.workspaceId === undefined || isNonEmptyString(candidate.workspaceId)) &&
|
|
597
|
+
(candidate.projectId === undefined || isNonEmptyString(candidate.projectId)) &&
|
|
598
|
+
(candidate.reason === undefined || isNonEmptyString(candidate.reason)) &&
|
|
599
|
+
(candidate.previousReceiptHash === undefined || isSha256(candidate.previousReceiptHash)) &&
|
|
600
|
+
isSha256(candidate.receiptHash));
|
|
601
|
+
if (!structurallyValid)
|
|
602
|
+
return false;
|
|
603
|
+
const { receiptHash, ...unsigned } = value;
|
|
604
|
+
return receiptHash === hashCanonical(unsigned);
|
|
605
|
+
}
|
|
606
|
+
function isRunControlApplication(value, expectedLoopId) {
|
|
607
|
+
if (typeof value !== "object" || value === null)
|
|
608
|
+
return false;
|
|
609
|
+
const candidate = value;
|
|
610
|
+
const structurallyValid = (hasOnlyOwnKeys(candidate, [
|
|
611
|
+
"schemaVersion",
|
|
612
|
+
"loopId",
|
|
613
|
+
"controlId",
|
|
614
|
+
"action",
|
|
615
|
+
"appliedAt",
|
|
616
|
+
"relatedControlId",
|
|
617
|
+
"receiptHash",
|
|
618
|
+
"previousApplicationHash",
|
|
619
|
+
"applicationHash"
|
|
620
|
+
]) &&
|
|
621
|
+
candidate.schemaVersion === "martin.run-control-application.v2" &&
|
|
622
|
+
isNonEmptyString(candidate.loopId) &&
|
|
623
|
+
(expectedLoopId === undefined || candidate.loopId === expectedLoopId) &&
|
|
624
|
+
isNonEmptyString(candidate.controlId) &&
|
|
625
|
+
(candidate.action === "pause_applied" || candidate.action === "continue_applied") &&
|
|
626
|
+
isIsoTimestamp(candidate.appliedAt) &&
|
|
627
|
+
(candidate.relatedControlId === undefined || isNonEmptyString(candidate.relatedControlId)) &&
|
|
628
|
+
(candidate.action === "continue_applied"
|
|
629
|
+
? isNonEmptyString(candidate.relatedControlId)
|
|
630
|
+
: candidate.relatedControlId === undefined) &&
|
|
631
|
+
isSha256(candidate.receiptHash) &&
|
|
632
|
+
(candidate.previousApplicationHash === undefined || isSha256(candidate.previousApplicationHash)) &&
|
|
633
|
+
isSha256(candidate.applicationHash));
|
|
634
|
+
if (!structurallyValid)
|
|
635
|
+
return false;
|
|
636
|
+
const { applicationHash, ...unsigned } = value;
|
|
637
|
+
return applicationHash === hashCanonical(unsigned);
|
|
638
|
+
}
|
|
639
|
+
function isRunControlPublication(value, expectedLoopId) {
|
|
640
|
+
if (typeof value !== "object" || value === null)
|
|
641
|
+
return false;
|
|
642
|
+
const candidate = value;
|
|
643
|
+
const structurallyValid = hasOnlyOwnKeys(candidate, [
|
|
644
|
+
"schemaVersion",
|
|
645
|
+
"loopId",
|
|
646
|
+
"controlId",
|
|
647
|
+
"receiptHash",
|
|
648
|
+
"signalState",
|
|
649
|
+
"recordedAt",
|
|
650
|
+
"previousPublicationHash",
|
|
651
|
+
"publicationHash"
|
|
652
|
+
]) &&
|
|
653
|
+
candidate.schemaVersion === "martin.run-control-publication.v1" &&
|
|
654
|
+
candidate.loopId === expectedLoopId &&
|
|
655
|
+
isNonEmptyString(candidate.controlId) &&
|
|
656
|
+
isSha256(candidate.receiptHash) &&
|
|
657
|
+
(candidate.signalState === "created" || candidate.signalState === "already_exists") &&
|
|
658
|
+
isIsoTimestamp(candidate.recordedAt) &&
|
|
659
|
+
(candidate.previousPublicationHash === undefined || isSha256(candidate.previousPublicationHash)) &&
|
|
660
|
+
isSha256(candidate.publicationHash);
|
|
661
|
+
if (!structurallyValid)
|
|
662
|
+
return false;
|
|
663
|
+
const { publicationHash, ...unsigned } = value;
|
|
664
|
+
return publicationHash === hashCanonical(unsigned);
|
|
665
|
+
}
|
|
666
|
+
function requirePendingPauseControlId(state) {
|
|
667
|
+
const latest = state.latestReceipt;
|
|
668
|
+
if (latest?.action !== "pause") {
|
|
669
|
+
throw unsupportedOperationError("Continue requires one pending pause request.", "Pause the active run first, then continue the exact pause returned by MartinLoop.");
|
|
670
|
+
}
|
|
671
|
+
return latest.controlId;
|
|
672
|
+
}
|
|
673
|
+
function nextControlRequestedAt(state) {
|
|
674
|
+
let latestEvidenceMs = -Infinity;
|
|
675
|
+
for (const receipt of state.receipts) {
|
|
676
|
+
latestEvidenceMs = Math.max(latestEvidenceMs, Date.parse(receipt.requestedAt));
|
|
677
|
+
}
|
|
678
|
+
for (const application of state.applications) {
|
|
679
|
+
latestEvidenceMs = Math.max(latestEvidenceMs, Date.parse(application.appliedAt));
|
|
680
|
+
}
|
|
681
|
+
return new Date(Math.max(Date.now(), latestEvidenceMs + 1)).toISOString();
|
|
682
|
+
}
|
|
683
|
+
function isControlAction(value) {
|
|
684
|
+
return value === "pause" || value === "cancel" || value === "continue";
|
|
685
|
+
}
|
|
686
|
+
function isNonEmptyString(value) {
|
|
687
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
688
|
+
}
|
|
689
|
+
function hasOnlyOwnKeys(value, allowedKeys) {
|
|
690
|
+
const allowed = new Set(allowedKeys);
|
|
691
|
+
return Object.keys(value).every((key) => allowed.has(key));
|
|
692
|
+
}
|
|
693
|
+
function isSha256(value) {
|
|
694
|
+
return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
|
|
695
|
+
}
|
|
696
|
+
export function hashCanonical(value) {
|
|
697
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
698
|
+
}
|
|
699
|
+
function isIsoTimestamp(value) {
|
|
700
|
+
return (isNonEmptyString(value) &&
|
|
701
|
+
Number.isFinite(Date.parse(value)) &&
|
|
702
|
+
new Date(value).toISOString() === value);
|
|
703
|
+
}
|
|
704
|
+
function isFileNotFoundError(error) {
|
|
705
|
+
return (typeof error === "object" &&
|
|
706
|
+
error !== null &&
|
|
707
|
+
"code" in error &&
|
|
708
|
+
error.code === "ENOENT");
|
|
709
|
+
}
|
|
710
|
+
function controlReceiptIntegrityError() {
|
|
711
|
+
return storeUnreadableError("Run control receipt integrity check failed.");
|
|
712
|
+
}
|
|
713
|
+
function sameFileIdentity(left, right) {
|
|
714
|
+
return String(left.dev) === String(right.dev) && String(left.ino) === String(right.ino);
|
|
715
|
+
}
|
|
716
|
+
async function assertSafeRunDirectory(runDirectory) {
|
|
717
|
+
const parentDirectory = path.dirname(runDirectory);
|
|
718
|
+
const parentEntry = await lstat(parentDirectory);
|
|
719
|
+
if (!parentEntry.isDirectory() || parentEntry.isSymbolicLink())
|
|
720
|
+
throw controlReceiptIntegrityError();
|
|
721
|
+
const entry = await lstat(runDirectory);
|
|
722
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
723
|
+
throw controlReceiptIntegrityError();
|
|
724
|
+
const canonical = path.resolve(await realpath(runDirectory));
|
|
725
|
+
const canonicalParent = path.resolve(await realpath(parentDirectory));
|
|
726
|
+
// Compare the leaf against the canonical parent rather than the lexical
|
|
727
|
+
// absolute path. macOS exposes /var through the trusted /private/var system
|
|
728
|
+
// alias; canonicalizing the parent preserves that platform behavior while
|
|
729
|
+
// still rejecting a linked runs-root or run-directory leaf.
|
|
730
|
+
const expected = path.join(canonicalParent, path.basename(runDirectory));
|
|
731
|
+
const isSamePath = process.platform === "win32"
|
|
732
|
+
? canonical.toLowerCase() === expected.toLowerCase()
|
|
733
|
+
: canonical === expected;
|
|
734
|
+
if (!isSamePath) {
|
|
735
|
+
throw controlReceiptIntegrityError();
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
async function safeReadJournal(runDirectory, journalPath) {
|
|
739
|
+
await assertSafeRunDirectory(runDirectory);
|
|
740
|
+
const before = await lstat(journalPath);
|
|
741
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
|
|
742
|
+
throw controlReceiptIntegrityError();
|
|
743
|
+
}
|
|
744
|
+
const handle = await open(journalPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
745
|
+
try {
|
|
746
|
+
const opened = await handle.stat();
|
|
747
|
+
if (!opened.isFile() || opened.nlink !== 1 || !sameFileIdentity(before, opened)) {
|
|
748
|
+
throw controlReceiptIntegrityError();
|
|
749
|
+
}
|
|
750
|
+
const contents = await handle.readFile({ encoding: "utf8" });
|
|
751
|
+
const after = await lstat(journalPath);
|
|
752
|
+
await assertSafeRunDirectory(runDirectory);
|
|
753
|
+
if (!after.isFile() || after.isSymbolicLink() || after.nlink !== 1 || !sameFileIdentity(opened, after)) {
|
|
754
|
+
throw controlReceiptIntegrityError();
|
|
755
|
+
}
|
|
756
|
+
return contents;
|
|
757
|
+
}
|
|
758
|
+
finally {
|
|
759
|
+
await handle.close();
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
async function safeAppendJournal(runDirectory, journalPath, contents, afterOpen) {
|
|
763
|
+
await assertSafeRunDirectory(runDirectory);
|
|
764
|
+
const before = await lstat(journalPath).catch((error) => {
|
|
765
|
+
if (isNodeError(error, "ENOENT"))
|
|
766
|
+
return undefined;
|
|
767
|
+
throw error;
|
|
768
|
+
});
|
|
769
|
+
if (before && (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1)) {
|
|
770
|
+
throw controlReceiptIntegrityError();
|
|
771
|
+
}
|
|
772
|
+
const flags = constants.O_WRONLY | constants.O_APPEND | (constants.O_NOFOLLOW ?? 0) |
|
|
773
|
+
(before ? 0 : constants.O_CREAT | constants.O_EXCL);
|
|
774
|
+
const handle = await open(journalPath, flags, 0o600);
|
|
775
|
+
try {
|
|
776
|
+
const opened = await handle.stat();
|
|
777
|
+
if (!opened.isFile() || opened.nlink !== 1 || (before && !sameFileIdentity(before, opened))) {
|
|
778
|
+
throw controlReceiptIntegrityError();
|
|
779
|
+
}
|
|
780
|
+
await afterOpen?.(journalPath);
|
|
781
|
+
await handle.writeFile(contents, "utf8");
|
|
782
|
+
await handle.sync();
|
|
783
|
+
const after = await lstat(journalPath);
|
|
784
|
+
await assertSafeRunDirectory(runDirectory);
|
|
785
|
+
if (!after.isFile() || after.isSymbolicLink() || after.nlink !== 1 || !sameFileIdentity(opened, after)) {
|
|
786
|
+
throw controlReceiptIntegrityError();
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
finally {
|
|
790
|
+
await handle.close();
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
const RUN_CONTROL_LOCK_RETRY_MS = 25;
|
|
794
|
+
const RUN_CONTROL_LOCK_MAX_RETRIES = 200;
|
|
795
|
+
async function withRunControlLock(runDirectory, operation, maxRetries = RUN_CONTROL_LOCK_MAX_RETRIES) {
|
|
796
|
+
await assertSafeRunDirectory(runDirectory);
|
|
797
|
+
const lockPath = path.join(runDirectory, ".run-control.lock");
|
|
798
|
+
const token = randomBytes(16).toString("hex");
|
|
799
|
+
let lockHandle;
|
|
800
|
+
let lease;
|
|
801
|
+
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
|
|
802
|
+
try {
|
|
803
|
+
lockHandle = await open(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
|
804
|
+
const now = new Date().toISOString();
|
|
805
|
+
lease = {
|
|
806
|
+
schemaVersion: "martin.run-control-lock.v1",
|
|
807
|
+
token,
|
|
808
|
+
pid: process.pid,
|
|
809
|
+
hostname: hostname(),
|
|
810
|
+
acquiredAt: now,
|
|
811
|
+
heartbeatAt: now
|
|
812
|
+
};
|
|
813
|
+
await lockHandle.writeFile(`${JSON.stringify(lease)}\n`, "utf8");
|
|
814
|
+
await lockHandle.sync();
|
|
815
|
+
break;
|
|
816
|
+
}
|
|
817
|
+
catch (error) {
|
|
818
|
+
if (!isNodeError(error, "EEXIST"))
|
|
819
|
+
throw storeUnreadableError();
|
|
820
|
+
try {
|
|
821
|
+
const before = await lstat(lockPath);
|
|
822
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
|
|
823
|
+
throw storeUnreadableError();
|
|
824
|
+
}
|
|
825
|
+
const raw = await safeReadJournal(runDirectory, lockPath);
|
|
826
|
+
const existing = JSON.parse(raw);
|
|
827
|
+
if (existing.schemaVersion === "martin.run-control-lock.v1" &&
|
|
828
|
+
typeof existing.token === "string" &&
|
|
829
|
+
existing.hostname === hostname() &&
|
|
830
|
+
typeof existing.pid === "number" &&
|
|
831
|
+
!isProcessAlive(existing.pid)) {
|
|
832
|
+
if (await reclaimDeadRunControlLock(runDirectory, lockPath, raw, before, existing.token)) {
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
catch (inspectionError) {
|
|
838
|
+
if (isNodeError(inspectionError, "ENOENT"))
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
await new Promise((resolve) => setTimeout(resolve, RUN_CONTROL_LOCK_RETRY_MS));
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (!lockHandle || !lease) {
|
|
845
|
+
throw storeUnreadableError("Run control journal is busy; no control was recorded.");
|
|
846
|
+
}
|
|
847
|
+
let heartbeatRunning = false;
|
|
848
|
+
const heartbeat = setInterval(() => {
|
|
849
|
+
if (!lockHandle || !lease || heartbeatRunning)
|
|
850
|
+
return;
|
|
851
|
+
heartbeatRunning = true;
|
|
852
|
+
lease = { ...lease, heartbeatAt: new Date().toISOString() };
|
|
853
|
+
const serializedLease = `${JSON.stringify(lease)}\n`;
|
|
854
|
+
void lockHandle.truncate(0)
|
|
855
|
+
// FileHandle.writeFile() writes from the handle's current position. The
|
|
856
|
+
// explicit position prevents a heartbeat from leaving a sparse prefix
|
|
857
|
+
// after truncate(), which would make the ownership record unreadable.
|
|
858
|
+
.then(() => lockHandle.write(serializedLease, 0, "utf8"))
|
|
859
|
+
.then(() => lockHandle.sync())
|
|
860
|
+
.catch(() => undefined)
|
|
861
|
+
.finally(() => {
|
|
862
|
+
heartbeatRunning = false;
|
|
863
|
+
});
|
|
864
|
+
}, 1_000);
|
|
865
|
+
heartbeat.unref();
|
|
866
|
+
try {
|
|
867
|
+
return await operation();
|
|
868
|
+
}
|
|
869
|
+
finally {
|
|
870
|
+
clearInterval(heartbeat);
|
|
871
|
+
await lockHandle.close();
|
|
872
|
+
const raw = await safeReadJournal(runDirectory, lockPath).catch(() => undefined);
|
|
873
|
+
if (raw) {
|
|
874
|
+
try {
|
|
875
|
+
const current = JSON.parse(raw);
|
|
876
|
+
if (current.token === token)
|
|
877
|
+
await unlink(lockPath);
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
// A replaced/corrupt lock is never deleted by a former owner.
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
async function reclaimDeadRunControlLock(runDirectory, lockPath, observedRaw, observed, staleToken) {
|
|
886
|
+
const claimPath = path.join(runDirectory, `.run-control.reclaim-${createHash("sha256").update(staleToken).digest("hex").slice(0, 24)}`);
|
|
887
|
+
try {
|
|
888
|
+
// A hard-link is an atomic compare token for the observed inode. Exactly
|
|
889
|
+
// one concurrent reclaimer can create this deterministic claim.
|
|
890
|
+
await link(lockPath, claimPath);
|
|
891
|
+
}
|
|
892
|
+
catch (error) {
|
|
893
|
+
if (isNodeError(error, "EEXIST") || isNodeError(error, "ENOENT"))
|
|
894
|
+
return false;
|
|
895
|
+
throw error;
|
|
896
|
+
}
|
|
897
|
+
try {
|
|
898
|
+
const claimed = await lstat(claimPath);
|
|
899
|
+
const current = await lstat(lockPath).catch((error) => {
|
|
900
|
+
if (isNodeError(error, "ENOENT"))
|
|
901
|
+
return undefined;
|
|
902
|
+
throw error;
|
|
903
|
+
});
|
|
904
|
+
if (!current ||
|
|
905
|
+
!claimed.isFile() ||
|
|
906
|
+
claimed.isSymbolicLink() ||
|
|
907
|
+
!sameFileIdentity(observed, claimed) ||
|
|
908
|
+
!sameFileIdentity(claimed, current)) {
|
|
909
|
+
return false;
|
|
910
|
+
}
|
|
911
|
+
const handle = await open(claimPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
912
|
+
let claimRaw;
|
|
913
|
+
try {
|
|
914
|
+
const opened = await handle.stat();
|
|
915
|
+
if (!sameFileIdentity(claimed, opened))
|
|
916
|
+
return false;
|
|
917
|
+
claimRaw = await handle.readFile({ encoding: "utf8" });
|
|
918
|
+
}
|
|
919
|
+
finally {
|
|
920
|
+
await handle.close();
|
|
921
|
+
}
|
|
922
|
+
if (claimRaw !== observedRaw)
|
|
923
|
+
return false;
|
|
924
|
+
// Only the successful claim creator may unlink. Other reclaimers wait for
|
|
925
|
+
// the claim to disappear, preventing a second stale observer from deleting
|
|
926
|
+
// a newly acquired lock after the first reclaimer succeeds.
|
|
927
|
+
const beforeUnlink = await lstat(lockPath).catch(() => undefined);
|
|
928
|
+
if (!beforeUnlink || !sameFileIdentity(claimed, beforeUnlink))
|
|
929
|
+
return false;
|
|
930
|
+
await unlink(lockPath);
|
|
931
|
+
return true;
|
|
932
|
+
}
|
|
933
|
+
finally {
|
|
934
|
+
await unlink(claimPath).catch(() => undefined);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
function isProcessAlive(pid) {
|
|
938
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
939
|
+
return true;
|
|
940
|
+
try {
|
|
941
|
+
process.kill(pid, 0);
|
|
942
|
+
return true;
|
|
943
|
+
}
|
|
944
|
+
catch (error) {
|
|
945
|
+
return !isNodeError(error, "ESRCH");
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
function isNodeError(error, code) {
|
|
949
|
+
return (typeof error === "object" &&
|
|
950
|
+
error !== null &&
|
|
951
|
+
"code" in error &&
|
|
952
|
+
error.code === code);
|
|
83
953
|
}
|
|
84
954
|
export function validateControlReason(value) {
|
|
85
955
|
if (value === undefined) {
|