wendkeep 0.77.0 → 0.79.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 +64 -0
- package/README.en.md +58 -3
- package/README.md +58 -3
- package/docs/en/commands/changes-and-verification.md +74 -2
- package/docs/en/commands/operating-profiles.md +49 -5
- package/docs/en/commands/verify.md +67 -5
- package/docs/en/commands/worktrees.md +39 -4
- package/docs/pt-BR/commands/changes-and-verification.md +73 -2
- package/docs/pt-BR/commands/operating-profiles.md +51 -5
- package/docs/pt-BR/commands/verify.md +67 -6
- package/docs/pt-BR/commands/worktrees.md +38 -3
- package/hooks/active-context-store.mjs +530 -2
- package/hooks/change-core.mjs +203 -123
- package/hooks/harness-doctor.mjs +51 -1
- package/hooks/obsidian-common.mjs +175 -9
- package/hooks/spec-core.mjs +118 -36
- package/package.json +2 -2
- package/packages/harness/src/sensors-core.mjs +57 -3
- package/packages/vault/src/evidence-envelope.mjs +73 -0
- package/packages/vault/src/index.mjs +1 -0
- package/packages/vault/src/memory-handoff.mjs +46 -5
- package/packages/vault/src/vault-path-safety.mjs +11 -0
- package/schema/wendkeep.evidence-envelope-v2.schema.json +92 -0
- package/schema/wendkeep.provenance-receipt-v2.schema.json +66 -0
- package/src/archive-operation-lock.mjs +235 -0
- package/src/change.mjs +1832 -48
- package/src/delivery.mjs +724 -67
- package/src/evidence-envelope.mjs +288 -0
- package/src/memory.mjs +2 -1
- package/src/provenance-gate.mjs +575 -0
- package/src/provenance-sources.mjs +547 -0
- package/src/receipt-ledger.mjs +841 -0
- package/src/release-provenance.mjs +48 -0
- package/src/skills-seed.mjs +11 -5
- package/src/verify.mjs +85 -22
- package/src/worktree-cleanup.mjs +1733 -118
- package/src/worktree.mjs +94 -5
|
@@ -5,6 +5,12 @@ import { basename, join, relative } from 'node:path';
|
|
|
5
5
|
|
|
6
6
|
import { sanitizeMemoryText } from './memory-schema.mjs';
|
|
7
7
|
import { scopeForMemoryKey } from './memory-scope.mjs';
|
|
8
|
+
import {
|
|
9
|
+
evaluateEvidenceBinding,
|
|
10
|
+
evidenceCheckoutBinding,
|
|
11
|
+
evidenceCheckoutBindingMatches,
|
|
12
|
+
evidenceSensors,
|
|
13
|
+
} from './evidence-envelope.mjs';
|
|
8
14
|
|
|
9
15
|
const SHARED_HANDOFF_FIELDS = Object.freeze([
|
|
10
16
|
['objective', 'objective.current'],
|
|
@@ -177,9 +183,33 @@ export function collectLifecycleEvidence(vaultBase, { changeSlug = '', summary =
|
|
|
177
183
|
};
|
|
178
184
|
}
|
|
179
185
|
const sensorPath = join(archivedDir, 'evidencia.json');
|
|
180
|
-
const
|
|
181
|
-
|
|
186
|
+
const sensorEnvelope = readJson(sensorPath);
|
|
187
|
+
const sensors = evidenceSensors(sensorEnvelope);
|
|
188
|
+
if (sensors.length && sensors.every((item) => item?.status === 'green')) {
|
|
182
189
|
evidence.sensors = [...new Set(sensors.map((item) => String(item.id || '')).filter(Boolean))].sort();
|
|
190
|
+
evidence.sensors_path = vaultRel(vaultBase, sensorPath);
|
|
191
|
+
const assessed = evaluateEvidenceBinding(sensorEnvelope, { change_slug: slug });
|
|
192
|
+
evidence.sensors_binding = assessed.state;
|
|
193
|
+
if (sensorEnvelope?.schema_version === 2 && assessed.state === 'bound') {
|
|
194
|
+
const checkoutBinding = evidenceCheckoutBinding(sensorEnvelope);
|
|
195
|
+
const verification = readJson(join(archivedDir, 'verificacao.json'));
|
|
196
|
+
const crossBound = verification?.evidenceEnvelopeId === sensorEnvelope.envelope_id
|
|
197
|
+
&& verdict?.evidenceEnvelopeId === sensorEnvelope.envelope_id
|
|
198
|
+
&& evidenceCheckoutBindingMatches(verification?.evidenceBinding, checkoutBinding)
|
|
199
|
+
&& evidenceCheckoutBindingMatches(verdict?.evidenceBinding, checkoutBinding);
|
|
200
|
+
if (!crossBound) {
|
|
201
|
+
evidence.sensors_binding = 'stale';
|
|
202
|
+
evidence.sensors_binding_reasons = ['archived verification/verdict binding mismatch'];
|
|
203
|
+
}
|
|
204
|
+
} else if (assessed.reasons.length) {
|
|
205
|
+
evidence.sensors_binding_reasons = assessed.reasons;
|
|
206
|
+
}
|
|
207
|
+
if (sensorEnvelope?.schema_version === 2) {
|
|
208
|
+
evidence.sensors_envelope_id = sensorEnvelope.envelope_id;
|
|
209
|
+
evidence.sensors_tasks_hash = sensorEnvelope.tasks_sha256;
|
|
210
|
+
evidence.sensors_repository_id = sensorEnvelope.repository_id;
|
|
211
|
+
evidence.sensors_worktree_id = sensorEnvelope.worktree_id;
|
|
212
|
+
}
|
|
183
213
|
}
|
|
184
214
|
}
|
|
185
215
|
}
|
|
@@ -279,12 +309,23 @@ export function buildSessionMemoryEvents({
|
|
|
279
309
|
if (Array.isArray(evidence.sensors) && evidence.sensors.length) {
|
|
280
310
|
events.push(makeEvent(context, {
|
|
281
311
|
memoryKey: 'quality.latest-sensors',
|
|
282
|
-
value:
|
|
283
|
-
|
|
284
|
-
|
|
312
|
+
value: {
|
|
313
|
+
ids: [...new Set(evidence.sensors.map(String))].sort(),
|
|
314
|
+
evidence_state: evidence.sensors_binding || 'legacy-unbound',
|
|
315
|
+
...(evidence.sensors_envelope_id ? { envelope_id: evidence.sensors_envelope_id } : {}),
|
|
316
|
+
...(evidence.sensors_binding && !['bound', 'legacy-unbound'].includes(evidence.sensors_binding)
|
|
317
|
+
? { recovery: 'reabra a change e rode wendkeep verify --deep + wk-verify' }
|
|
318
|
+
: {}),
|
|
319
|
+
},
|
|
320
|
+
authority: evidence.sensors_binding === 'bound' ? 'verified' : 'reported',
|
|
321
|
+
evidence: [evidence.sensors_path].filter(Boolean),
|
|
285
322
|
scopeContext: {
|
|
286
323
|
changeSlug: evidence.change?.slug || normalizedShared?.change_slug,
|
|
287
324
|
tasksHash: evidence.sensors_tasks_hash || normalizedShared?.tasks_hash,
|
|
325
|
+
repositoryId: evidence.sensors_repository_id,
|
|
326
|
+
worktreeId: evidence.sensors_worktree_id,
|
|
327
|
+
evidenceEnvelopeId: evidence.sensors_envelope_id,
|
|
328
|
+
evidenceState: evidence.sensors_binding,
|
|
288
329
|
},
|
|
289
330
|
}));
|
|
290
331
|
}
|
|
@@ -225,10 +225,20 @@ export function writeVaultFileSync(vaultBase, targetPath, content, encoding = 'u
|
|
|
225
225
|
export function writeVaultFileAtomic(vaultBase, targetPath, content, encoding = 'utf8', {
|
|
226
226
|
label = 'arquivo atômico do Vault',
|
|
227
227
|
code = 'VAULT_PATH_UNSAFE',
|
|
228
|
+
scopeRoot = '',
|
|
229
|
+
beforeRename,
|
|
228
230
|
} = {}) {
|
|
229
231
|
const checked = assertVaultPathSafe(vaultBase, targetPath, {
|
|
230
232
|
expectedType: 'file', label, code,
|
|
231
233
|
});
|
|
234
|
+
if (scopeRoot) {
|
|
235
|
+
const scope = assertVaultPathSafe(vaultBase, scopeRoot, {
|
|
236
|
+
allowMissing: false, expectedType: 'directory', label: `escopo de ${label}`, code,
|
|
237
|
+
});
|
|
238
|
+
if (!containedBy(scope.target, checked.target)) {
|
|
239
|
+
throw unsafe(`${label} escapa do escopo autorizado: ${checked.target}`, code);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
232
242
|
assertVaultPathSafe(vaultBase, dirname(checked.target), {
|
|
233
243
|
allowMissing: false, expectedType: 'directory', label: `ancestral de ${label}`, code,
|
|
234
244
|
});
|
|
@@ -245,6 +255,7 @@ export function writeVaultFileAtomic(vaultBase, targetPath, content, encoding =
|
|
|
245
255
|
allowMissing: false, expectedType: 'file', label: `temporário de ${label}`, code,
|
|
246
256
|
});
|
|
247
257
|
assertVaultPathSafe(vaultBase, checked.target, { expectedType: 'file', label, code });
|
|
258
|
+
if (typeof beforeRename === 'function') beforeRename({ temporary: tmp, target: checked.target });
|
|
248
259
|
renameSync(tmp, checked.target);
|
|
249
260
|
tmpCreated = false;
|
|
250
261
|
assertVaultPathSafe(vaultBase, checked.target, {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/rogersialves/wendkeep/schema/wendkeep.evidence-envelope-v2.schema.json",
|
|
4
|
+
"title": "WendKeep Evidence Envelope v2",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": [
|
|
8
|
+
"schema_version",
|
|
9
|
+
"project_id",
|
|
10
|
+
"repository_id",
|
|
11
|
+
"worktree_id",
|
|
12
|
+
"work_session_id",
|
|
13
|
+
"change_slug",
|
|
14
|
+
"branch",
|
|
15
|
+
"base_sha",
|
|
16
|
+
"head_sha",
|
|
17
|
+
"index_tree_sha",
|
|
18
|
+
"worktree_digest",
|
|
19
|
+
"dirty",
|
|
20
|
+
"tasks_sha256",
|
|
21
|
+
"effective_spec_sha256",
|
|
22
|
+
"sensor_config_sha256",
|
|
23
|
+
"wendkeep_version",
|
|
24
|
+
"platform",
|
|
25
|
+
"started_at",
|
|
26
|
+
"finished_at",
|
|
27
|
+
"sensors",
|
|
28
|
+
"envelope_id"
|
|
29
|
+
],
|
|
30
|
+
"properties": {
|
|
31
|
+
"schema_version": { "const": 2 },
|
|
32
|
+
"project_id": { "type": "string", "minLength": 1 },
|
|
33
|
+
"repository_id": { "type": "string", "minLength": 1 },
|
|
34
|
+
"worktree_id": { "type": "string", "minLength": 1 },
|
|
35
|
+
"work_session_id": { "type": "string", "minLength": 1 },
|
|
36
|
+
"change_slug": { "type": "string", "minLength": 1 },
|
|
37
|
+
"branch": { "type": "string", "minLength": 1 },
|
|
38
|
+
"base_sha": { "$ref": "#/$defs/gitObject" },
|
|
39
|
+
"head_sha": { "$ref": "#/$defs/gitObject" },
|
|
40
|
+
"index_tree_sha": { "$ref": "#/$defs/gitObject" },
|
|
41
|
+
"worktree_digest": { "$ref": "#/$defs/sha256" },
|
|
42
|
+
"dirty": { "type": "boolean" },
|
|
43
|
+
"tasks_sha256": { "$ref": "#/$defs/sha256" },
|
|
44
|
+
"effective_spec_sha256": { "$ref": "#/$defs/sha256" },
|
|
45
|
+
"sensor_config_sha256": { "$ref": "#/$defs/sha256" },
|
|
46
|
+
"wendkeep_version": { "type": "string", "minLength": 1 },
|
|
47
|
+
"platform": { "type": "string", "minLength": 1 },
|
|
48
|
+
"started_at": { "type": "string", "format": "date-time" },
|
|
49
|
+
"finished_at": { "type": "string", "format": "date-time" },
|
|
50
|
+
"envelope_id": { "$ref": "#/$defs/sha256" },
|
|
51
|
+
"sensors": {
|
|
52
|
+
"type": "array",
|
|
53
|
+
"items": { "$ref": "#/$defs/sensor" }
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"$defs": {
|
|
57
|
+
"gitObject": { "type": "string", "pattern": "^[a-f0-9]{40,64}$" },
|
|
58
|
+
"sha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" },
|
|
59
|
+
"sensor": {
|
|
60
|
+
"type": "object",
|
|
61
|
+
"required": [
|
|
62
|
+
"id",
|
|
63
|
+
"status",
|
|
64
|
+
"severity",
|
|
65
|
+
"command",
|
|
66
|
+
"command_sha256",
|
|
67
|
+
"started_at",
|
|
68
|
+
"finished_at",
|
|
69
|
+
"duration_ms",
|
|
70
|
+
"exit_code",
|
|
71
|
+
"output_sha256",
|
|
72
|
+
"output_tail"
|
|
73
|
+
],
|
|
74
|
+
"properties": {
|
|
75
|
+
"id": { "type": "string", "minLength": 1 },
|
|
76
|
+
"status": { "enum": ["green", "red"] },
|
|
77
|
+
"severity": { "enum": ["critical", "warning"] },
|
|
78
|
+
"command": { "type": "string" },
|
|
79
|
+
"command_sha256": { "$ref": "#/$defs/sha256" },
|
|
80
|
+
"started_at": { "type": "string", "format": "date-time" },
|
|
81
|
+
"finished_at": { "type": "string", "format": "date-time" },
|
|
82
|
+
"duration_ms": { "type": "number", "minimum": 0 },
|
|
83
|
+
"exit_code": { "type": ["integer", "null"] },
|
|
84
|
+
"output_sha256": { "$ref": "#/$defs/sha256" },
|
|
85
|
+
"output_tail": { "type": "string", "maxLength": 2000 },
|
|
86
|
+
"note": { "type": "string", "maxLength": 2000 },
|
|
87
|
+
"survivors": { "type": "array" },
|
|
88
|
+
"ts": { "type": "string", "format": "date-time" }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/rogersialves/wendkeep/blob/main/schema/wendkeep.provenance-receipt-v2.schema.json",
|
|
4
|
+
"title": "WendKeep Provenance Receipt v2",
|
|
5
|
+
"description": "A canonical, hash-chained receipt for a provenance-gated operation.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": [
|
|
9
|
+
"schema_version",
|
|
10
|
+
"sequence",
|
|
11
|
+
"receipt_id",
|
|
12
|
+
"previous_hash",
|
|
13
|
+
"receipt_hash",
|
|
14
|
+
"kind",
|
|
15
|
+
"subject",
|
|
16
|
+
"claims",
|
|
17
|
+
"observations",
|
|
18
|
+
"recorded_at"
|
|
19
|
+
],
|
|
20
|
+
"properties": {
|
|
21
|
+
"schema_version": {
|
|
22
|
+
"const": 2
|
|
23
|
+
},
|
|
24
|
+
"sequence": {
|
|
25
|
+
"type": "integer",
|
|
26
|
+
"minimum": 1
|
|
27
|
+
},
|
|
28
|
+
"receipt_id": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"pattern": "^sha256:[a-f0-9]{64}$"
|
|
31
|
+
},
|
|
32
|
+
"previous_hash": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"pattern": "^sha256:[a-f0-9]{64}$",
|
|
35
|
+
"description": "Hash of the previous v2 receipt, or the canonical legacy/empty prefix for genesis."
|
|
36
|
+
},
|
|
37
|
+
"receipt_hash": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"pattern": "^sha256:[a-f0-9]{64}$",
|
|
40
|
+
"description": "SHA-256 of the canonical receipt without receipt_hash."
|
|
41
|
+
},
|
|
42
|
+
"kind": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"pattern": "^[a-z][a-z0-9._-]{1,63}$"
|
|
45
|
+
},
|
|
46
|
+
"subject": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"description": "Sanitized identity and current operation target.",
|
|
49
|
+
"additionalProperties": true
|
|
50
|
+
},
|
|
51
|
+
"claims": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"description": "Sanitized claims made by the operation.",
|
|
54
|
+
"additionalProperties": true
|
|
55
|
+
},
|
|
56
|
+
"observations": {
|
|
57
|
+
"type": "object",
|
|
58
|
+
"description": "Sanitized source observations keyed by adapter/kind.",
|
|
59
|
+
"additionalProperties": true
|
|
60
|
+
},
|
|
61
|
+
"recorded_at": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"format": "date-time"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync,
|
|
4
|
+
readFileSync, readdirSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync,
|
|
5
|
+
} from 'node:fs';
|
|
6
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
7
|
+
|
|
8
|
+
function archiveLockError(code, details = {}) {
|
|
9
|
+
const error = new Error(code);
|
|
10
|
+
error.code = code;
|
|
11
|
+
Object.assign(error, details);
|
|
12
|
+
return error;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function samePhysicalFile(left, right) {
|
|
16
|
+
if (!left?.isFile() || !right?.isFile() || left.ino !== right.ino) return false;
|
|
17
|
+
if (left.dev === right.dev) return true;
|
|
18
|
+
return process.platform === 'win32' && (left.dev === 0 || right.dev === 0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ownerProcessAlive(pid) {
|
|
22
|
+
try { process.kill(pid, 0); return true; }
|
|
23
|
+
catch (error) { return !['ESRCH', 'EINVAL'].includes(error?.code); }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function assertLockDirectory(target) {
|
|
27
|
+
const stat = lstatSync(target);
|
|
28
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
29
|
+
throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
|
|
30
|
+
}
|
|
31
|
+
return stat;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readOwnerMarker(markerPath) {
|
|
35
|
+
const stat = lstatSync(markerPath);
|
|
36
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {
|
|
37
|
+
throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
|
|
38
|
+
}
|
|
39
|
+
let record;
|
|
40
|
+
try { record = JSON.parse(readFileSync(markerPath, 'utf8')); }
|
|
41
|
+
catch { throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE'); }
|
|
42
|
+
if (record?.schema_version !== 1
|
|
43
|
+
|| typeof record.owner_token !== 'string' || !record.owner_token
|
|
44
|
+
|| !Number.isSafeInteger(record.owner_pid) || record.owner_pid <= 0
|
|
45
|
+
|| typeof record.acquired_at !== 'string' || !Number.isFinite(Date.parse(record.acquired_at))) {
|
|
46
|
+
throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
|
|
47
|
+
}
|
|
48
|
+
return { stat, record };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function inspectExistingLock(target) {
|
|
52
|
+
try { assertLockDirectory(target); }
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error?.code === 'ENOENT') return;
|
|
55
|
+
throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
|
|
56
|
+
}
|
|
57
|
+
const entries = readdirSync(target);
|
|
58
|
+
if (entries.length === 0) {
|
|
59
|
+
try { rmdirSync(target); } catch { /* re-observe on the next bounded attempt */ }
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (entries.length !== 1 || !/^owner\.[0-9a-f-]+\.json$/i.test(entries[0])) {
|
|
63
|
+
throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
|
|
64
|
+
}
|
|
65
|
+
const markerPath = join(target, entries[0]);
|
|
66
|
+
const observed = readOwnerMarker(markerPath);
|
|
67
|
+
if (basename(markerPath) !== `owner.${observed.record.owner_token}.json`) {
|
|
68
|
+
throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
|
|
69
|
+
}
|
|
70
|
+
if (ownerProcessAlive(observed.record.owner_pid)) {
|
|
71
|
+
throw archiveLockError('WENDKEEP_ARCHIVE_BUSY', { owner_state: 'live' });
|
|
72
|
+
}
|
|
73
|
+
const confirmed = readOwnerMarker(markerPath);
|
|
74
|
+
if (!samePhysicalFile(observed.stat, confirmed.stat)
|
|
75
|
+
|| confirmed.record.owner_token !== observed.record.owner_token
|
|
76
|
+
|| confirmed.record.owner_pid !== observed.record.owner_pid) {
|
|
77
|
+
throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
|
|
78
|
+
}
|
|
79
|
+
try { unlinkSync(markerPath); }
|
|
80
|
+
catch { throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE'); }
|
|
81
|
+
try { rmdirSync(target); } catch { /* replacement/nonempty directory remains authoritative */ }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function acquireArchiveOperationLock({
|
|
85
|
+
lockPath,
|
|
86
|
+
now = () => Date.now(),
|
|
87
|
+
maxAcquireAttempts = 3,
|
|
88
|
+
faultInjection = {},
|
|
89
|
+
}) {
|
|
90
|
+
if (!lockPath || !Number.isSafeInteger(maxAcquireAttempts) || maxAcquireAttempts < 1) {
|
|
91
|
+
throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_INVALID');
|
|
92
|
+
}
|
|
93
|
+
const runtime = resolve(dirname(lockPath));
|
|
94
|
+
const target = resolve(lockPath);
|
|
95
|
+
mkdirSync(runtime, { recursive: true });
|
|
96
|
+
const runtimeStat = lstatSync(runtime);
|
|
97
|
+
if (!runtimeStat.isDirectory() || runtimeStat.isSymbolicLink()
|
|
98
|
+
|| resolve(realpathSync(runtime)).toLowerCase() !== runtime.toLowerCase()) {
|
|
99
|
+
throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const token = randomUUID();
|
|
103
|
+
const markerName = `owner.${token}.json`;
|
|
104
|
+
const pending = `${target}.pending.${process.pid}.${token}`;
|
|
105
|
+
const pendingMarker = join(pending, markerName);
|
|
106
|
+
let descriptor;
|
|
107
|
+
let descriptorIdentity;
|
|
108
|
+
let pendingExists = false;
|
|
109
|
+
let published = false;
|
|
110
|
+
try {
|
|
111
|
+
mkdirSync(pending);
|
|
112
|
+
pendingExists = true;
|
|
113
|
+
descriptor = openSync(
|
|
114
|
+
pendingMarker,
|
|
115
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0),
|
|
116
|
+
0o600,
|
|
117
|
+
);
|
|
118
|
+
writeFileSync(descriptor, `${JSON.stringify({
|
|
119
|
+
schema_version: 1,
|
|
120
|
+
owner_token: token,
|
|
121
|
+
owner_pid: process.pid,
|
|
122
|
+
acquired_at: new Date(now()).toISOString(),
|
|
123
|
+
})}\n`, 'utf8');
|
|
124
|
+
fsyncSync(descriptor);
|
|
125
|
+
closeSync(descriptor);
|
|
126
|
+
descriptor = undefined;
|
|
127
|
+
|
|
128
|
+
for (let attempt = 1; attempt <= maxAcquireAttempts; attempt += 1) {
|
|
129
|
+
try {
|
|
130
|
+
if (typeof faultInjection?.beforePublishRename === 'function') {
|
|
131
|
+
faultInjection.beforePublishRename({ attempt, pending, lockPath: target });
|
|
132
|
+
}
|
|
133
|
+
// The pending directory is already nonempty and durable. Rename publishes metadata and
|
|
134
|
+
// exclusivity together; the canonical namespace never observes a partial marker/hardlink.
|
|
135
|
+
renameSync(pending, target);
|
|
136
|
+
pendingExists = false;
|
|
137
|
+
published = true;
|
|
138
|
+
if (typeof faultInjection?.afterPublishRename === 'function') {
|
|
139
|
+
faultInjection.afterPublishRename({ attempt, lockPath: target });
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (published) throw error;
|
|
144
|
+
if (existsSync(target)) inspectExistingLock(target);
|
|
145
|
+
if (attempt === maxAcquireAttempts) {
|
|
146
|
+
throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const markerPath = join(target, markerName);
|
|
152
|
+
descriptor = openSync(
|
|
153
|
+
markerPath,
|
|
154
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW || 0),
|
|
155
|
+
);
|
|
156
|
+
const identity = readOwnerMarker(markerPath);
|
|
157
|
+
descriptorIdentity = fstatSync(descriptor);
|
|
158
|
+
if (!samePhysicalFile(descriptorIdentity, identity.stat)
|
|
159
|
+
|| identity.record.owner_token !== token) {
|
|
160
|
+
throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST');
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (descriptor !== undefined) {
|
|
164
|
+
try { closeSync(descriptor); } catch { /* original code remains authoritative */ }
|
|
165
|
+
descriptor = undefined;
|
|
166
|
+
}
|
|
167
|
+
if (pendingExists) {
|
|
168
|
+
try { unlinkSync(pendingMarker); } catch { /* marker may be absent */ }
|
|
169
|
+
try { rmdirSync(pending); } catch { /* private pending state is recoverable */ }
|
|
170
|
+
}
|
|
171
|
+
if (error?.code?.startsWith('WENDKEEP_ARCHIVE_')) throw error;
|
|
172
|
+
throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_UNAVAILABLE');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const markerPath = join(target, markerName);
|
|
176
|
+
let terminal = false;
|
|
177
|
+
let assertionCount = 0;
|
|
178
|
+
const closeHandle = () => {
|
|
179
|
+
if (descriptor === undefined) return;
|
|
180
|
+
try { closeSync(descriptor); } finally { descriptor = undefined; }
|
|
181
|
+
};
|
|
182
|
+
const assertOwned = () => {
|
|
183
|
+
if (terminal || descriptor === undefined) {
|
|
184
|
+
throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST');
|
|
185
|
+
}
|
|
186
|
+
try { assertLockDirectory(target); }
|
|
187
|
+
catch { throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST'); }
|
|
188
|
+
let identity;
|
|
189
|
+
try { identity = readOwnerMarker(markerPath); }
|
|
190
|
+
catch { throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST'); }
|
|
191
|
+
const currentDescriptorIdentity = fstatSync(descriptor);
|
|
192
|
+
if (!samePhysicalFile(currentDescriptorIdentity, identity.stat)
|
|
193
|
+
|| identity.record.owner_token !== token) {
|
|
194
|
+
throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST');
|
|
195
|
+
}
|
|
196
|
+
descriptorIdentity = currentDescriptorIdentity;
|
|
197
|
+
assertionCount += 1;
|
|
198
|
+
if (typeof faultInjection?.afterAssertOwned === 'function') {
|
|
199
|
+
faultInjection.afterAssertOwned({ count: assertionCount, lockPath: target, markerPath });
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return Object.freeze({
|
|
205
|
+
token,
|
|
206
|
+
assertOwned,
|
|
207
|
+
release() {
|
|
208
|
+
if (terminal) return;
|
|
209
|
+
try {
|
|
210
|
+
assertOwned();
|
|
211
|
+
if (typeof faultInjection?.beforeReleaseCommit === 'function') {
|
|
212
|
+
faultInjection.beforeReleaseCommit({ lockPath: target, markerPath });
|
|
213
|
+
}
|
|
214
|
+
closeHandle();
|
|
215
|
+
const finalIdentity = readOwnerMarker(markerPath);
|
|
216
|
+
if (!samePhysicalFile(descriptorIdentity, finalIdentity.stat)
|
|
217
|
+
|| finalIdentity.record.owner_token !== token) {
|
|
218
|
+
throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST');
|
|
219
|
+
}
|
|
220
|
+
unlinkSync(markerPath);
|
|
221
|
+
rmdirSync(target);
|
|
222
|
+
} catch {
|
|
223
|
+
terminal = true;
|
|
224
|
+
closeHandle();
|
|
225
|
+
throw archiveLockError('WENDKEEP_ARCHIVE_LOCK_OWNERSHIP_LOST');
|
|
226
|
+
}
|
|
227
|
+
terminal = true;
|
|
228
|
+
closeHandle();
|
|
229
|
+
},
|
|
230
|
+
get active() {
|
|
231
|
+
if (terminal) return false;
|
|
232
|
+
try { return assertOwned(); } catch { return false; }
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
}
|