@holmes-lab/holmes-kit 0.19.6 → 0.20.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/CHANGELOG.md +96 -0
- package/README.md +8 -1
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve-status.d.ts +24 -0
- package/dist/holmes/cli/approve-status.js +166 -0
- package/dist/holmes/cli/approve-watch.d.ts +6 -0
- package/dist/holmes/cli/approve-watch.js +6 -0
- package/dist/holmes/cli/approve.d.ts +27 -0
- package/dist/holmes/cli/approve.js +68 -7
- package/dist/holmes/cli/cli-execution.d.ts +2 -0
- package/dist/holmes/cli/cli-execution.js +24 -0
- package/dist/holmes/cli/gitignore-merge.d.ts +0 -5
- package/dist/holmes/cli/gitignore-merge.js +6 -2
- package/dist/holmes/cli/index.js +176 -139
- package/dist/holmes/cli/playbook-skills.d.ts +2 -22
- package/dist/holmes/cli/playbook-skills.js +10 -54
- package/dist/holmes/governance/approval-grants.d.ts +22 -0
- package/dist/holmes/governance/approval-grants.js +55 -1
- package/dist/holmes/governance/approval-queue.d.ts +43 -4
- package/dist/holmes/governance/approval-queue.js +86 -12
- package/dist/holmes/governance/display-id-aliases.d.ts +29 -0
- package/dist/holmes/governance/display-id-aliases.js +86 -0
- package/dist/holmes/governance/ledger-store.d.ts +42 -14
- package/dist/holmes/governance/ledger-store.js +49 -3
- package/dist/holmes/governance/ledger-timeline.d.ts +3 -1
- package/dist/holmes/governance/ledger-timeline.js +11 -1
- package/dist/holmes/governance/provenance-chain.d.ts +2 -0
- package/dist/holmes/governance/provenance-chain.js +13 -2
- package/dist/holmes/governance/provenance-ledger.d.ts +28 -0
- package/dist/holmes/governance/provenance-ledger.js +69 -16
- package/dist/holmes/governance/session-context.d.ts +3 -0
- package/dist/holmes/governance/session-context.js +26 -8
- package/dist/holmes/guardrail/risk-classifier.js +2 -2
- package/dist/holmes/guardrail/write-target.js +2 -1
- package/dist/holmes/hooks/pre-tool-use.js +12 -1
- package/dist/holmes/hooks/stop.js +14 -3
- package/dist/holmes/mcp/handlers/adoption.d.ts +118 -0
- package/dist/holmes/mcp/handlers/adoption.js +186 -0
- package/dist/holmes/mcp/handlers/entity-integration.d.ts +100 -0
- package/dist/holmes/mcp/handlers/entity-integration.js +324 -0
- package/dist/holmes/mcp/handlers/entity-renumber.d.ts +137 -0
- package/dist/holmes/mcp/handlers/entity-renumber.js +228 -0
- package/dist/holmes/mcp/handlers/entity-store.d.ts +78 -0
- package/dist/holmes/mcp/handlers/entity-store.js +212 -0
- package/dist/holmes/mcp/handlers/graph-operations.d.ts +90 -0
- package/dist/holmes/mcp/handlers/graph-operations.js +260 -0
- package/dist/holmes/mcp/handlers/maintenance-analysis.d.ts +34 -0
- package/dist/holmes/mcp/handlers/maintenance-analysis.js +288 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +40 -0
- package/dist/holmes/mcp/handlers/maintenance-evidence.js +104 -0
- package/dist/holmes/mcp/handlers/operator-inspection.d.ts +167 -0
- package/dist/holmes/mcp/handlers/operator-inspection.js +284 -0
- package/dist/holmes/mcp/handlers/phase-queries.d.ts +34 -0
- package/dist/holmes/mcp/handlers/phase-queries.js +86 -0
- package/dist/holmes/mcp/handlers/review-evidence.d.ts +47 -0
- package/dist/holmes/mcp/handlers/review-evidence.js +370 -0
- package/dist/holmes/mcp/handlers/review-queries.d.ts +68 -0
- package/dist/holmes/mcp/handlers/review-queries.js +79 -0
- package/dist/holmes/mcp/handlers/risk-assessment.d.ts +25 -0
- package/dist/holmes/mcp/handlers/risk-assessment.js +181 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.d.ts +55 -0
- package/dist/holmes/mcp/handlers/slice-orchestration.js +324 -0
- package/dist/holmes/mcp/handlers/spec-approval.d.ts +105 -0
- package/dist/holmes/mcp/handlers/spec-approval.js +504 -0
- package/dist/holmes/mcp/handlers/spec-authoring.d.ts +57 -0
- package/dist/holmes/mcp/handlers/spec-authoring.js +326 -0
- package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +122 -0
- package/dist/holmes/mcp/handlers/spec-lifecycle.js +339 -0
- package/dist/holmes/mcp/handlers/spec-queries.d.ts +30 -0
- package/dist/holmes/mcp/handlers/spec-queries.js +74 -0
- package/dist/holmes/mcp/handlers/taint-screening.d.ts +48 -0
- package/dist/holmes/mcp/handlers/taint-screening.js +183 -0
- package/dist/holmes/mcp/handlers/test-execution.d.ts +63 -0
- package/dist/holmes/mcp/handlers/test-execution.js +210 -0
- package/dist/holmes/mcp/handlers/workspace-identity.d.ts +30 -0
- package/dist/holmes/mcp/handlers/workspace-identity.js +68 -0
- package/dist/holmes/mcp/handlers/workspace-queries.d.ts +77 -0
- package/dist/holmes/mcp/handlers/workspace-queries.js +193 -0
- package/dist/holmes/mcp/handlers.d.ts +653 -424
- package/dist/holmes/mcp/handlers.js +267 -3250
- package/dist/holmes/mcp/server.js +2 -1
- package/dist/holmes/mcp/tool-schemas.js +43 -2
- package/dist/holmes/project/execution-context.d.ts +17 -0
- package/dist/holmes/project/execution-context.js +120 -0
- package/dist/holmes/project/installer-markers.d.ts +34 -0
- package/dist/holmes/project/installer-markers.js +65 -0
- package/dist/holmes/project/root.d.ts +3 -1
- package/dist/holmes/project/root.js +4 -3
- package/dist/holmes/project/workspace-identity.d.ts +29 -0
- package/dist/holmes/project/workspace-identity.js +181 -0
- package/dist/holmes/review/test-outcomes.d.ts +6 -1
- package/dist/holmes/review/test-outcomes.js +23 -5
- package/dist/holmes/review/test-runner.d.ts +18 -0
- package/dist/holmes/review/test-runner.js +132 -4
- package/dist/holmes/rtm/graph-store.d.ts +50 -1
- package/dist/holmes/rtm/graph-store.js +244 -3
- package/dist/holmes/rtm/incremental.d.ts +1 -0
- package/dist/holmes/rtm/incremental.js +12 -3
- package/dist/holmes/rtm/localize.js +7 -0
- package/dist/holmes/rtm/rtm-graph.d.ts +9 -0
- package/dist/holmes/rtm/rtm-graph.js +13 -0
- package/dist/holmes/semantic/vector-cache.d.ts +16 -1
- package/dist/holmes/semantic/vector-cache.js +155 -19
- package/dist/holmes/spec/approval-status.d.ts +10 -0
- package/dist/holmes/spec/approval-status.js +7 -3
- package/dist/holmes/spec/entity-git-snapshot.d.ts +50 -0
- package/dist/holmes/spec/entity-git-snapshot.js +276 -0
- package/dist/holmes/spec/entity-integration-discharge.d.ts +73 -0
- package/dist/holmes/spec/entity-integration-discharge.js +233 -0
- package/dist/holmes/spec/entity-integration-transaction.d.ts +179 -0
- package/dist/holmes/spec/entity-integration-transaction.js +1221 -0
- package/dist/holmes/spec/entity-integration.d.ts +207 -0
- package/dist/holmes/spec/entity-integration.js +747 -0
- package/dist/holmes/spec/entity-renumber-transaction.d.ts +82 -0
- package/dist/holmes/spec/entity-renumber-transaction.js +711 -0
- package/dist/holmes/spec/entity-renumber.d.ts +32 -0
- package/dist/holmes/spec/entity-renumber.js +156 -0
- package/dist/holmes/spec/entity-store.d.ts +135 -0
- package/dist/holmes/spec/entity-store.js +1051 -0
- package/dist/holmes/spec/entity-transaction.d.ts +89 -0
- package/dist/holmes/spec/entity-transaction.js +701 -0
- package/dist/holmes/spec/renumber.d.ts +58 -0
- package/dist/holmes/spec/renumber.js +200 -1
- package/dist/holmes/spec/spec-store.d.ts +3 -2
- package/dist/holmes/spec/spec-store.js +23 -2
- package/dist/holmes/spec/transition-policy.d.ts +36 -0
- package/dist/holmes/spec/transition-policy.js +135 -0
- package/package.json +1 -1
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.LOCK_STAGING_STALE_MS = exports.SOURCE_PUBLICATION_LOCATOR = exports.EntityStoreError = void 0;
|
|
37
|
+
exports.entityContentVersion = entityContentVersion;
|
|
38
|
+
exports.entityStat = entityStat;
|
|
39
|
+
exports.entityDirectory = entityDirectory;
|
|
40
|
+
exports.readEntityBytes = readEntityBytes;
|
|
41
|
+
exports.publishEntityRecord = publishEntityRecord;
|
|
42
|
+
exports.abandonPublishedSteps = abandonPublishedSteps;
|
|
43
|
+
exports.abandonEntityOperation = abandonEntityOperation;
|
|
44
|
+
exports.entityLockOwner = entityLockOwner;
|
|
45
|
+
exports.entityCompletionRecord = entityCompletionRecord;
|
|
46
|
+
exports.retireEntityOperation = retireEntityOperation;
|
|
47
|
+
exports.assertEntityByteInput = assertEntityByteInput;
|
|
48
|
+
exports.isEntityTransientName = isEntityTransientName;
|
|
49
|
+
exports.observeEntityTarget = observeEntityTarget;
|
|
50
|
+
exports.settleEntityTransients = settleEntityTransients;
|
|
51
|
+
exports.applyEntityByteChange = applyEntityByteChange;
|
|
52
|
+
exports.withEntityStoreLock = withEntityStoreLock;
|
|
53
|
+
exports.withEntityStoreLockAsync = withEntityStoreLockAsync;
|
|
54
|
+
exports.entityLockHolderAlive = entityLockHolderAlive;
|
|
55
|
+
exports.withEntityRecoveryLock = withEntityRecoveryLock;
|
|
56
|
+
// @implements A-SPEC-629, A-SPEC-627
|
|
57
|
+
// @implements A-SPEC-640
|
|
58
|
+
// @implements A-SPEC-639
|
|
59
|
+
// @implements A-SPEC-638
|
|
60
|
+
// @implements A-SPEC-634
|
|
61
|
+
// @implements A-SPEC-633
|
|
62
|
+
const fs = __importStar(require("node:fs"));
|
|
63
|
+
const path = __importStar(require("node:path"));
|
|
64
|
+
const node_crypto_1 = require("node:crypto");
|
|
65
|
+
class EntityStoreError extends Error {
|
|
66
|
+
code;
|
|
67
|
+
holmesRefusal = true;
|
|
68
|
+
constructor(code, message) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.code = code;
|
|
71
|
+
this.name = 'EntityStoreError';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
exports.EntityStoreError = EntityStoreError;
|
|
75
|
+
function entityContentVersion(bytes) {
|
|
76
|
+
return 'sha256:' + (0, node_crypto_1.createHash)('sha256').update(bytes).digest('hex');
|
|
77
|
+
}
|
|
78
|
+
function entityStat(file) {
|
|
79
|
+
try {
|
|
80
|
+
return fs.lstatSync(file);
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
if (e.code === 'ENOENT')
|
|
84
|
+
return undefined;
|
|
85
|
+
throw e;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function entityDirectory(root, relative, create = false) {
|
|
89
|
+
let current = root;
|
|
90
|
+
if (relative === '.')
|
|
91
|
+
return current;
|
|
92
|
+
for (const part of relative.replace(/\\/g, '/').split('/').filter(Boolean)) {
|
|
93
|
+
if (part === '..' || part === '.')
|
|
94
|
+
throw new EntityStoreError('unsafe-path', 'Entity paths must be canonical relative locators.');
|
|
95
|
+
current = path.join(current, part);
|
|
96
|
+
if (create && !entityStat(current)) {
|
|
97
|
+
try {
|
|
98
|
+
fs.mkdirSync(current);
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
if (e.code !== 'EEXIST')
|
|
102
|
+
throw e;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const s = entityStat(current);
|
|
106
|
+
if (s && (!s.isDirectory() || s.isSymbolicLink()))
|
|
107
|
+
throw new EntityStoreError('unsafe-path', 'Entity directories must not be symlinks or non-directories.');
|
|
108
|
+
}
|
|
109
|
+
return current;
|
|
110
|
+
}
|
|
111
|
+
function readEntityBytes(file) {
|
|
112
|
+
const s = entityStat(file);
|
|
113
|
+
if (!s)
|
|
114
|
+
return undefined;
|
|
115
|
+
if (!s.isFile() || s.isSymbolicLink())
|
|
116
|
+
throw new EntityStoreError('unsafe-path', 'Entity records must be regular files.');
|
|
117
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
118
|
+
try {
|
|
119
|
+
const opened = fs.fstatSync(fd);
|
|
120
|
+
if (!opened.isFile() || opened.dev !== s.dev || opened.ino !== s.ino)
|
|
121
|
+
throw new EntityStoreError('entity-state-changed', 'Entity record changed while opening.');
|
|
122
|
+
return fs.readFileSync(fd);
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
fs.closeSync(fd);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Stage and fsync complete bytes, then publish without replacing any existing target. */
|
|
129
|
+
function publishEntityRecord(root, relative, bytes) {
|
|
130
|
+
const dir = entityDirectory(root, path.dirname(relative), true);
|
|
131
|
+
const destination = path.join(dir, path.basename(relative));
|
|
132
|
+
const temp = path.join(dir, `.entity-${(0, node_crypto_1.randomUUID)()}.tmp`);
|
|
133
|
+
const fd = fs.openSync(temp, 'wx', 0o600);
|
|
134
|
+
try {
|
|
135
|
+
fs.writeFileSync(fd, bytes);
|
|
136
|
+
fs.fsyncSync(fd);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
fs.closeSync(fd);
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
fs.linkSync(temp, destination);
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
fs.unlinkSync(temp);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* @implements A-SPEC-639 — roll an operation's own publications back. Steps are judged in reverse
|
|
150
|
+
* order, one verdict per locator (its last step): bytes equal to `after` are the operation's own
|
|
151
|
+
* publication and are restored to `before`; bytes equal to `before` were never published; anything
|
|
152
|
+
* else is a third state and is preserved untouched.
|
|
153
|
+
*/
|
|
154
|
+
function abandonPublishedSteps(root, steps) {
|
|
155
|
+
const last = new Map();
|
|
156
|
+
for (const step of steps)
|
|
157
|
+
last.set(step.locator, step);
|
|
158
|
+
const restored = [], untouched = [], preserved = [];
|
|
159
|
+
for (const step of [...steps].reverse()) {
|
|
160
|
+
if (last.get(step.locator) !== step)
|
|
161
|
+
continue;
|
|
162
|
+
const dir = entityDirectory(root, path.dirname(step.locator)), target = path.join(dir, path.basename(step.locator));
|
|
163
|
+
const bytes = readEntityBytes(target), current = bytes?.toString('base64') ?? null;
|
|
164
|
+
if (current === step.after && step.after !== step.before) {
|
|
165
|
+
const beforeMode = bytes ? ((entityStat(target).mode & 0o100) ? '100755' : '100644') : null;
|
|
166
|
+
applyEntityByteChange(root, { locator: step.locator, before: current, after: step.before, beforeMode, afterMode: step.before === null ? null : (step.beforeMode ?? '100644') });
|
|
167
|
+
restored.push(step.locator);
|
|
168
|
+
}
|
|
169
|
+
else if (current === step.before)
|
|
170
|
+
untouched.push(step.locator);
|
|
171
|
+
else
|
|
172
|
+
preserved.push(step.locator);
|
|
173
|
+
}
|
|
174
|
+
return { restored: restored.sort(), untouched: untouched.sort(), preserved: preserved.sort() };
|
|
175
|
+
}
|
|
176
|
+
/** @implements A-SPEC-639 — retire a pending operation as ABANDONED: its journal and owners move under `.ax/state/entity-abandoned`. */
|
|
177
|
+
function abandonEntityOperation(root, operationId, locator, expectedJournalVersion, outcome) {
|
|
178
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(operationId))
|
|
179
|
+
throw new EntityStoreError('invalid-entity-state', 'Invalid operation abandonment identifier.');
|
|
180
|
+
const pending = entityDirectory(root, '.ax/state/entity-transactions/' + operationId);
|
|
181
|
+
const journal = readEntityBytes(path.join(pending, 'journal.json'));
|
|
182
|
+
if (!journal)
|
|
183
|
+
throw new EntityStoreError('invalid-entity-state', 'Operation abandonment requires its journal.');
|
|
184
|
+
if (entityContentVersion(journal) !== expectedJournalVersion)
|
|
185
|
+
throw new EntityStoreError('plan-changed', 'Abandonment cannot certify a journal different from the inspected operation.');
|
|
186
|
+
const lockOwner = entityLockOwner(root, locator);
|
|
187
|
+
if (!lockOwner)
|
|
188
|
+
throw new EntityStoreError('unknown-owner', 'Abandonment requires owned store exclusion.');
|
|
189
|
+
const record = JSON.stringify({ schema: 'holmes-entity-abandonment/1', operationId, storeLocator: locator, lockOwner, journalVersion: expectedJournalVersion, ...outcome, abandonedAt: new Date().toISOString() });
|
|
190
|
+
publishEntityRecord(root, path.join('.ax/state/entity-transactions', operationId, 'abandoned.json'), record);
|
|
191
|
+
const abandoned = entityDirectory(root, '.ax/state/entity-abandoned', true);
|
|
192
|
+
const destination = path.join(abandoned, operationId);
|
|
193
|
+
if (entityStat(destination))
|
|
194
|
+
throw new EntityStoreError('retirement-conflict', 'An abandoned operation already occupies the destination; preserve both records.');
|
|
195
|
+
fs.renameSync(pending, destination);
|
|
196
|
+
}
|
|
197
|
+
/** Keep the complete journal as a receipt; never expose an empty pending operation. */
|
|
198
|
+
function entityLockOwner(root, locator) {
|
|
199
|
+
const dir = entityDirectory(root, path.join('.ax/state/entity-locks', (0, node_crypto_1.createHash)('sha256').update(locator).digest('hex')));
|
|
200
|
+
if (!entityStat(dir))
|
|
201
|
+
return null;
|
|
202
|
+
// @implements A-SPEC-633 — the lock can be RETIRED (renamed away by its owner) between the stat
|
|
203
|
+
// above and this read. That is "no owner", never a raw filesystem error: a read-only inspection
|
|
204
|
+
// must not fail because a writer finished. A directory that still exists without a readable
|
|
205
|
+
// record is a different thing — legacy or foreign state — and stays `unknown-owner` below.
|
|
206
|
+
// A record that was present at stat time but vanished or was replaced before the open (ENOENT,
|
|
207
|
+
// or the inode changed under the read) was retired under the reader — whatever occupies the
|
|
208
|
+
// path now, including a fresh lock a tight writer published in between. Re-read once against
|
|
209
|
+
// the current state; if that is disrupted again, there was no stable owner at this instant.
|
|
210
|
+
// The record can also be simply ABSENT at read time (stat undefined, no throw) because the
|
|
211
|
+
// generation was renamed aside and a tight writer already published the next one at this path:
|
|
212
|
+
// the directory the reader stat'ed is not the directory it is reading. Decide by directory
|
|
213
|
+
// identity: a different inode is a new generation (re-read once, then "no stable owner"); the same
|
|
214
|
+
// inode without a record is foreign or legacy state and stays `unknown-owner` below.
|
|
215
|
+
let bytes, generation = entityStat(dir);
|
|
216
|
+
for (let attempt = 0;; attempt++) {
|
|
217
|
+
let vanished = false;
|
|
218
|
+
try {
|
|
219
|
+
bytes = readEntityBytes(path.join(dir, 'owner.json'));
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
vanished = e.code === 'ENOENT' || (e instanceof EntityStoreError && e.code === 'entity-state-changed');
|
|
223
|
+
if (!vanished)
|
|
224
|
+
throw e;
|
|
225
|
+
}
|
|
226
|
+
if (bytes)
|
|
227
|
+
break;
|
|
228
|
+
const now = entityStat(dir);
|
|
229
|
+
if (!now || !generation)
|
|
230
|
+
return null;
|
|
231
|
+
if (!vanished && now.ino === generation.ino && now.dev === generation.dev)
|
|
232
|
+
break;
|
|
233
|
+
if (attempt >= 1)
|
|
234
|
+
return null;
|
|
235
|
+
generation = now;
|
|
236
|
+
}
|
|
237
|
+
if (!bytes && !entityStat(dir))
|
|
238
|
+
return null;
|
|
239
|
+
let owner;
|
|
240
|
+
try {
|
|
241
|
+
owner = JSON.parse(bytes?.toString('utf8') ?? 'null');
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
throw new EntityStoreError('unknown-owner', 'Malformed initial lock owner.');
|
|
245
|
+
}
|
|
246
|
+
if (!owner || Object.keys(owner).length !== 3 || typeof owner.token !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(owner.token) || typeof owner.pid !== 'number' || !Number.isSafeInteger(owner.pid) || owner.pid <= 0 || owner.storeLocator !== locator || !bytes)
|
|
247
|
+
throw new EntityStoreError('unknown-owner', 'Initial lock ownership is invalid or missing.');
|
|
248
|
+
return { token: owner.token, version: entityContentVersion(bytes) };
|
|
249
|
+
}
|
|
250
|
+
function entityCompletionRecord(root, operationId, locator, journal) {
|
|
251
|
+
const lockOwner = entityLockOwner(root, locator);
|
|
252
|
+
if (!lockOwner)
|
|
253
|
+
throw new EntityStoreError('unknown-owner', 'Completion requires owned store exclusion.');
|
|
254
|
+
return JSON.stringify({ schema: 'holmes-entity-completion/1', operationId, storeLocator: locator, lockOwner, journalVersion: entityContentVersion(journal) });
|
|
255
|
+
}
|
|
256
|
+
function retireEntityOperation(root, operationId, locator, expectedJournalVersion) {
|
|
257
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(operationId))
|
|
258
|
+
throw new EntityStoreError('invalid-entity-state', 'Invalid operation retirement identifier.');
|
|
259
|
+
const pending = entityDirectory(root, '.ax/state/entity-transactions/' + operationId);
|
|
260
|
+
const journal = readEntityBytes(path.join(pending, 'journal.json'));
|
|
261
|
+
if (!journal)
|
|
262
|
+
throw new EntityStoreError('invalid-entity-state', 'Operation retirement requires its journal.');
|
|
263
|
+
if (entityContentVersion(journal) !== expectedJournalVersion)
|
|
264
|
+
throw new EntityStoreError('plan-changed', 'Completion cannot certify a journal different from the executed operation.');
|
|
265
|
+
const record = entityCompletionRecord(root, operationId, locator, journal);
|
|
266
|
+
const receipt = readEntityBytes(path.join(pending, 'completion.json'));
|
|
267
|
+
if (receipt && receipt.toString('utf8') !== record)
|
|
268
|
+
throw new EntityStoreError('retirement-conflict', 'Completion belongs to different inputs or ownership.');
|
|
269
|
+
if (!receipt)
|
|
270
|
+
publishEntityRecord(root, path.join('.ax/state/entity-transactions', operationId, 'completion.json'), record);
|
|
271
|
+
const completed = entityDirectory(root, '.ax/state/entity-completed', true);
|
|
272
|
+
const destination = path.join(completed, operationId);
|
|
273
|
+
if (entityStat(destination))
|
|
274
|
+
throw new EntityStoreError('retirement-conflict', 'A completed operation already occupies the destination; preserve both records.');
|
|
275
|
+
if (!readEntityBytes(path.join(pending, 'journal.json'))?.equals(journal))
|
|
276
|
+
throw new EntityStoreError('plan-changed', 'Journal changed during completion; preserve the operation and its receipt.');
|
|
277
|
+
fs.renameSync(pending, destination);
|
|
278
|
+
}
|
|
279
|
+
function assertEntityByteInput(root, change) {
|
|
280
|
+
const dir = entityDirectory(root, path.dirname(change.locator));
|
|
281
|
+
const target = path.join(dir, path.basename(change.locator));
|
|
282
|
+
const actual = readEntityBytes(target)?.toString('base64') ?? null;
|
|
283
|
+
if (actual !== change.before)
|
|
284
|
+
throw new EntityStoreError('external-change', 'A transaction target changed outside the store; preserve it and recover explicitly.');
|
|
285
|
+
if ('beforeMode' in change || 'afterMode' in change) {
|
|
286
|
+
for (const phase of ['before', 'after']) {
|
|
287
|
+
const mode = change[phase === 'before' ? 'beforeMode' : 'afterMode'];
|
|
288
|
+
if (change[phase] === null ? mode !== null : mode !== '100644' && mode !== '100755')
|
|
289
|
+
throw new EntityStoreError('invalid-entity-state', 'File mode and byte-presence preconditions must agree.');
|
|
290
|
+
}
|
|
291
|
+
const stat = entityStat(target), mode = stat ? (stat.mode & 0o100 ? '100755' : '100644') : null;
|
|
292
|
+
if (mode !== change.beforeMode)
|
|
293
|
+
throw new EntityStoreError('external-change', 'A transaction target mode changed outside the store; preserve it and recover explicitly.');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* @implements A-SPEC-640 — transient names the publication primitive may leave beside a target: its
|
|
298
|
+
* temp (`.entity-<uuid>.tmp`) and the aside an update or deletion moves the original to
|
|
299
|
+
* (`<basename>.entity-aside-<uuid>`). Never tracked, never counted as foreign files.
|
|
300
|
+
*/
|
|
301
|
+
const TRANSIENT_TEMP = /^\.entity-[0-9a-f-]{36}\.tmp$/, TRANSIENT_ASIDE = /\.entity-aside-[0-9a-f-]{36}$/;
|
|
302
|
+
function isEntityTransientName(name) { return TRANSIENT_TEMP.test(name) || TRANSIENT_ASIDE.test(name); }
|
|
303
|
+
function asideEntries(dir, basename) {
|
|
304
|
+
let names;
|
|
305
|
+
try {
|
|
306
|
+
names = fs.readdirSync(dir);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
return names.filter(name => name.startsWith(basename + '.entity-aside-') && TRANSIENT_ASIDE.test(name) && name.length === basename.length + '.entity-aside-'.length + 36).sort();
|
|
312
|
+
}
|
|
313
|
+
const fileMode = (file) => { const s = entityStat(file); return s && s.isFile() ? ((s.mode & 0o100) ? '100755' : '100644') : null; };
|
|
314
|
+
/**
|
|
315
|
+
* @implements A-SPEC-640 — read-only view of a target that sees through a lone aside: when the target
|
|
316
|
+
* is absent and exactly one aside exists beside it, the aside's bytes and mode are the target's current
|
|
317
|
+
* state (a crash between move-aside and link). Several asides are reported, not resolved.
|
|
318
|
+
*/
|
|
319
|
+
function observeEntityTarget(root, locator) {
|
|
320
|
+
const dir = entityDirectory(root, path.dirname(locator)), target = path.join(dir, path.basename(locator));
|
|
321
|
+
const bytes = readEntityBytes(target);
|
|
322
|
+
if (bytes)
|
|
323
|
+
return { bytes, mode: fileMode(target), asides: [] };
|
|
324
|
+
const asides = asideEntries(dir, path.basename(locator));
|
|
325
|
+
if (asides.length === 1) {
|
|
326
|
+
const file = path.join(dir, asides[0]), aside = readEntityBytes(file);
|
|
327
|
+
if (aside)
|
|
328
|
+
return { bytes: aside, mode: fileMode(file), aside: asides[0], asides };
|
|
329
|
+
}
|
|
330
|
+
return { mode: null, asides };
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* @implements A-SPEC-640 — executor-side settlement under ownership: a lone aside goes back to its
|
|
334
|
+
* absent target; temps in the steps' directories holding a step's own `after` bytes are removed.
|
|
335
|
+
* Anything else is preserved.
|
|
336
|
+
*/
|
|
337
|
+
function settleEntityTransients(root, steps) {
|
|
338
|
+
const restored = [], removed = [];
|
|
339
|
+
const afters = new Set(steps.map(step => step.after).filter((after) => after !== null));
|
|
340
|
+
const locators = [...new Set(steps.map(step => step.locator))];
|
|
341
|
+
const byLocator = new Map();
|
|
342
|
+
for (const step of steps)
|
|
343
|
+
byLocator.set(step.locator, [...(byLocator.get(step.locator) ?? []), step]);
|
|
344
|
+
for (const locator of locators) {
|
|
345
|
+
const dir = entityDirectory(root, path.dirname(locator));
|
|
346
|
+
if (!entityStat(dir))
|
|
347
|
+
continue;
|
|
348
|
+
const target = path.join(dir, path.basename(locator)), asides = asideEntries(dir, path.basename(locator));
|
|
349
|
+
if (!entityStat(target)) {
|
|
350
|
+
if (asides.length === 1) {
|
|
351
|
+
fs.renameSync(path.join(dir, asides[0]), target);
|
|
352
|
+
restored.push(locator);
|
|
353
|
+
}
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
// A step that crashed after its link and before removing its aside: the target holds the step's
|
|
357
|
+
// `after`, the aside its `before`. Only that exact pair is cleaned up; any other aside stays.
|
|
358
|
+
const current = readEntityBytes(target)?.toString('base64') ?? null;
|
|
359
|
+
for (const name of asides) {
|
|
360
|
+
const file = path.join(dir, name), bytes = readEntityBytes(file)?.toString('base64') ?? null;
|
|
361
|
+
if ((byLocator.get(locator) ?? []).some(step => step.after === current && step.before === bytes)) {
|
|
362
|
+
fs.unlinkSync(file);
|
|
363
|
+
removed.push(path.dirname(locator) === '.' ? name : path.dirname(locator) + '/' + name);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
for (const relative of [...new Set(locators.map(locator => path.dirname(locator)))]) {
|
|
368
|
+
const dir = entityDirectory(root, relative);
|
|
369
|
+
if (!entityStat(dir))
|
|
370
|
+
continue;
|
|
371
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
372
|
+
if (!TRANSIENT_TEMP.test(name))
|
|
373
|
+
continue;
|
|
374
|
+
const file = path.join(dir, name), s = fs.lstatSync(file);
|
|
375
|
+
if (!s.isFile() || s.isSymbolicLink())
|
|
376
|
+
continue;
|
|
377
|
+
if (afters.has(fs.readFileSync(file).toString('base64'))) {
|
|
378
|
+
fs.unlinkSync(file);
|
|
379
|
+
removed.push(relative === '.' ? name : relative + '/' + name);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return { restored: restored.sort(), removed: removed.sort() };
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Changes are generated by the owning store, not executed from an untrusted recovery payload.
|
|
387
|
+
* @implements A-SPEC-640 — updates and deletions publish through a verified aside: the original is
|
|
388
|
+
* moved aside, checked byte-for-byte (and by mode) against the expected `before`, and only then the
|
|
389
|
+
* new bytes are linked into place (or the aside removed for a deletion). A foreign write inside the
|
|
390
|
+
* window is therefore refused with the original back in place; a file that appears at the target
|
|
391
|
+
* after the move-aside is refused and both files are preserved.
|
|
392
|
+
*/
|
|
393
|
+
function applyEntityByteChange(root, change) {
|
|
394
|
+
assertEntityByteInput(root, change);
|
|
395
|
+
if (change.before === change.after && change.beforeMode === change.afterMode)
|
|
396
|
+
return;
|
|
397
|
+
const dir = entityDirectory(root, path.dirname(change.locator), true);
|
|
398
|
+
const target = path.join(dir, path.basename(change.locator));
|
|
399
|
+
const originalMode = entityStat(target)?.mode;
|
|
400
|
+
const sameBefore = () => {
|
|
401
|
+
assertEntityByteInput(root, change);
|
|
402
|
+
return entityStat(target)?.mode === originalMode;
|
|
403
|
+
};
|
|
404
|
+
if (!sameBefore())
|
|
405
|
+
throw new EntityStoreError('external-change', 'A transaction target changed outside the store; preserve it and recover explicitly.');
|
|
406
|
+
const aside = target + '.entity-aside-' + (0, node_crypto_1.randomUUID)();
|
|
407
|
+
const moveAside = () => {
|
|
408
|
+
fs.renameSync(target, aside);
|
|
409
|
+
const bytes = readEntityBytes(aside)?.toString('base64') ?? null;
|
|
410
|
+
const modeAgrees = change.beforeMode === undefined || fileMode(aside) === change.beforeMode;
|
|
411
|
+
if (bytes !== change.before || !modeAgrees) {
|
|
412
|
+
fs.renameSync(aside, target);
|
|
413
|
+
throw new EntityStoreError('external-change', 'A transaction target changed inside the publication window; the original is back in place.');
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
if (change.after === null) {
|
|
417
|
+
moveAside();
|
|
418
|
+
fs.unlinkSync(aside);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const bytes = Buffer.from(change.after, 'base64');
|
|
422
|
+
const temp = path.join(dir, `.entity-${(0, node_crypto_1.randomUUID)()}.tmp`);
|
|
423
|
+
const fd = fs.openSync(temp, 'wx', 0o600);
|
|
424
|
+
try {
|
|
425
|
+
fs.writeFileSync(fd, bytes);
|
|
426
|
+
const mode = change.afterMode === '100755' ? 0o755 : change.afterMode === '100644' ? 0o644 : originalMode === undefined ? 0o600 : originalMode & 0o777;
|
|
427
|
+
fs.fchmodSync(fd, mode);
|
|
428
|
+
fs.fsyncSync(fd);
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
fs.closeSync(fd);
|
|
432
|
+
}
|
|
433
|
+
const publish = (message) => {
|
|
434
|
+
try {
|
|
435
|
+
fs.linkSync(temp, target);
|
|
436
|
+
}
|
|
437
|
+
catch (e) {
|
|
438
|
+
if (e.code === 'EEXIST')
|
|
439
|
+
throw new EntityStoreError('external-change', message);
|
|
440
|
+
throw e;
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
try {
|
|
444
|
+
if (!sameBefore())
|
|
445
|
+
throw new EntityStoreError('external-change', 'A transaction target changed before publication.');
|
|
446
|
+
if (change.before === null)
|
|
447
|
+
publish('A file appeared at the target during publication; it is preserved.');
|
|
448
|
+
else {
|
|
449
|
+
moveAside();
|
|
450
|
+
publish('A file appeared at the target during publication; it and the aside are preserved.');
|
|
451
|
+
fs.unlinkSync(aside);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
finally {
|
|
455
|
+
if (entityStat(temp))
|
|
456
|
+
fs.unlinkSync(temp);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
function retireOwnedLock(root, dir, expected, owned) {
|
|
460
|
+
const current = entityStat(dir);
|
|
461
|
+
if (current?.dev !== expected.dev || current?.ino !== expected.ino)
|
|
462
|
+
throw new EntityStoreError('unknown-owner', 'Lock directory changed before retirement.');
|
|
463
|
+
for (const name of fs.readdirSync(dir))
|
|
464
|
+
if (!owned.has(path.join(dir, name)))
|
|
465
|
+
throw new EntityStoreError('cleanup-conflict', 'Unknown lock contents must be preserved.');
|
|
466
|
+
const retired = entityDirectory(root, '.ax/state/entity-retired-locks', true);
|
|
467
|
+
const destination = path.join(retired, (0, node_crypto_1.randomUUID)());
|
|
468
|
+
if (entityStat(destination))
|
|
469
|
+
throw new EntityStoreError('retirement-conflict', 'Lock retirement destination is occupied.');
|
|
470
|
+
// Release the active pathname in one step, retaining owner evidence until then.
|
|
471
|
+
fs.renameSync(dir, destination);
|
|
472
|
+
for (const file of owned)
|
|
473
|
+
fs.unlinkSync(path.join(destination, path.basename(file)));
|
|
474
|
+
fs.rmdirSync(destination);
|
|
475
|
+
}
|
|
476
|
+
/** The shared source-publication coordination namespace, independent of every configured spec store (A-SPEC-634: exported so recovery can release it without importing the renumber module). */
|
|
477
|
+
exports.SOURCE_PUBLICATION_LOCATOR = '.ax/state/entity-renumber-source-publication';
|
|
478
|
+
/** Age after which a `.staging-*` entry beside the locks is treated as abandoned by a publisher that died before its rename (A-SPEC-633). */
|
|
479
|
+
exports.LOCK_STAGING_STALE_MS = 10 * 60_000;
|
|
480
|
+
/** Never steals a live or uncertain hold by age. Crash-owner recovery is a separate act. */
|
|
481
|
+
function acquireEntityStoreLock(root, locator) {
|
|
482
|
+
const locks = entityDirectory(root, path.join('.ax', 'state', 'entity-locks'), true);
|
|
483
|
+
const dir = path.join(locks, (0, node_crypto_1.createHash)('sha256').update(locator).digest('hex'));
|
|
484
|
+
// @implements A-SPEC-633 — the lock APPEARS atomically. Measured 2026-09-13 with the previous
|
|
485
|
+
// mkdir-then-write order: a reader looping entityLockOwner beside a looping writer saw the
|
|
486
|
+
// directory before its owner record 17,518 times in four seconds. So the owner record is written
|
|
487
|
+
// and fsynced inside a private staging directory, and the staging directory is renamed into the
|
|
488
|
+
// lock path: a contender observes no lock or a complete one, and loses the rename with
|
|
489
|
+
// EEXIST/ENOTEMPTY (POSIX) or EPERM/EEXIST (Windows) — all "busy", never "unknown owner".
|
|
490
|
+
sweepAbandonedStaging(locks);
|
|
491
|
+
// An existing directory is busy whatever it holds: rename(2) would silently REPLACE an empty
|
|
492
|
+
// one, and an empty lock directory is exactly the legacy/foreign state that must be preserved
|
|
493
|
+
// for explicit recovery (a live winner between this check and the rename loses it with
|
|
494
|
+
// ENOTEMPTY/EEXIST below).
|
|
495
|
+
if (entityStat(dir))
|
|
496
|
+
throw new EntityStoreError('store-busy', 'Entity store is owned by another or interrupted operation; inspect before recovery.');
|
|
497
|
+
const staging = path.join(locks, '.staging-' + (0, node_crypto_1.randomUUID)()), token = (0, node_crypto_1.randomUUID)();
|
|
498
|
+
fs.mkdirSync(staging);
|
|
499
|
+
const record = JSON.stringify({ token, pid: process.pid, storeLocator: locator });
|
|
500
|
+
try {
|
|
501
|
+
// No fsync: the guarantee is atomic VISIBILITY to concurrent processes (the rename), not
|
|
502
|
+
// durability across power loss — the previous protocol never synced either, and measured
|
|
503
|
+
// 2026-09-13 an fsync per acquisition cut throughput from thousands to ~76 per second, on a
|
|
504
|
+
// lock every adopted-store read takes.
|
|
505
|
+
fs.writeFileSync(path.join(staging, 'owner.json'), record, { flag: 'wx', mode: 0o600 });
|
|
506
|
+
fs.renameSync(staging, dir);
|
|
507
|
+
}
|
|
508
|
+
catch (e) {
|
|
509
|
+
try {
|
|
510
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
511
|
+
}
|
|
512
|
+
catch { /* best effort */ }
|
|
513
|
+
const code = e.code;
|
|
514
|
+
if (code === 'EEXIST' || code === 'ENOTEMPTY' || code === 'EPERM')
|
|
515
|
+
throw new EntityStoreError('store-busy', 'Entity store is owned by another or interrupted operation; inspect before recovery.');
|
|
516
|
+
throw e;
|
|
517
|
+
}
|
|
518
|
+
const ownership = fs.lstatSync(dir);
|
|
519
|
+
const release = () => {
|
|
520
|
+
const current = entityStat(dir);
|
|
521
|
+
if (current?.dev === ownership.dev && current?.ino === ownership.ino) {
|
|
522
|
+
const ownerFile = path.join(dir, 'owner.json');
|
|
523
|
+
const owner = readEntityBytes(ownerFile);
|
|
524
|
+
if (owner && JSON.parse(owner.toString('utf8')).token === token) {
|
|
525
|
+
retireOwnedLock(root, dir, ownership, new Set([ownerFile]));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
return release;
|
|
530
|
+
}
|
|
531
|
+
/** Remove `.staging-*` entries older than the bound: a publisher that died before its rename. Younger entries may belong to a live acquirer; hashed lock directories are never touched. */
|
|
532
|
+
function sweepAbandonedStaging(locks) {
|
|
533
|
+
let names;
|
|
534
|
+
try {
|
|
535
|
+
names = fs.readdirSync(locks);
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
for (const name of names) {
|
|
541
|
+
if (!name.startsWith('.staging-'))
|
|
542
|
+
continue;
|
|
543
|
+
const entry = path.join(locks, name);
|
|
544
|
+
try {
|
|
545
|
+
if (Date.now() - fs.lstatSync(entry).mtimeMs > exports.LOCK_STAGING_STALE_MS)
|
|
546
|
+
fs.rmSync(entry, { recursive: true, force: true });
|
|
547
|
+
}
|
|
548
|
+
catch { /* raced with its owner or already gone */ }
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
function withEntityStoreLock(root, locator, act) {
|
|
552
|
+
const release = acquireEntityStoreLock(root, locator);
|
|
553
|
+
try {
|
|
554
|
+
return act();
|
|
555
|
+
}
|
|
556
|
+
finally {
|
|
557
|
+
release();
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/** Keep ownership through asynchronous repository lookups and final publication. */
|
|
561
|
+
async function withEntityStoreLockAsync(root, locator, act) {
|
|
562
|
+
const release = acquireEntityStoreLock(root, locator);
|
|
563
|
+
try {
|
|
564
|
+
const result = act();
|
|
565
|
+
// Synchronous activated publication is already complete. Do not retain its
|
|
566
|
+
// lock for an artificial microtask and turn a stale-version error into busy.
|
|
567
|
+
return result instanceof Promise ? await result : result;
|
|
568
|
+
}
|
|
569
|
+
finally {
|
|
570
|
+
release();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* @implements A-SPEC-638 — whether the current generation of a store lock is held by a live process.
|
|
575
|
+
* Follows the owner/successor chain the recovery lock follows; a missing, malformed or released
|
|
576
|
+
* chain end, or a holder that no longer answers a signal probe, is "not alive".
|
|
577
|
+
*/
|
|
578
|
+
function entityLockHolderAlive(root, locator) {
|
|
579
|
+
const dir = entityDirectory(root, path.join('.ax/state/entity-locks', (0, node_crypto_1.createHash)('sha256').update(locator).digest('hex')));
|
|
580
|
+
if (!entityStat(dir))
|
|
581
|
+
return false;
|
|
582
|
+
const claims = path.join(root, '.ax/state/entity-recovery-claims', (0, node_crypto_1.createHash)('sha256').update(locator).digest('hex'));
|
|
583
|
+
let file = path.join(dir, 'owner.json');
|
|
584
|
+
for (let depth = 0; depth < 128; depth++) {
|
|
585
|
+
let owner;
|
|
586
|
+
try {
|
|
587
|
+
owner = JSON.parse(readEntityBytes(file)?.toString('utf8') ?? 'null');
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
if (!owner || typeof owner.token !== 'string' || !Number.isSafeInteger(owner.pid) || owner.pid <= 0)
|
|
593
|
+
return false;
|
|
594
|
+
const successor = [path.join(dir, 'successor-' + owner.token + '.json'), path.join(claims, 'successor-' + owner.token + '.json')].find(candidate => entityStat(candidate));
|
|
595
|
+
if (successor) {
|
|
596
|
+
file = successor;
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (owner.released)
|
|
600
|
+
return false;
|
|
601
|
+
// A generation this very process holds is not a foreign publication in progress: whoever wrote
|
|
602
|
+
// the pending record under an earlier generation is gone, or we could not hold the lock now.
|
|
603
|
+
if (owner.pid === process.pid)
|
|
604
|
+
return false;
|
|
605
|
+
try {
|
|
606
|
+
process.kill(owner.pid, 0);
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
catch (e) {
|
|
610
|
+
return e.code !== 'ESRCH';
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
/** Claim a dead owner's immutable generation without deleting or replacing its lock. */
|
|
616
|
+
function withEntityRecoveryLock(root, locator, act) {
|
|
617
|
+
const relative = path.join('.ax/state/entity-locks', (0, node_crypto_1.createHash)('sha256').update(locator).digest('hex'));
|
|
618
|
+
const dir = entityDirectory(root, relative);
|
|
619
|
+
if (!entityStat(dir))
|
|
620
|
+
return withEntityStoreLock(root, locator, act);
|
|
621
|
+
const directory = fs.lstatSync(dir), records = [], tokens = new Set();
|
|
622
|
+
let file = path.join(dir, 'owner.json');
|
|
623
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
624
|
+
for (let depth = 0; depth < 128; depth++) {
|
|
625
|
+
const bytes = readEntityBytes(file);
|
|
626
|
+
let owner;
|
|
627
|
+
try {
|
|
628
|
+
owner = JSON.parse(bytes?.toString('utf8') ?? 'null');
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
throw new EntityStoreError('unknown-owner', 'Malformed lock ownership must be preserved.');
|
|
632
|
+
}
|
|
633
|
+
if (!owner || !uuid.test(owner.token) || !Number.isSafeInteger(owner.pid) || owner.pid <= 0 || owner.storeLocator !== locator || Object.keys(owner).some(k => !['token', 'pid', 'storeLocator', 'released'].includes(k)) || (owner.released !== undefined && owner.released !== true) || (records.length === 0 && owner.released) || tokens.has(owner.token))
|
|
634
|
+
throw new EntityStoreError('unknown-owner', 'Invalid or cyclic lock ownership must be preserved.');
|
|
635
|
+
tokens.add(owner.token);
|
|
636
|
+
records.push(file);
|
|
637
|
+
if (!owner.released) {
|
|
638
|
+
try {
|
|
639
|
+
process.kill(owner.pid, 0);
|
|
640
|
+
throw new EntityStoreError('store-busy', 'The lock owner is still running; elapsed time does not permit recovery.');
|
|
641
|
+
}
|
|
642
|
+
catch (e) {
|
|
643
|
+
if (e.code !== 'ESRCH')
|
|
644
|
+
throw e instanceof EntityStoreError ? e : new EntityStoreError('unknown-owner', 'Cannot establish that the lock owner stopped.');
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const legacySuccessor = path.join(dir, 'successor-' + owner.token + '.json');
|
|
648
|
+
const claims = entityDirectory(root, path.join('.ax/state/entity-recovery-claims', (0, node_crypto_1.createHash)('sha256').update(locator).digest('hex')), true);
|
|
649
|
+
const externalSuccessor = path.join(claims, 'successor-' + owner.token + '.json');
|
|
650
|
+
if (entityStat(legacySuccessor) && entityStat(externalSuccessor))
|
|
651
|
+
throw new EntityStoreError('unknown-owner', 'Conflicting ownership generations must be preserved.');
|
|
652
|
+
const successor = entityStat(legacySuccessor) ? legacySuccessor : externalSuccessor;
|
|
653
|
+
if (entityStat(successor)) {
|
|
654
|
+
file = successor;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
const claim = { token: (0, node_crypto_1.randomUUID)(), pid: process.pid, storeLocator: locator };
|
|
658
|
+
try {
|
|
659
|
+
publishEntityRecord(root, path.relative(root, successor), JSON.stringify(claim));
|
|
660
|
+
}
|
|
661
|
+
catch (e) {
|
|
662
|
+
if (e.code === 'EEXIST')
|
|
663
|
+
throw new EntityStoreError('store-busy', 'Another recovery owns this generation.');
|
|
664
|
+
throw e;
|
|
665
|
+
}
|
|
666
|
+
records.push(successor);
|
|
667
|
+
try {
|
|
668
|
+
const current = entityStat(dir);
|
|
669
|
+
if (current?.dev !== directory.dev || current?.ino !== directory.ino)
|
|
670
|
+
throw new EntityStoreError('unknown-owner', 'Lock directory changed during recovery claim.');
|
|
671
|
+
// Only known generation files and their same-inode publication hardlinks belong to us.
|
|
672
|
+
// External generation claims survive retirement so delayed contenders cannot
|
|
673
|
+
// claim an old token against a newer directory at the reused active path.
|
|
674
|
+
const localRecords = records.filter(r => path.dirname(r) === dir);
|
|
675
|
+
const owned = new Set(localRecords);
|
|
676
|
+
const inodes = localRecords.map(r => fs.lstatSync(r));
|
|
677
|
+
for (const name of fs.readdirSync(dir)) {
|
|
678
|
+
const candidate = path.join(dir, name);
|
|
679
|
+
if (owned.has(candidate))
|
|
680
|
+
continue;
|
|
681
|
+
const s = fs.lstatSync(candidate);
|
|
682
|
+
if (/^\.entity-[0-9a-f-]+\.tmp$/.test(name) && s.isFile() && !s.isSymbolicLink() && inodes.some(i => i.dev === s.dev && i.ino === s.ino))
|
|
683
|
+
owned.add(candidate);
|
|
684
|
+
else
|
|
685
|
+
throw new EntityStoreError('cleanup-conflict', 'Unknown lock contents are preserved for inspection.');
|
|
686
|
+
}
|
|
687
|
+
const result = act();
|
|
688
|
+
retireOwnedLock(root, dir, directory, owned);
|
|
689
|
+
return result;
|
|
690
|
+
}
|
|
691
|
+
catch (e) {
|
|
692
|
+
// A failed live recovery must not strand its PID as an active owner forever.
|
|
693
|
+
// Mark only our generation released; another claimant appends a new generation.
|
|
694
|
+
const bytes = readEntityBytes(successor);
|
|
695
|
+
if (bytes?.toString('utf8') === JSON.stringify(claim))
|
|
696
|
+
applyEntityByteChange(root, { locator: path.relative(root, successor), before: bytes.toString('base64'), after: Buffer.from(JSON.stringify({ ...claim, released: true })).toString('base64') });
|
|
697
|
+
throw e;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
throw new EntityStoreError('unknown-owner', 'Recovery ownership chain exceeds the supported inspection bound.');
|
|
701
|
+
}
|