@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/dist/session.js CHANGED
@@ -1,4 +1,5 @@
1
- import { createHash } from 'node:crypto';
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
- this.participant = options.participant;
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
- const digest = createHash('sha1').update(this.participant.type).digest('hex').slice(0, 12);
49
- return `session:${digest}:${String(this.participant.id)}:${this.scope}`;
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
- return new Thread(this.#durable, `${this.key()}:thread`);
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
- return new StoreTaskSource(this.#durable, `${this.key()}:tasks`, options);
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
- return this.#ephemeral.withLock(this.key(), async () => {
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,2 @@
1
+ import type { LockIdentity } from './file-lock-identity.js';
2
+ export declare function windowsLockIdentity(): LockIdentity | null;
@@ -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.** It is an exclusive-create lockfile (`wx`),
20
- * which is the one primitive that is atomic on every filesystem worth
21
- * supporting — unlike an "is it there?" check followed by a create, which has a
22
- * window between the two. Two workers on one machine genuinely exclude each
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
  }