@particle-academy/prism-harness 0.7.0 → 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 +94 -15
- package/dist/address-index.d.ts +35 -0
- package/dist/address-index.js +170 -0
- package/dist/address.d.ts +25 -0
- package/dist/address.js +83 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +11 -0
- package/dist/harness.d.ts +41 -2
- package/dist/harness.js +99 -4
- package/dist/index.d.ts +11 -5
- package/dist/index.js +4 -1
- package/dist/modes.d.ts +10 -1
- package/dist/modes.js +53 -1
- package/dist/registration.d.ts +6 -0
- package/dist/registration.js +15 -0
- package/dist/runtime.d.ts +21 -15
- package/dist/runtime.js +95 -8
- package/dist/session.d.ts +12 -0
- package/dist/session.js +66 -11
- package/dist/stores/file-lock-identity.d.ts +20 -0
- package/dist/stores/file-lock-identity.js +74 -0
- package/dist/stores/file-lock-windows-identity.d.ts +2 -0
- package/dist/stores/file-lock-windows-identity.js +48 -0
- package/dist/stores/file-owner-lock.d.ts +28 -0
- package/dist/stores/file-owner-lock.js +280 -0
- package/dist/stores/file-session-store.d.ts +13 -6
- package/dist/stores/file-session-store.js +16 -183
- package/dist/stores/memory-session-store.d.ts +2 -0
- package/dist/stores/memory-session-store.js +2 -0
- package/dist/stores/session-store.d.ts +4 -0
- package/dist/subagent-runner.js +10 -3
- package/dist/subagents.d.ts +2 -1
- package/dist/subagents.js +10 -3
- package/dist/task-tools.js +6 -1
- package/dist/thread.d.ts +2 -0
- package/dist/thread.js +6 -0
- package/dist/tools.d.ts +28 -5
- package/dist/tools.js +159 -21
- package/package.json +1 -1
package/dist/session.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ParticipantRef, SessionAddress } from './address.js';
|
|
2
|
+
import { indexedStore, storeLockRank, StoreAddressIndex } from './address-index.js';
|
|
2
3
|
import { isJsonObject } from './json.js';
|
|
3
4
|
import { StoreTaskSource } from './store-task-source.js';
|
|
4
5
|
import { Thread } from './thread.js';
|
|
@@ -18,18 +19,39 @@ import { Thread } from './thread.js';
|
|
|
18
19
|
* - the thread and stored capabilities are DURABLE.
|
|
19
20
|
*/
|
|
20
21
|
export class Session {
|
|
22
|
+
address;
|
|
23
|
+
actor;
|
|
24
|
+
#addressIndex;
|
|
25
|
+
#rawDurable;
|
|
26
|
+
#rawEphemeral;
|
|
27
|
+
#lockStore;
|
|
21
28
|
scope;
|
|
22
29
|
participant;
|
|
23
30
|
#ephemeral;
|
|
24
31
|
#durable;
|
|
25
32
|
#ttlSeconds;
|
|
26
33
|
#cachedState = null;
|
|
34
|
+
#resolutionOnly = false;
|
|
35
|
+
#resolvedMode = null;
|
|
27
36
|
constructor(options) {
|
|
28
|
-
|
|
37
|
+
if (options.participant instanceof ParticipantRef && options.actor === undefined)
|
|
38
|
+
throw new Error('Typed participants require an explicit authorization actor.');
|
|
39
|
+
const strictIdentity = options.agent != null || options.participant instanceof ParticipantRef;
|
|
40
|
+
this.participant = options.participant instanceof ParticipantRef ? options.participant : (strictIdentity ? Object.freeze({ ...options.participant }) : options.participant);
|
|
41
|
+
this.address = new SessionAddress(this.participant, options.scope, options.agent ?? null);
|
|
42
|
+
this.actor = options.actor ?? this.participant;
|
|
43
|
+
const index = options.addressIndex ?? (this.address.agent !== null || options.participant instanceof ParticipantRef
|
|
44
|
+
? new StoreAddressIndex(options.durable, options.maxAddressesPerParticipant ?? null) : undefined);
|
|
45
|
+
this.#addressIndex = index;
|
|
46
|
+
this.#rawDurable = options.durable;
|
|
47
|
+
this.#rawEphemeral = options.ephemeral;
|
|
29
48
|
this.scope = options.scope;
|
|
30
|
-
this.#ephemeral = options.ephemeral;
|
|
31
|
-
this.#durable = options.durable;
|
|
49
|
+
this.#ephemeral = index ? indexedStore(options.ephemeral, index, this.address, 'ephemeral') : options.ephemeral;
|
|
50
|
+
this.#durable = index ? indexedStore(options.durable, index, this.address, 'durable') : options.durable;
|
|
51
|
+
this.#lockStore = index && storeLockRank(options.durable) > storeLockRank(options.ephemeral) ? this.#durable : this.#ephemeral;
|
|
32
52
|
this.#ttlSeconds = options.ttlSeconds ?? null;
|
|
53
|
+
for (const key of strictIdentity ? ['address', 'actor', 'participant', 'scope'] : ['address', 'actor'])
|
|
54
|
+
Object.defineProperty(this, key, { writable: false, configurable: false });
|
|
33
55
|
}
|
|
34
56
|
/**
|
|
35
57
|
* The address this session is resolved by.
|
|
@@ -45,11 +67,14 @@ export class Session {
|
|
|
45
67
|
* ONE store and resolve the same session.
|
|
46
68
|
*/
|
|
47
69
|
key() {
|
|
48
|
-
|
|
49
|
-
|
|
70
|
+
return this.address.agent === null && !(this.participant instanceof ParticipantRef)
|
|
71
|
+
? new SessionAddress(this.participant, this.scope).sessionKey() : this.address.sessionKey();
|
|
50
72
|
}
|
|
73
|
+
get agent() { return this.address.agent; }
|
|
51
74
|
// -- ephemeral half --------------------------------------------------------
|
|
52
75
|
async mode() {
|
|
76
|
+
if (this.#resolvedMode !== null)
|
|
77
|
+
return this.#resolvedMode;
|
|
53
78
|
return this.#readString('mode');
|
|
54
79
|
}
|
|
55
80
|
async usingMode(mode) {
|
|
@@ -69,10 +94,11 @@ export class Session {
|
|
|
69
94
|
}
|
|
70
95
|
async state() {
|
|
71
96
|
this.#cachedState ??= (await this.#ephemeral.get(this.#ephemeralKey())) ?? {};
|
|
72
|
-
return this.#cachedState;
|
|
97
|
+
return this.#resolutionOnly ? structuredClone(this.#cachedState) : this.#cachedState;
|
|
73
98
|
}
|
|
74
99
|
/** Drop the ephemeral half. THE CONVERSATION IS UNTOUCHED. */
|
|
75
100
|
async forget() {
|
|
101
|
+
this.#assertWritable();
|
|
76
102
|
await this.#ephemeral.forget(this.#ephemeralKey());
|
|
77
103
|
this.#cachedState = null;
|
|
78
104
|
return this;
|
|
@@ -80,7 +106,13 @@ export class Session {
|
|
|
80
106
|
// -- durable half ----------------------------------------------------------
|
|
81
107
|
/** The stored conversation this session is bound to. */
|
|
82
108
|
thread() {
|
|
83
|
-
|
|
109
|
+
this.#assertWritable();
|
|
110
|
+
const store = this.#addressIndex ? indexedStore(this.#rawDurable, this.#addressIndex, this.address, 'durable', 'thread') : this.#durable;
|
|
111
|
+
return new Thread(store, `${this.key()}:thread`);
|
|
112
|
+
}
|
|
113
|
+
async findThread() {
|
|
114
|
+
const thread = this.thread();
|
|
115
|
+
return await thread.exists() ? thread : null;
|
|
84
116
|
}
|
|
85
117
|
/**
|
|
86
118
|
* The agent task list this session is bound to.
|
|
@@ -93,13 +125,15 @@ export class Session {
|
|
|
93
125
|
* against a store as safe as one reached through here.
|
|
94
126
|
*/
|
|
95
127
|
tasks(options = {}) {
|
|
96
|
-
|
|
128
|
+
this.#assertWritable();
|
|
129
|
+
const store = this.#addressIndex ? indexedStore(this.#rawDurable, this.#addressIndex, this.address, 'durable', 'tasks') : this.#durable;
|
|
130
|
+
return new StoreTaskSource(store, `${this.key()}:tasks`, options);
|
|
97
131
|
}
|
|
98
132
|
async capability(name) {
|
|
99
133
|
const stored = (await this.#durable.get(this.#durableKey())) ?? {};
|
|
100
134
|
const capabilities = isJsonObject(stored.capabilities) ? stored.capabilities : {};
|
|
101
135
|
const capability = capabilities[name];
|
|
102
|
-
return isJsonObject(capability) ? capability : null;
|
|
136
|
+
return isJsonObject(capability) ? (this.#resolutionOnly ? structuredClone(capability) : capability) : null;
|
|
103
137
|
}
|
|
104
138
|
async usingCapability(name, state) {
|
|
105
139
|
return this.#writeCapabilities((capabilities) => ({ ...capabilities, [name]: state }));
|
|
@@ -149,7 +183,8 @@ export class Session {
|
|
|
149
183
|
* run inside this, not outside it.
|
|
150
184
|
*/
|
|
151
185
|
async lock(callback, ttlSeconds = 10, waitSeconds = 5) {
|
|
152
|
-
|
|
186
|
+
this.#assertWritable();
|
|
187
|
+
return this.#lockStore.withLock(this.key(), async () => {
|
|
153
188
|
// Re-read inside the lock. State written by whoever held it before us
|
|
154
189
|
// is otherwise invisible to this instance, and acting on a stale read
|
|
155
190
|
// is the thing the lock exists to prevent.
|
|
@@ -164,17 +199,37 @@ export class Session {
|
|
|
164
199
|
#durableKey() {
|
|
165
200
|
return `${this.key()}:durable`;
|
|
166
201
|
}
|
|
202
|
+
/** Refuse explicit logical mutations; ordinary get() retains expired-entry cleanup. */
|
|
203
|
+
async resolveReadOnly(mode, resolve) {
|
|
204
|
+
const view = new Session({ participant: this.participant, scope: this.scope, agent: this.agent, actor: this.actor,
|
|
205
|
+
ephemeral: this.#rawEphemeral, durable: this.#rawDurable, addressIndex: this.#addressIndex, ttlSeconds: this.#ttlSeconds });
|
|
206
|
+
view.#resolutionOnly = true;
|
|
207
|
+
view.#resolvedMode = mode;
|
|
208
|
+
try {
|
|
209
|
+
return await resolve(view);
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
view.#resolutionOnly = false;
|
|
213
|
+
view.#cachedState = null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
#assertWritable() {
|
|
217
|
+
if (this.#resolutionOnly)
|
|
218
|
+
throw new Error('Harness tool resolution is read-only.');
|
|
219
|
+
}
|
|
167
220
|
async #readString(key) {
|
|
168
221
|
const value = (await this.state())[key];
|
|
169
222
|
return typeof value === 'string' ? value : null;
|
|
170
223
|
}
|
|
171
224
|
async #write(key, value) {
|
|
225
|
+
this.#assertWritable();
|
|
172
226
|
const state = { ...(await this.state()), [key]: value };
|
|
173
227
|
await this.#ephemeral.put(this.#ephemeralKey(), state, this.#ttlSeconds);
|
|
174
228
|
this.#cachedState = state;
|
|
175
229
|
return this;
|
|
176
230
|
}
|
|
177
231
|
async #writeCapabilities(mutate) {
|
|
232
|
+
this.#assertWritable();
|
|
178
233
|
const stored = (await this.#durable.get(this.#durableKey())) ?? {};
|
|
179
234
|
const capabilities = isJsonObject(stored.capabilities) ? stored.capabilities : {};
|
|
180
235
|
await this.#durable.put(this.#durableKey(), { ...stored, capabilities: mutate(capabilities) });
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface LockIdentity {
|
|
2
|
+
readonly platform: 'linux' | 'darwin' | 'win32';
|
|
3
|
+
readonly boot_id: string;
|
|
4
|
+
readonly pid_namespace: string | null;
|
|
5
|
+
}
|
|
6
|
+
export interface FileLockIdentityStatus {
|
|
7
|
+
readonly sampled: boolean;
|
|
8
|
+
readonly recovery_available: boolean;
|
|
9
|
+
readonly definitive: boolean;
|
|
10
|
+
readonly retry_after_ms: number | null;
|
|
11
|
+
readonly sample_duration_ms: number;
|
|
12
|
+
readonly code: 'file_lock_identity_unavailable' | null;
|
|
13
|
+
}
|
|
14
|
+
/** Process metadata snapshot only; never runs a query or performs I/O. */
|
|
15
|
+
export declare function fileLockIdentityStatus(): FileLockIdentityStatus;
|
|
16
|
+
/** Definitive metadata is permanent; transient failure is sampled at most once per minute. */
|
|
17
|
+
export declare function nativeLockIdentity(): LockIdentity | null;
|
|
18
|
+
export declare function sameLockIdentity(owner: LockIdentity, local: LockIdentity): boolean;
|
|
19
|
+
/** Read-only signal-zero probe. Permission errors and reused live PIDs remain held. */
|
|
20
|
+
export declare function lockPidIsDead(pid: number): boolean;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFileSync, readlinkSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { windowsLockIdentity } from './file-lock-windows-identity.js';
|
|
4
|
+
let sampled = false;
|
|
5
|
+
let established = null;
|
|
6
|
+
let retryAt = 0;
|
|
7
|
+
let definitive = false;
|
|
8
|
+
let durationMs = 0;
|
|
9
|
+
/** Process metadata snapshot only; never runs a query or performs I/O. */
|
|
10
|
+
export function fileLockIdentityStatus() {
|
|
11
|
+
return Object.freeze({ sampled, recovery_available: established?.pid_namespace != null, definitive,
|
|
12
|
+
retry_after_ms: !sampled || definitive ? null : Math.max(0, retryAt - performance.now()),
|
|
13
|
+
sample_duration_ms: durationMs, code: sampled && established?.pid_namespace == null ? 'file_lock_identity_unavailable' : null });
|
|
14
|
+
}
|
|
15
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
16
|
+
/** Definitive metadata is permanent; transient failure is sampled at most once per minute. */
|
|
17
|
+
export function nativeLockIdentity() {
|
|
18
|
+
if (!sampled || (!definitive && performance.now() >= retryAt)) {
|
|
19
|
+
const start = performance.now();
|
|
20
|
+
const identity = readNativeLockIdentity();
|
|
21
|
+
durationMs = performance.now() - start;
|
|
22
|
+
established = identity === null ? null : Object.freeze(identity);
|
|
23
|
+
definitive = identity !== null && (identity.pid_namespace !== null || identity.platform === 'win32');
|
|
24
|
+
retryAt = performance.now() + 60000;
|
|
25
|
+
sampled = true;
|
|
26
|
+
}
|
|
27
|
+
return established;
|
|
28
|
+
}
|
|
29
|
+
function readNativeLockIdentity() {
|
|
30
|
+
let identity = null;
|
|
31
|
+
try {
|
|
32
|
+
if (process.platform === 'win32')
|
|
33
|
+
identity = windowsLockIdentity();
|
|
34
|
+
else if (process.platform === 'linux') {
|
|
35
|
+
const boot = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
|
|
36
|
+
if (!UUID.test(boot))
|
|
37
|
+
return null;
|
|
38
|
+
let realm = null;
|
|
39
|
+
try {
|
|
40
|
+
const value = readlinkSync('/proc/self/ns/pid');
|
|
41
|
+
if (/^pid:\[[0-9]+\]$/.test(value))
|
|
42
|
+
realm = value;
|
|
43
|
+
}
|
|
44
|
+
catch { /* unknown */ }
|
|
45
|
+
identity = { platform: 'linux', boot_id: boot.toLowerCase(), pid_namespace: realm };
|
|
46
|
+
}
|
|
47
|
+
else if (process.platform === 'darwin') {
|
|
48
|
+
const result = spawnSync('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'], { encoding: 'utf8', timeout: 15000, maxBuffer: 65536 });
|
|
49
|
+
const boot = result.stdout?.trim();
|
|
50
|
+
if (result.error || result.status !== 0 || !boot || !UUID.test(boot))
|
|
51
|
+
return null;
|
|
52
|
+
identity = { platform: 'darwin', boot_id: boot.toLowerCase(), pid_namespace: 'darwin:kernel' };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return identity;
|
|
59
|
+
}
|
|
60
|
+
export function sameLockIdentity(owner, local) {
|
|
61
|
+
return owner.pid_namespace !== null && local.pid_namespace !== null &&
|
|
62
|
+
owner.platform === local.platform && owner.boot_id === local.boot_id &&
|
|
63
|
+
owner.pid_namespace === local.pid_namespace;
|
|
64
|
+
}
|
|
65
|
+
/** Read-only signal-zero probe. Permission errors and reused live PIDs remain held. */
|
|
66
|
+
export function lockPidIsDead(pid) {
|
|
67
|
+
try {
|
|
68
|
+
process.kill(pid, 0);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
return error.code === 'ESRCH';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { win32 } from 'node:path';
|
|
3
|
+
// Master2797: fixed metadata only, no compilation, no value payload or Job state.
|
|
4
|
+
// Resolve the first-party CIM module from PSHOME, never a user module search path.
|
|
5
|
+
const COMMAND = String.raw `
|
|
6
|
+
$ErrorActionPreference = 'Stop'
|
|
7
|
+
$PSModuleAutoloadingPreference = 'None'
|
|
8
|
+
try {
|
|
9
|
+
Import-Module -Name ($PSHOME + '\Modules\Microsoft.PowerShell.Utility\Microsoft.PowerShell.Utility.psd1') -ErrorAction Stop
|
|
10
|
+
Import-Module -Name ($PSHOME + '\Modules\CimCmdlets\CimCmdlets.psd1') -ErrorAction Stop
|
|
11
|
+
$boot = (CimCmdlets\Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime
|
|
12
|
+
if ($null -eq $boot) { throw 'Missing boot identity' }
|
|
13
|
+
$ticks = $boot.ToUniversalTime().Ticks.ToString([Globalization.CultureInfo]::InvariantCulture)
|
|
14
|
+
$base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
|
|
15
|
+
try {
|
|
16
|
+
$key = $base.OpenSubKey('SYSTEM\CurrentControlSet\Control')
|
|
17
|
+
if ($null -eq $key) { throw 'Missing registry key' }
|
|
18
|
+
try { $present = $key.GetValueNames() -contains 'ContainerType' }
|
|
19
|
+
finally { $key.Dispose() }
|
|
20
|
+
} finally { $base.Dispose() }
|
|
21
|
+
[Console]::Out.WriteLine('boot=' + $ticks)
|
|
22
|
+
if ($present) { [Console]::Out.WriteLine('container=present') }
|
|
23
|
+
else { [Console]::Out.WriteLine('container=absent') }
|
|
24
|
+
} catch { exit 1 }
|
|
25
|
+
`;
|
|
26
|
+
export function windowsLockIdentity() {
|
|
27
|
+
const root = process.env.SystemRoot;
|
|
28
|
+
if (!root || !/^[a-z]:[\\/]/i.test(root) || !win32.isAbsolute(root))
|
|
29
|
+
return null;
|
|
30
|
+
const env = { SystemRoot: root, windir: root };
|
|
31
|
+
for (const key of ['TEMP', 'TMP'])
|
|
32
|
+
if (process.env[key])
|
|
33
|
+
env[key] = process.env[key];
|
|
34
|
+
const binary = win32.join(env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
35
|
+
try {
|
|
36
|
+
const result = spawnSync(binary, ['-NoProfile', '-NonInteractive', '-NoLogo', '-Command', COMMAND], { env, encoding: 'utf8', timeout: 15000, maxBuffer: 16384, windowsHide: true, shell: false });
|
|
37
|
+
if (result.error || result.status !== 0 || result.stderr !== '')
|
|
38
|
+
return null;
|
|
39
|
+
const match = /^boot=([1-9][0-9]{0,18})\r?\ncontainer=(present|absent)\r?\n$/.exec(result.stdout);
|
|
40
|
+
if (!match || match[0] !== result.stdout || BigInt(match[1]) > 3155378975999999999n)
|
|
41
|
+
return null;
|
|
42
|
+
return { platform: 'win32', boot_id: 'cim-utc-ticks:' + match[1],
|
|
43
|
+
pid_namespace: match[2] === 'absent' ? 'win32:kernel' : null };
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { LockIdentity } from './file-lock-identity.js';
|
|
2
|
+
export interface FileLockOwner {
|
|
3
|
+
version: 2;
|
|
4
|
+
pid: number;
|
|
5
|
+
identity: LockIdentity;
|
|
6
|
+
nonce: string;
|
|
7
|
+
released: boolean;
|
|
8
|
+
expires_at: number;
|
|
9
|
+
}
|
|
10
|
+
interface ReleaseProgress {
|
|
11
|
+
lockPath: string;
|
|
12
|
+
owner: FileLockOwner;
|
|
13
|
+
unlinked: boolean;
|
|
14
|
+
flushing?: Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export interface PendingFileLockRelease {
|
|
17
|
+
readonly key_digest: string;
|
|
18
|
+
readonly nonce: string;
|
|
19
|
+
}
|
|
20
|
+
/** Snapshot only: no filesystem access, retries, raw keys, paths or PIDs. */
|
|
21
|
+
export declare function pendingFileOwnerReleases(directory: string): readonly PendingFileLockRelease[];
|
|
22
|
+
/** Closed, bounded record; duplicate keys and fractional/exponent number tokens refuse. */
|
|
23
|
+
export declare function parseFileLockOwner(raw: Buffer, filename: string): FileLockOwner | null;
|
|
24
|
+
/** Unlink the observed nonce first. Missing old filename NEVER authorizes rmdir. */
|
|
25
|
+
export declare function cleanFileLockOwner(lockPath: string, owner: FileLockOwner, temporary?: boolean, progress?: ReleaseProgress): Promise<void>;
|
|
26
|
+
/** Decision 0022. Acquisition retry never contains callback execution. */
|
|
27
|
+
export declare function withFileOwnerLock<T>(path: string, key: string, callback: () => T | Promise<T>, ttl: number, wait: number): Promise<T>;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readdir, readFile, rename, rmdir, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { HarnessError } from '../errors.js';
|
|
5
|
+
import { nativeLockIdentity, sameLockIdentity, lockPidIsDead } from './file-lock-identity.js';
|
|
6
|
+
// Failed release is observable, then retried by this process's next lock call.
|
|
7
|
+
// Key by nonce: old and replacement owners of one path must never overwrite
|
|
8
|
+
// each other's pending state. No background timer keeps an idle worker alive.
|
|
9
|
+
const pendingReleases = new Map();
|
|
10
|
+
/** Snapshot only: no filesystem access, retries, raw keys, paths or PIDs. */
|
|
11
|
+
export function pendingFileOwnerReleases(directory) {
|
|
12
|
+
const parent = resolve(directory);
|
|
13
|
+
const rows = [...pendingReleases.values()].filter(progress => dirname(progress.lockPath) === parent)
|
|
14
|
+
.map(progress => Object.freeze({ key_digest: basename(progress.lockPath, '.json.lock'), nonce: progress.owner.nonce }));
|
|
15
|
+
rows.sort((a, b) => a.key_digest === b.key_digest ? (a.nonce < b.nonce ? -1 : a.nonce > b.nonce ? 1 : 0) : (a.key_digest < b.key_digest ? -1 : 1));
|
|
16
|
+
return Object.freeze(rows);
|
|
17
|
+
}
|
|
18
|
+
const OWNER = /^owner-([0-9a-f]{32})\.json$/;
|
|
19
|
+
const HELD = new Set(['EEXIST', 'ENOTEMPTY', 'EISDIR', 'ENOTDIR', 'EPERM', 'EACCES', 'EBUSY']);
|
|
20
|
+
function code(error) { return error?.code; }
|
|
21
|
+
function sleep(ms) { return new Promise(done => { setTimeout(done, ms); }); }
|
|
22
|
+
function object(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); }
|
|
23
|
+
function keys(value, expected) { return Object.keys(value).sort().join(',') === expected.sort().join(','); }
|
|
24
|
+
function bounded(value) { return typeof value === 'string' && value.length > 0 && Buffer.byteLength(value, 'utf8') <= 256 && Buffer.from(value, 'utf8').toString('utf8') === value; }
|
|
25
|
+
/** Closed, bounded record; duplicate keys and fractional/exponent number tokens refuse. */
|
|
26
|
+
export function parseFileLockOwner(raw, filename) {
|
|
27
|
+
if (raw.length > 4096 || raw.length === 0 || raw.at(-1) !== 10)
|
|
28
|
+
return null;
|
|
29
|
+
try {
|
|
30
|
+
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(raw);
|
|
31
|
+
const value = JSON.parse(text);
|
|
32
|
+
const tokens = text.match(/"(?:\\.|[^"\\])*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|[{}\[\]:,]|true|false|null/g) ?? [];
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
35
|
+
const token = tokens[i];
|
|
36
|
+
if (token.startsWith('"') && tokens[i + 1] === ':') {
|
|
37
|
+
const key = JSON.parse(token);
|
|
38
|
+
if (seen.has(key))
|
|
39
|
+
return null;
|
|
40
|
+
seen.add(key);
|
|
41
|
+
}
|
|
42
|
+
else if (/^-?\d/.test(token) && (!/^-?\d+$/.test(token) || !Number.isSafeInteger(Number(token))))
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const match = OWNER.exec(filename);
|
|
46
|
+
if (!match || !object(value) || !keys(value, ['version', 'pid', 'identity', 'nonce', 'released', 'expires_at']))
|
|
47
|
+
return null;
|
|
48
|
+
if (value.version !== 2 || typeof value.pid !== 'number' || !Number.isInteger(value.pid) || value.pid < 1 || value.pid > 4294967295 ||
|
|
49
|
+
typeof value.released !== 'boolean' || typeof value.expires_at !== 'number' || !Number.isSafeInteger(value.expires_at) || value.nonce !== match[1])
|
|
50
|
+
return null;
|
|
51
|
+
const id = value.identity;
|
|
52
|
+
if (!object(id) || !keys(id, ['platform', 'boot_id', 'pid_namespace']) ||
|
|
53
|
+
typeof id.platform !== 'string' || !['linux', 'darwin', 'win32'].includes(id.platform) || !bounded(id.boot_id) ||
|
|
54
|
+
!(id.pid_namespace === null || bounded(id.pid_namespace)))
|
|
55
|
+
return null;
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function filename(owner) { return 'owner-' + owner.nonce + '.json'; }
|
|
63
|
+
function payload(owner) { return JSON.stringify(owner) + '\n'; }
|
|
64
|
+
/** Unlink the observed nonce first. Missing old filename NEVER authorizes rmdir. */
|
|
65
|
+
export async function cleanFileLockOwner(lockPath, owner, temporary = false, progress) {
|
|
66
|
+
if (temporary)
|
|
67
|
+
await unlink(join(lockPath, 'owner-' + owner.nonce + '.release.tmp')).catch(error => { if (code(error) !== 'ENOENT')
|
|
68
|
+
throw error; });
|
|
69
|
+
if (!progress?.unlinked)
|
|
70
|
+
for (let attempt = 0;; attempt++) {
|
|
71
|
+
try {
|
|
72
|
+
await unlink(join(lockPath, filename(owner)));
|
|
73
|
+
if (progress)
|
|
74
|
+
progress.unlinked = true;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (code(error) === 'ENOENT')
|
|
79
|
+
return;
|
|
80
|
+
if (attempt >= 4 || !HELD.has(code(error) ?? ''))
|
|
81
|
+
throw error;
|
|
82
|
+
await sleep(5);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (let attempt = 0;; attempt++) {
|
|
86
|
+
try {
|
|
87
|
+
await rmdir(lockPath);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
// A replacement is populated, or somebody already removed the empty dir.
|
|
92
|
+
if (['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(code(error) ?? ''))
|
|
93
|
+
return;
|
|
94
|
+
if (attempt >= 4 || !HELD.has(code(error) ?? ''))
|
|
95
|
+
throw error;
|
|
96
|
+
await sleep(5);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function inspect(lockPath, local) {
|
|
101
|
+
let owner, temporary;
|
|
102
|
+
try {
|
|
103
|
+
const info = await lstat(lockPath);
|
|
104
|
+
if (info.isSymbolicLink())
|
|
105
|
+
return 'held';
|
|
106
|
+
if (info.isFile())
|
|
107
|
+
return 'legacy';
|
|
108
|
+
if (!info.isDirectory())
|
|
109
|
+
return 'held';
|
|
110
|
+
const entries = await readdir(lockPath);
|
|
111
|
+
const names = entries.filter(name => OWNER.test(name));
|
|
112
|
+
if (names.length !== 1)
|
|
113
|
+
return 'held';
|
|
114
|
+
const name = names[0], release = name.replace('.json', '.release.tmp');
|
|
115
|
+
if (entries.some(entry => entry !== name && entry !== release))
|
|
116
|
+
return 'held';
|
|
117
|
+
temporary = entries.includes(release);
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
const stat = await lstat(join(lockPath, entry));
|
|
120
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096)
|
|
121
|
+
return 'held';
|
|
122
|
+
}
|
|
123
|
+
owner = parseFileLockOwner(await readFile(join(lockPath, name)), name);
|
|
124
|
+
// Entire established identity must match BEFORE the first liveness probe.
|
|
125
|
+
if (!owner || !local || !sameLockIdentity(owner.identity, local))
|
|
126
|
+
return 'held';
|
|
127
|
+
if (!owner.released && !lockPidIsDead(owner.pid))
|
|
128
|
+
return 'held';
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (code(error) === 'ENOENT')
|
|
132
|
+
return 'retry';
|
|
133
|
+
if (HELD.has(code(error) ?? ''))
|
|
134
|
+
return 'held';
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
await cleanFileLockOwner(lockPath, owner, temporary);
|
|
138
|
+
return 'retry';
|
|
139
|
+
}
|
|
140
|
+
async function release(progress) {
|
|
141
|
+
const { lockPath, owner } = progress;
|
|
142
|
+
if (progress.unlinked) {
|
|
143
|
+
await cleanFileLockOwner(lockPath, owner, false, progress);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const name = filename(owner);
|
|
147
|
+
try {
|
|
148
|
+
const observed = parseFileLockOwner(await readFile(join(lockPath, name)), name);
|
|
149
|
+
if (!observed || observed.nonce !== owner.nonce)
|
|
150
|
+
throw new Error('File lock owner changed before release');
|
|
151
|
+
if (observed.released) {
|
|
152
|
+
await cleanFileLockOwner(lockPath, owner, true, progress);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
if (code(error) === 'ENOENT')
|
|
158
|
+
return;
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
const temporary = join(lockPath, 'owner-' + owner.nonce + '.release.tmp');
|
|
162
|
+
// The callback has finished; close the full release record before publishing it.
|
|
163
|
+
// While this exact live owner is still unreleased, nobody may reclaim it.
|
|
164
|
+
// Retrying its own known nonce temporary can safely replace a partial write.
|
|
165
|
+
await writeFile(temporary, payload({ ...owner, released: true }), { encoding: 'utf8', flag: 'w' });
|
|
166
|
+
for (let attempt = 0;; attempt++) {
|
|
167
|
+
try {
|
|
168
|
+
await rename(temporary, join(lockPath, name));
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (attempt >= 4 || !HELD.has(code(error) ?? ''))
|
|
173
|
+
throw error;
|
|
174
|
+
await sleep(5);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
await cleanFileLockOwner(lockPath, owner, false, progress);
|
|
178
|
+
}
|
|
179
|
+
async function flushPendingReleases() {
|
|
180
|
+
for (const progress of [...pendingReleases.values()]) {
|
|
181
|
+
if (pendingReleases.get(progress.owner.nonce) !== progress)
|
|
182
|
+
continue;
|
|
183
|
+
if (!progress.flushing)
|
|
184
|
+
progress.flushing = release(progress);
|
|
185
|
+
const operation = progress.flushing;
|
|
186
|
+
try {
|
|
187
|
+
await operation;
|
|
188
|
+
if (pendingReleases.get(progress.owner.nonce) === progress)
|
|
189
|
+
pendingReleases.delete(progress.owner.nonce);
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
if (progress.flushing === operation)
|
|
193
|
+
progress.flushing = undefined;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Decision 0022. Acquisition retry never contains callback execution. */
|
|
198
|
+
export async function withFileOwnerLock(path, key, callback, ttl, wait) {
|
|
199
|
+
if (!Number.isFinite(ttl) || !Number.isFinite(wait) || wait < 0 || !Number.isSafeInteger(Math.floor(Date.now() / 1000 + ttl)))
|
|
200
|
+
throw new RangeError('Invalid file lock duration');
|
|
201
|
+
await flushPendingReleases();
|
|
202
|
+
const lockPath = resolve(path);
|
|
203
|
+
try {
|
|
204
|
+
if ((await lstat(lockPath)).isFile())
|
|
205
|
+
throw new HarnessError('session_locked', 'legacy_lock_requires_maintenance: ' + lockPath + '; stop_old_workers_then_inspect_and_archive');
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (code(error) !== 'ENOENT')
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
const identity = nativeLockIdentity();
|
|
212
|
+
const platform = process.platform;
|
|
213
|
+
if (!identity && platform !== 'win32' && platform !== 'linux' && platform !== 'darwin')
|
|
214
|
+
throw new HarnessError('session_locked', 'Unsupported file-lock platform');
|
|
215
|
+
const owner = { version: 2, pid: process.pid, identity: identity ?? { platform: platform, boot_id: 'unknown', pid_namespace: null }, nonce: randomBytes(16).toString('hex'), released: false, expires_at: Math.floor(Date.now() / 1000 + ttl) };
|
|
216
|
+
if (identity?.pid_namespace == null)
|
|
217
|
+
process.emitWarning(JSON.stringify({ key_digest: basename(lockPath, '.json.lock'), nonce: owner.nonce }), { code: 'file_lock_identity_unavailable' });
|
|
218
|
+
const candidate = lockPath + '.candidate-' + owner.nonce;
|
|
219
|
+
if (dirname(candidate) !== dirname(lockPath) || basename(candidate) === basename(lockPath))
|
|
220
|
+
throw new Error('Lock publication must stay inside its parent');
|
|
221
|
+
const deadline = performance.now() + wait * 1000;
|
|
222
|
+
await mkdir(candidate);
|
|
223
|
+
let published = false;
|
|
224
|
+
try {
|
|
225
|
+
await writeFile(join(candidate, filename(owner)), payload(owner), { encoding: 'utf8', flag: 'wx' });
|
|
226
|
+
for (;;) {
|
|
227
|
+
if (pendingReleases.size)
|
|
228
|
+
await flushPendingReleases();
|
|
229
|
+
// Node/Windows rename can replace a regular file. Refuse stale legacy
|
|
230
|
+
// locks before publication; stopping ALL legacy workers is mandatory.
|
|
231
|
+
try {
|
|
232
|
+
const existing = await lstat(lockPath);
|
|
233
|
+
if (existing.isFile())
|
|
234
|
+
throw new HarnessError('session_locked', 'legacy_lock_requires_maintenance: ' + lockPath + '; stop_old_workers_then_inspect_and_archive');
|
|
235
|
+
if (existing.isSymbolicLink() || !existing.isDirectory())
|
|
236
|
+
throw HarnessError.sessionLocked(key, wait);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (code(error) !== 'ENOENT')
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
await rename(candidate, lockPath);
|
|
244
|
+
published = true;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
if (!HELD.has(code(error) ?? ''))
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
const state = await inspect(lockPath, identity);
|
|
252
|
+
if (state === 'legacy')
|
|
253
|
+
throw new HarnessError('session_locked', 'legacy_lock_requires_maintenance: ' + lockPath + '; stop_old_workers_then_inspect_and_archive');
|
|
254
|
+
if (state === 'retry' && performance.now() < deadline)
|
|
255
|
+
continue;
|
|
256
|
+
if (performance.now() >= deadline)
|
|
257
|
+
throw HarnessError.sessionLocked(key, wait);
|
|
258
|
+
await sleep(25);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
if (!published)
|
|
263
|
+
await cleanFileLockOwner(candidate, owner);
|
|
264
|
+
}
|
|
265
|
+
// Exactly one execution, with exact original error identity on normal cleanup.
|
|
266
|
+
try {
|
|
267
|
+
return await callback();
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
const progress = { lockPath, owner, unlinked: false };
|
|
271
|
+
try {
|
|
272
|
+
await release(progress);
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
pendingReleases.set(owner.nonce, progress);
|
|
276
|
+
process.emitWarning(JSON.stringify({ key_digest: basename(lockPath, '.json.lock'), nonce: owner.nonce }), { code: 'file_lock_release_pending' });
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { JsonObject } from '../json.js';
|
|
2
|
+
import type { FileLockIdentityStatus } from './file-lock-identity.js';
|
|
3
|
+
import type { PendingFileLockRelease } from './file-owner-lock.js';
|
|
2
4
|
import type { AppendLogStore, Durability, SessionStore } from './session-store.js';
|
|
3
5
|
/**
|
|
4
6
|
* State on disk, as one JSON file per key. DURABLE, and it says so.
|
|
@@ -16,17 +18,20 @@ import type { AppendLogStore, Durability, SessionStore } from './session-store.j
|
|
|
16
18
|
* partial write here is a corrupted thread, and a process killed mid-write is
|
|
17
19
|
* ordinary rather than exotic.
|
|
18
20
|
*
|
|
19
|
-
* **The lock is cross-process.**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* other. Two workers on different machines sharing a network filesystem do NOT
|
|
24
|
-
* reliably, and no file lock can promise that; use a database or Redis there.
|
|
21
|
+
* **The lock is cross-process.** Decision 0022 uses an atomically published
|
|
22
|
+
* owner directory. TTL never proves death. Local filesystems on one host only;
|
|
23
|
+
* SMB/NFS are unsupported. Stop all old workers before upgrading and inspect
|
|
24
|
+
* legacy timestamp lockfiles explicitly. Use a database or Redis across hosts.
|
|
25
25
|
*/
|
|
26
26
|
export declare class FileSessionStore implements SessionStore, AppendLogStore {
|
|
27
27
|
#private;
|
|
28
28
|
constructor(directory: string);
|
|
29
29
|
durability(): Durability;
|
|
30
|
+
/** Local filesystem on one host; SMB/NFS sharing is unsupported. */
|
|
31
|
+
sharingScope(): 'host';
|
|
32
|
+
lockScope(): 'host' | 'process' | 'distributed' | 'unsupported';
|
|
33
|
+
/** Process discovery snapshot; this observer never starts metadata discovery. */
|
|
34
|
+
identityStatus(): FileLockIdentityStatus;
|
|
30
35
|
get(key: string): Promise<JsonObject | null>;
|
|
31
36
|
put(key: string, payload: JsonObject, ttlSeconds?: number | null): Promise<void>;
|
|
32
37
|
forget(key: string): Promise<void>;
|
|
@@ -42,4 +47,6 @@ export declare class FileSessionStore implements SessionStore, AppendLogStore {
|
|
|
42
47
|
logLength(key: string): Promise<number | null>;
|
|
43
48
|
clearLog(key: string): Promise<void>;
|
|
44
49
|
withLock<T>(key: string, callback: () => T | Promise<T>, ttlSeconds?: number, waitSeconds?: number): Promise<T>;
|
|
50
|
+
/** Pending release diagnostics for this directory; length is the pending count. */
|
|
51
|
+
pendingReleases(): readonly PendingFileLockRelease[];
|
|
45
52
|
}
|