@raidiant/notifai 0.3.0 → 0.5.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 +19 -4
- package/dist/atomic-file.d.ts +4 -0
- package/dist/atomic-file.js +60 -10
- package/dist/atomic-file.js.map +1 -1
- package/dist/client.d.ts +25 -2
- package/dist/client.js +45 -1
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +56 -7
- package/dist/commands.js +980 -318
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +46 -8
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +38 -3
- package/dist/config.js +61 -14
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +26 -0
- package/dist/file-lock.js +268 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/harnesses.d.ts +28 -0
- package/dist/harnesses.js +40 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/hook-adapter.d.ts +27 -0
- package/dist/hook-adapter.js +188 -0
- package/dist/hook-adapter.js.map +1 -0
- package/dist/hook-input.d.ts +10 -0
- package/dist/hook-input.js +64 -0
- package/dist/hook-input.js.map +1 -0
- package/dist/hooks.d.ts +66 -10
- package/dist/hooks.js +751 -276
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +40 -16
- package/dist/install-hooks.js +217 -73
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +3 -3
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +142 -0
- package/dist/logging.js +533 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.js +97 -36
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -7
- package/dist/opencode-plugin.js +12 -13
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/ui/config-view.js +3 -0
- package/dist/ui/config-view.js.map +1 -1
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +3 -2
- package/src/atomic-file.ts +86 -7
- package/src/claude-wake.test.ts +320 -0
- package/src/claude-wake.ts +489 -0
- package/src/client.test.ts +12 -0
- package/src/client.ts +72 -4
- package/src/codex-wake.test.ts +253 -0
- package/src/codex-wake.ts +288 -0
- package/src/commands.test.ts +1261 -77
- package/src/commands.ts +1482 -375
- package/src/config-schema.test.ts +3 -3
- package/src/config-schema.ts +57 -44
- package/src/config.test.ts +22 -16
- package/src/config.ts +81 -66
- package/src/file-lock-process-worker.test.ts +64 -0
- package/src/file-lock.test.ts +279 -0
- package/src/file-lock.ts +318 -0
- package/src/harnesses.test.ts +34 -0
- package/src/harnesses.ts +57 -0
- package/src/hook-adapter.test.ts +187 -0
- package/src/hook-adapter.ts +212 -0
- package/src/hook-input.test.ts +39 -0
- package/src/hook-input.ts +72 -0
- package/src/hooks.test.ts +1985 -417
- package/src/hooks.ts +1338 -461
- package/src/install-hooks.test.ts +131 -41
- package/src/install-hooks.ts +284 -84
- package/src/interactive.ts +3 -8
- package/src/logging-process-worker.test.ts +39 -0
- package/src/logging.test.ts +453 -0
- package/src/logging.ts +639 -0
- package/src/main.ts +113 -32
- package/src/opencode-plugin.ts +15 -16
- package/src/session-end-log-lock-worker.test.ts +35 -0
- package/src/skill.test.ts +34 -0
- package/src/ui/config-view.test.ts +9 -7
- package/src/ui/config-view.ts +3 -0
- package/src/idle.test.ts +0 -32
- package/src/idle.ts +0 -41
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
symlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from 'node:fs'
|
|
13
|
+
import os from 'node:os'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import { describe, expect, it } from 'vitest'
|
|
16
|
+
import { withFileLock, withTargetFileLock } from './file-lock.js'
|
|
17
|
+
|
|
18
|
+
interface LockWorker {
|
|
19
|
+
child: ChildProcess
|
|
20
|
+
done: Promise<void>
|
|
21
|
+
pausedPath: string
|
|
22
|
+
continuePath: string
|
|
23
|
+
enteredPath: string
|
|
24
|
+
releasePath: string
|
|
25
|
+
waitsPath: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface LockWorkerOptions {
|
|
29
|
+
pauseAt?: string
|
|
30
|
+
action?: 'hold' | 'crash'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function waitCount(file: string): number {
|
|
34
|
+
try {
|
|
35
|
+
return Number(readFileSync(file, 'utf8'))
|
|
36
|
+
} catch {
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function waitUntil(predicate: () => boolean, timeoutMs = 10_000): Promise<void> {
|
|
42
|
+
const deadline = Date.now() + timeoutMs
|
|
43
|
+
while (!predicate()) {
|
|
44
|
+
if (Date.now() >= deadline) throw new Error('timed out waiting for file-lock worker')
|
|
45
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function lockWorker(
|
|
50
|
+
root: string,
|
|
51
|
+
lockPath: string,
|
|
52
|
+
id: string,
|
|
53
|
+
options: LockWorkerOptions = {},
|
|
54
|
+
): LockWorker {
|
|
55
|
+
const marker = (name: string): string => path.join(root, `${id}-${name}`)
|
|
56
|
+
const pausedPath = marker('paused')
|
|
57
|
+
const continuePath = marker('continue')
|
|
58
|
+
const enteredPath = marker('entered')
|
|
59
|
+
const releasePath = marker('release')
|
|
60
|
+
const waitsPath = marker('waits')
|
|
61
|
+
const vitest = path.join(process.cwd(), 'node_modules', 'vitest', 'vitest.mjs')
|
|
62
|
+
const child = spawn(
|
|
63
|
+
process.execPath,
|
|
64
|
+
[vitest, 'run', 'src/file-lock-process-worker.test.ts', '--reporter=dot', '--maxWorkers=1'],
|
|
65
|
+
{
|
|
66
|
+
cwd: process.cwd(),
|
|
67
|
+
env: {
|
|
68
|
+
...process.env,
|
|
69
|
+
NOTIFAI_FILE_LOCK_WORKER: id,
|
|
70
|
+
NOTIFAI_FILE_LOCK_PATH: lockPath,
|
|
71
|
+
NOTIFAI_FILE_LOCK_PAUSED: pausedPath,
|
|
72
|
+
NOTIFAI_FILE_LOCK_CONTINUE: continuePath,
|
|
73
|
+
NOTIFAI_FILE_LOCK_ENTERED: enteredPath,
|
|
74
|
+
NOTIFAI_FILE_LOCK_RELEASE: releasePath,
|
|
75
|
+
NOTIFAI_FILE_LOCK_WAITS: waitsPath,
|
|
76
|
+
NOTIFAI_FILE_LOCK_ACTION: options.action ?? 'hold',
|
|
77
|
+
...(options.pauseAt === undefined
|
|
78
|
+
? {}
|
|
79
|
+
: { NOTIFAI_FILE_LOCK_PAUSE_AT: options.pauseAt }),
|
|
80
|
+
},
|
|
81
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
const stdout: Buffer[] = []
|
|
85
|
+
const stderr: Buffer[] = []
|
|
86
|
+
child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk))
|
|
87
|
+
child.stderr!.on('data', (chunk: Buffer) => stderr.push(chunk))
|
|
88
|
+
const done = new Promise<void>((resolve, reject) => {
|
|
89
|
+
child.once('error', reject)
|
|
90
|
+
child.once('exit', (code, signal) => {
|
|
91
|
+
const expectedExit =
|
|
92
|
+
options.action === 'crash' ? code !== 0 || signal !== null : code === 0
|
|
93
|
+
if (expectedExit) resolve()
|
|
94
|
+
else {
|
|
95
|
+
reject(
|
|
96
|
+
new Error(
|
|
97
|
+
`file-lock worker ${id} exited ${String(code)}\n${Buffer.concat(stdout).toString()}\n${Buffer.concat(stderr).toString()}`,
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
void done.catch(() => undefined)
|
|
104
|
+
return { child, done, pausedPath, continuePath, enteredPath, releasePath, waitsPath }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function stop(workers: LockWorker[]): void {
|
|
108
|
+
for (const worker of workers) {
|
|
109
|
+
if (worker.child.exitCode === null && worker.child.signalCode === null) worker.child.kill('SIGKILL')
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe('cross-process file lock', () => {
|
|
114
|
+
it('does not expose an ownerless publication window before acquisition is complete', async () => {
|
|
115
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-file-lock-'))
|
|
116
|
+
const lockPath = path.join(root, 'shared.lock')
|
|
117
|
+
const workers: LockWorker[] = []
|
|
118
|
+
try {
|
|
119
|
+
const first = lockWorker(root, lockPath, 'first', { pauseAt: 'choosing-published' })
|
|
120
|
+
workers.push(first)
|
|
121
|
+
await waitUntil(() => existsSync(first.pausedPath))
|
|
122
|
+
|
|
123
|
+
const published = readdirSync(lockPath)
|
|
124
|
+
expect(published).toHaveLength(1)
|
|
125
|
+
expect(published[0]).toMatch(/^choosing-\d+-[0-9a-f]+$/)
|
|
126
|
+
expect(statSync(path.join(lockPath, published[0]!)).size).toBe(0)
|
|
127
|
+
|
|
128
|
+
const second = lockWorker(root, lockPath, 'second')
|
|
129
|
+
workers.push(second)
|
|
130
|
+
await waitUntil(() => existsSync(second.pausedPath) && waitCount(second.waitsPath) > 0)
|
|
131
|
+
expect(existsSync(first.enteredPath)).toBe(false)
|
|
132
|
+
expect(existsSync(second.enteredPath)).toBe(false)
|
|
133
|
+
|
|
134
|
+
writeFileSync(first.continuePath, 'continue')
|
|
135
|
+
await waitUntil(() => existsSync(first.enteredPath) !== existsSync(second.enteredPath))
|
|
136
|
+
|
|
137
|
+
const winner = existsSync(first.enteredPath) ? first : second
|
|
138
|
+
const loser = winner === first ? second : first
|
|
139
|
+
const waitsBefore = waitCount(loser.waitsPath)
|
|
140
|
+
await waitUntil(() => {
|
|
141
|
+
if (existsSync(loser.enteredPath)) throw new Error('both lock actions overlapped')
|
|
142
|
+
return waitCount(loser.waitsPath) > waitsBefore
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
writeFileSync(winner.releasePath, 'release')
|
|
146
|
+
await waitUntil(() => existsSync(loser.enteredPath))
|
|
147
|
+
writeFileSync(loser.releasePath, 'release')
|
|
148
|
+
await Promise.all(workers.map(({ done }) => done))
|
|
149
|
+
expect(existsSync(lockPath)).toBe(false)
|
|
150
|
+
} finally {
|
|
151
|
+
stop(workers)
|
|
152
|
+
await Promise.allSettled(workers.map(({ done }) => done))
|
|
153
|
+
rmSync(root, { recursive: true, force: true })
|
|
154
|
+
}
|
|
155
|
+
}, 30_000)
|
|
156
|
+
|
|
157
|
+
it('cannot reap a successor after observing a dead owner', async () => {
|
|
158
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-file-lock-'))
|
|
159
|
+
const lockPath = path.join(root, 'shared.lock')
|
|
160
|
+
const workers: LockWorker[] = []
|
|
161
|
+
try {
|
|
162
|
+
const crashed = lockWorker(root, lockPath, 'crashed', { action: 'crash' })
|
|
163
|
+
workers.push(crashed)
|
|
164
|
+
await waitUntil(() => existsSync(crashed.enteredPath))
|
|
165
|
+
await crashed.done
|
|
166
|
+
|
|
167
|
+
const staleEntries = readdirSync(lockPath)
|
|
168
|
+
expect(staleEntries).toHaveLength(1)
|
|
169
|
+
expect(staleEntries[0]).toMatch(/^ticket-\d+-\d+-[0-9a-f]+$/)
|
|
170
|
+
const staleEntry = staleEntries[0]!
|
|
171
|
+
|
|
172
|
+
const delayedReaper = lockWorker(root, lockPath, 'delayed-reaper', {
|
|
173
|
+
pauseAt: 'stale-entry',
|
|
174
|
+
})
|
|
175
|
+
workers.push(delayedReaper)
|
|
176
|
+
await waitUntil(
|
|
177
|
+
() =>
|
|
178
|
+
existsSync(delayedReaper.pausedPath) &&
|
|
179
|
+
readFileSync(delayedReaper.pausedPath, 'utf8') === staleEntry,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const successor = lockWorker(root, lockPath, 'successor')
|
|
183
|
+
workers.push(successor)
|
|
184
|
+
await waitUntil(() => existsSync(successor.enteredPath))
|
|
185
|
+
const successorEntry = readdirSync(lockPath).find((entry) => entry.startsWith('ticket-'))
|
|
186
|
+
expect(successorEntry).toBeDefined()
|
|
187
|
+
expect(successorEntry).not.toBe(staleEntry)
|
|
188
|
+
|
|
189
|
+
writeFileSync(delayedReaper.continuePath, 'continue')
|
|
190
|
+
await waitUntil(() => {
|
|
191
|
+
if (existsSync(delayedReaper.enteredPath)) {
|
|
192
|
+
throw new Error('the delayed stale cleanup displaced the successor')
|
|
193
|
+
}
|
|
194
|
+
return waitCount(delayedReaper.waitsPath) > 0
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
writeFileSync(successor.releasePath, 'release')
|
|
198
|
+
await waitUntil(() => existsSync(delayedReaper.enteredPath))
|
|
199
|
+
writeFileSync(delayedReaper.releasePath, 'release')
|
|
200
|
+
await Promise.all([successor.done, delayedReaper.done])
|
|
201
|
+
} finally {
|
|
202
|
+
stop(workers)
|
|
203
|
+
await Promise.allSettled(workers.map(({ done }) => done))
|
|
204
|
+
rmSync(root, { recursive: true, force: true })
|
|
205
|
+
}
|
|
206
|
+
}, 30_000)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('target file transaction lock', () => {
|
|
210
|
+
it('refuses a symlinked target parent before publishing a contender', () => {
|
|
211
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-target-lock-'))
|
|
212
|
+
const foreign = path.join(root, 'foreign')
|
|
213
|
+
const linked = path.join(root, 'linked')
|
|
214
|
+
mkdirSync(foreign)
|
|
215
|
+
symlinkSync(foreign, linked)
|
|
216
|
+
|
|
217
|
+
expect(() => withTargetFileLock(path.join(linked, 'settings.json'), () => {})).toThrow(
|
|
218
|
+
/not a regular directory/,
|
|
219
|
+
)
|
|
220
|
+
expect(existsSync(path.join(foreign, '.settings.json.notifai.lock'))).toBe(false)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('refuses a symlink posing as the lock rendezvous', () => {
|
|
224
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-target-lock-'))
|
|
225
|
+
const foreign = path.join(root, 'foreign')
|
|
226
|
+
const lock = path.join(root, '.settings.json.notifai.lock')
|
|
227
|
+
mkdirSync(foreign)
|
|
228
|
+
symlinkSync(foreign, lock)
|
|
229
|
+
|
|
230
|
+
expect(() => withTargetFileLock(path.join(root, 'settings.json'), () => {})).toThrow(
|
|
231
|
+
/not a regular directory/,
|
|
232
|
+
)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('does not remove a replacement at its unique owned entry', () => {
|
|
236
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-target-lock-'))
|
|
237
|
+
const target = path.join(root, 'settings.json')
|
|
238
|
+
const lock = path.join(root, '.settings.json.notifai.lock')
|
|
239
|
+
let replacement = ''
|
|
240
|
+
|
|
241
|
+
expect(() =>
|
|
242
|
+
withTargetFileLock(target, () => {
|
|
243
|
+
const entry = readdirSync(lock)[0]!
|
|
244
|
+
replacement = path.join(lock, entry)
|
|
245
|
+
rmSync(replacement)
|
|
246
|
+
writeFileSync(replacement, 'replacement')
|
|
247
|
+
}),
|
|
248
|
+
).toThrow(/changed while held/)
|
|
249
|
+
expect(readFileSync(replacement, 'utf8')).toBe('replacement')
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('preserves the action error when release also finds a replacement', () => {
|
|
253
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-target-lock-'))
|
|
254
|
+
const target = path.join(root, 'settings.json')
|
|
255
|
+
const lock = path.join(root, '.settings.json.notifai.lock')
|
|
256
|
+
const actionError = new Error('merge failed')
|
|
257
|
+
|
|
258
|
+
expect(() =>
|
|
259
|
+
withTargetFileLock(target, () => {
|
|
260
|
+
const entry = path.join(lock, readdirSync(lock)[0]!)
|
|
261
|
+
rmSync(entry)
|
|
262
|
+
writeFileSync(entry, 'replacement')
|
|
263
|
+
throw actionError
|
|
264
|
+
}),
|
|
265
|
+
).toThrow(actionError)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('refuses a symlink masquerading as a protocol entry', () => {
|
|
269
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-target-lock-'))
|
|
270
|
+
const lock = path.join(root, 'shared.lock')
|
|
271
|
+
const foreign = path.join(root, 'foreign')
|
|
272
|
+
mkdirSync(lock)
|
|
273
|
+
writeFileSync(foreign, 'leave me')
|
|
274
|
+
symlinkSync(foreign, path.join(lock, 'ticket-1-999999-deadbeef'))
|
|
275
|
+
|
|
276
|
+
expect(() => withFileLock(lock, () => {})).toThrow(/regular file-lock entry/)
|
|
277
|
+
expect(readFileSync(foreign, 'utf8')).toBe('leave me')
|
|
278
|
+
})
|
|
279
|
+
})
|
package/src/file-lock.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
fstatSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
rmdirSync,
|
|
12
|
+
unlinkSync,
|
|
13
|
+
} from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Serialize a short cross-process filesystem transaction.
|
|
18
|
+
*
|
|
19
|
+
* Each contender publishes all of its ownership in one unique filename. A
|
|
20
|
+
* two-phase bakery lock then orders live contenders by ticket and process
|
|
21
|
+
* identity. Because paths are never reused, recovering an entry left by a dead
|
|
22
|
+
* process cannot remove a later owner's entry; because process liveness, rather
|
|
23
|
+
* than age, decides recovery, a delayed holder is never mistaken for a dead one.
|
|
24
|
+
*
|
|
25
|
+
* Acquisition is deliberately bounded because both harness hooks and logging
|
|
26
|
+
* must fail open rather than hold an agent turn indefinitely.
|
|
27
|
+
*/
|
|
28
|
+
const FILE_LOCK_WAIT_MS = 1_000
|
|
29
|
+
const FILE_LOCK_POLL_MS = 5
|
|
30
|
+
const FILE_LOCK_TOKEN_BYTES = 12
|
|
31
|
+
const ENTRY_PATTERN = /^(choosing|ticket-(\d+))-(\d+)-([0-9a-f]+)$/
|
|
32
|
+
const lockSleep = new Int32Array(new SharedArrayBuffer(4))
|
|
33
|
+
|
|
34
|
+
export type FileLockObservation =
|
|
35
|
+
| { phase: 'choosing-published'; entry: string }
|
|
36
|
+
| { phase: 'stale-entry'; entry: string }
|
|
37
|
+
| { phase: 'waiting'; blockers: string[] }
|
|
38
|
+
|
|
39
|
+
interface FileLockOptions {
|
|
40
|
+
waitMs?: number
|
|
41
|
+
observe?: (observation: FileLockObservation) => void
|
|
42
|
+
/** Installer transactions report lock replacement after a successful action. */
|
|
43
|
+
strictRelease?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface FileIdentity {
|
|
47
|
+
dev: number
|
|
48
|
+
ino: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ChoosingEntry {
|
|
52
|
+
kind: 'choosing'
|
|
53
|
+
name: string
|
|
54
|
+
pid: number
|
|
55
|
+
token: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface TicketEntry {
|
|
59
|
+
kind: 'ticket'
|
|
60
|
+
name: string
|
|
61
|
+
pid: number
|
|
62
|
+
token: string
|
|
63
|
+
ticket: bigint
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type LockEntry = ChoosingEntry | TicketEntry
|
|
67
|
+
|
|
68
|
+
function parseEntry(name: string): LockEntry | null {
|
|
69
|
+
const match = ENTRY_PATTERN.exec(name)
|
|
70
|
+
if (match === null) return null
|
|
71
|
+
const pid = Number(match[3])
|
|
72
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null
|
|
73
|
+
const token = match[4]!
|
|
74
|
+
if (match[1] === 'choosing') return { kind: 'choosing', name, pid, token }
|
|
75
|
+
try {
|
|
76
|
+
const ticket = BigInt(match[2]!)
|
|
77
|
+
if (ticket <= 0n) return null
|
|
78
|
+
return { kind: 'ticket', name, pid, token, ticket }
|
|
79
|
+
} catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function processIsAlive(pid: number): boolean {
|
|
85
|
+
if (pid === process.pid) return true
|
|
86
|
+
try {
|
|
87
|
+
process.kill(pid, 0)
|
|
88
|
+
return true
|
|
89
|
+
} catch (err) {
|
|
90
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
91
|
+
if (code === 'ESRCH') return false
|
|
92
|
+
// EPERM means the process exists but is owned by somebody else. Unknown
|
|
93
|
+
// failures are also treated as live: losing recovery is safer than allowing
|
|
94
|
+
// two holders into the transaction.
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function currentUid(): number | undefined {
|
|
100
|
+
return typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function assertOwnedDirectory(directory: string): FileIdentity {
|
|
104
|
+
const stat = lstatSync(directory)
|
|
105
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
106
|
+
throw new Error(`${directory} is not a regular directory; refusing to use it as a file lock.`)
|
|
107
|
+
}
|
|
108
|
+
const uid = currentUid()
|
|
109
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
110
|
+
throw new Error(`${directory} is owned by uid ${stat.uid}, not the current user.`)
|
|
111
|
+
}
|
|
112
|
+
return { dev: stat.dev, ino: stat.ino }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function assertSameDirectory(directory: string, expected: FileIdentity): void {
|
|
116
|
+
const current = lstatSync(directory)
|
|
117
|
+
if (
|
|
118
|
+
current.isSymbolicLink() ||
|
|
119
|
+
!current.isDirectory() ||
|
|
120
|
+
current.dev !== expected.dev ||
|
|
121
|
+
current.ino !== expected.ino
|
|
122
|
+
) {
|
|
123
|
+
throw new Error(`${directory} changed while the file lock was active.`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sameIdentity(file: string, expected: FileIdentity): boolean {
|
|
128
|
+
try {
|
|
129
|
+
const current = lstatSync(file)
|
|
130
|
+
return current.dev === expected.dev && current.ino === expected.ino
|
|
131
|
+
} catch {
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Live protocol entries, pruning only unique paths whose process is gone. */
|
|
137
|
+
function liveEntries(
|
|
138
|
+
directory: string,
|
|
139
|
+
directoryIdentity: FileIdentity,
|
|
140
|
+
observe: FileLockOptions['observe'],
|
|
141
|
+
): LockEntry[] {
|
|
142
|
+
assertSameDirectory(directory, directoryIdentity)
|
|
143
|
+
const live: LockEntry[] = []
|
|
144
|
+
for (const name of readdirSync(directory)) {
|
|
145
|
+
const entry = parseEntry(name)
|
|
146
|
+
if (entry === null) continue
|
|
147
|
+
const entryPath = path.join(directory, name)
|
|
148
|
+
let stat
|
|
149
|
+
try {
|
|
150
|
+
stat = lstatSync(entryPath)
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue
|
|
153
|
+
throw err
|
|
154
|
+
}
|
|
155
|
+
const uid = currentUid()
|
|
156
|
+
if (stat.isSymbolicLink() || !stat.isFile() || (uid !== undefined && stat.uid !== uid)) {
|
|
157
|
+
throw new Error(`${entryPath} is not a current-user-owned regular file-lock entry.`)
|
|
158
|
+
}
|
|
159
|
+
if (processIsAlive(entry.pid)) {
|
|
160
|
+
live.push(entry)
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
observe?.({ phase: 'stale-entry', entry: name })
|
|
164
|
+
try {
|
|
165
|
+
// Recheck the unique dead owner's inode so delayed recovery cannot reap
|
|
166
|
+
// a replacement published at the same path by external interference.
|
|
167
|
+
const current = lstatSync(entryPath)
|
|
168
|
+
if (current.dev === stat.dev && current.ino === stat.ino) unlinkSync(entryPath)
|
|
169
|
+
} catch {
|
|
170
|
+
// Another contender already recovered this exact dead owner's unique path.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return live
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function publishEmpty(file: string): FileIdentity {
|
|
177
|
+
const handle = openSync(file, 'wx', 0o600)
|
|
178
|
+
try {
|
|
179
|
+
const stat = fstatSync(handle)
|
|
180
|
+
return { dev: stat.dev, ino: stat.ino }
|
|
181
|
+
} finally {
|
|
182
|
+
closeSync(handle)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function publishChoosing(
|
|
187
|
+
directory: string,
|
|
188
|
+
name: string,
|
|
189
|
+
deadline: number,
|
|
190
|
+
observe: FileLockOptions['observe'],
|
|
191
|
+
): { file: string; fileIdentity: FileIdentity; directoryIdentity: FileIdentity } {
|
|
192
|
+
const file = path.join(directory, name)
|
|
193
|
+
for (;;) {
|
|
194
|
+
try {
|
|
195
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
196
|
+
const directoryIdentity = assertOwnedDirectory(directory)
|
|
197
|
+
chmodSync(directory, 0o700)
|
|
198
|
+
// Recover before registration so a delayed stale cleanup can only name an
|
|
199
|
+
// old owner's unique path, never this contender's future one.
|
|
200
|
+
liveEntries(directory, directoryIdentity, observe)
|
|
201
|
+
const fileIdentity = publishEmpty(file)
|
|
202
|
+
assertSameDirectory(directory, directoryIdentity)
|
|
203
|
+
return { file, fileIdentity, directoryIdentity }
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
|
206
|
+
// The previous holder removed an empty rendezvous directory between our
|
|
207
|
+
// mkdir and publication. Recreate it; once our entry exists, no cooperating
|
|
208
|
+
// releaser can remove the non-empty directory underneath us.
|
|
209
|
+
if (Date.now() >= deadline) {
|
|
210
|
+
throw new Error(`timed out publishing file lock contender in ${directory}`)
|
|
211
|
+
}
|
|
212
|
+
Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function comesBefore(entry: TicketEntry, ticket: bigint, pid: number, token: string): boolean {
|
|
218
|
+
if (entry.ticket !== ticket) return entry.ticket < ticket
|
|
219
|
+
if (entry.pid !== pid) return entry.pid < pid
|
|
220
|
+
return entry.token < token
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function withFileLock<T>(file: string, action: () => T, options: FileLockOptions = {}): T {
|
|
224
|
+
const waitMs = options.waitMs ?? FILE_LOCK_WAIT_MS
|
|
225
|
+
const deadline = Date.now() + waitMs
|
|
226
|
+
const pid = process.pid
|
|
227
|
+
const token = randomBytes(FILE_LOCK_TOKEN_BYTES).toString('hex')
|
|
228
|
+
const choosingName = `choosing-${pid}-${token}`
|
|
229
|
+
let ownedPath: string | null = null
|
|
230
|
+
let ownedIdentity: FileIdentity | null = null
|
|
231
|
+
let directoryIdentity: FileIdentity | null = null
|
|
232
|
+
let operationFailed = false
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const published = publishChoosing(file, choosingName, deadline, options.observe)
|
|
236
|
+
ownedPath = published.file
|
|
237
|
+
ownedIdentity = published.fileIdentity
|
|
238
|
+
directoryIdentity = published.directoryIdentity
|
|
239
|
+
options.observe?.({ phase: 'choosing-published', entry: choosingName })
|
|
240
|
+
let highest = 0n
|
|
241
|
+
for (const entry of liveEntries(file, directoryIdentity, options.observe)) {
|
|
242
|
+
if (entry.kind === 'ticket' && entry.ticket > highest) highest = entry.ticket
|
|
243
|
+
}
|
|
244
|
+
const ticket = highest + 1n
|
|
245
|
+
const ticketName = `ticket-${ticket}-${pid}-${token}`
|
|
246
|
+
const ticketPath = path.join(file, ticketName)
|
|
247
|
+
|
|
248
|
+
// The rename is the doorway: observers see either a chooser or its complete
|
|
249
|
+
// ticket, never a gap in which a live contender has no published state.
|
|
250
|
+
renameSync(ownedPath, ticketPath)
|
|
251
|
+
ownedPath = ticketPath
|
|
252
|
+
|
|
253
|
+
for (;;) {
|
|
254
|
+
const blockers: string[] = []
|
|
255
|
+
for (const entry of liveEntries(file, directoryIdentity, options.observe)) {
|
|
256
|
+
if (entry.pid === pid && entry.token === token) continue
|
|
257
|
+
if (entry.kind === 'choosing' || comesBefore(entry, ticket, pid, token)) {
|
|
258
|
+
blockers.push(entry.name)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (blockers.length === 0) {
|
|
262
|
+
assertSameDirectory(file, directoryIdentity)
|
|
263
|
+
return action()
|
|
264
|
+
}
|
|
265
|
+
options.observe?.({ phase: 'waiting', blockers })
|
|
266
|
+
if (Date.now() >= deadline) throw new Error(`timed out waiting for file lock ${file}`)
|
|
267
|
+
Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS)
|
|
268
|
+
}
|
|
269
|
+
} catch (err) {
|
|
270
|
+
operationFailed = true
|
|
271
|
+
throw err
|
|
272
|
+
} finally {
|
|
273
|
+
let releaseChanged = false
|
|
274
|
+
if (ownedPath !== null && ownedIdentity !== null) {
|
|
275
|
+
try {
|
|
276
|
+
if (sameIdentity(ownedPath, ownedIdentity)) unlinkSync(ownedPath)
|
|
277
|
+
else releaseChanged = true
|
|
278
|
+
} catch {
|
|
279
|
+
// External cleanup cannot turn a release failure into a caller failure.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
if (directoryIdentity !== null) assertSameDirectory(file, directoryIdentity)
|
|
284
|
+
rmdirSync(file)
|
|
285
|
+
} catch {
|
|
286
|
+
// Another contender has published, or external cleanup already removed it.
|
|
287
|
+
}
|
|
288
|
+
if (releaseChanged && options.strictRelease === true && !operationFailed) {
|
|
289
|
+
throw new Error(`${ownedPath ?? file} changed while held; refusing to remove the replacement.`)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Serialize a read/merge/write transaction for one user-owned target file.
|
|
296
|
+
*
|
|
297
|
+
* This is the single installer-facing lock policy: the target's direct parent
|
|
298
|
+
* is a real current-user-owned directory, remains the same inode throughout,
|
|
299
|
+
* and the bakery rendezvous lives beside the target rather than inside it.
|
|
300
|
+
*/
|
|
301
|
+
export function withTargetFileLock<T>(
|
|
302
|
+
target: string,
|
|
303
|
+
action: () => T,
|
|
304
|
+
options: Omit<FileLockOptions, 'strictRelease'> = {},
|
|
305
|
+
): T {
|
|
306
|
+
const parent = path.dirname(target)
|
|
307
|
+
mkdirSync(parent, { recursive: true })
|
|
308
|
+
const parentIdentity = assertOwnedDirectory(parent)
|
|
309
|
+
const lockDirectory = path.join(parent, `.${path.basename(target)}.notifai.lock`)
|
|
310
|
+
return withFileLock(
|
|
311
|
+
lockDirectory,
|
|
312
|
+
() => {
|
|
313
|
+
assertSameDirectory(parent, parentIdentity)
|
|
314
|
+
return action()
|
|
315
|
+
},
|
|
316
|
+
{ ...options, strictRelease: true },
|
|
317
|
+
)
|
|
318
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { HARNESS_CAPABILITIES, HARNESSES } from './harnesses.js'
|
|
3
|
+
|
|
4
|
+
describe('harness continuation contract', () => {
|
|
5
|
+
it('requires one explicit native delivery contract for every shipped harness', () => {
|
|
6
|
+
expect(Object.keys(HARNESS_CAPABILITIES).sort()).toEqual([...HARNESSES].sort())
|
|
7
|
+
expect(HARNESS_CAPABILITIES).toEqual({
|
|
8
|
+
'claude-code': {
|
|
9
|
+
stopContinuation: 'decision-block',
|
|
10
|
+
deliveryRoutes: ['hook-continuation', 'inbox-socket', 'cold-resume', 'hold-for-next-turn'],
|
|
11
|
+
deliveryContract:
|
|
12
|
+
'the Stop hook returns at once and waits out of band, then posts the answer into this same session over its own inbox socket; a session that has stopped is resumed only once a liveness probe proves it stopped',
|
|
13
|
+
},
|
|
14
|
+
codex: {
|
|
15
|
+
stopContinuation: 'decision-block',
|
|
16
|
+
deliveryRoutes: ['hook-continuation', 'cold-resume', 'hold-for-next-turn'],
|
|
17
|
+
deliveryContract:
|
|
18
|
+
'live Stop continuation while the turn is held; once it returns, a stopped thread is resumed behind its writer lock and anything else waits for the next turn',
|
|
19
|
+
},
|
|
20
|
+
cursor: {
|
|
21
|
+
stopContinuation: 'unsupported',
|
|
22
|
+
deliveryRoutes: ['unsupported'],
|
|
23
|
+
deliveryContract:
|
|
24
|
+
'the hook can return a live follow-up, but the invoking shell exposes no exact conversation id; asynchronous ask is unsupported',
|
|
25
|
+
},
|
|
26
|
+
opencode: {
|
|
27
|
+
stopContinuation: 'unsupported',
|
|
28
|
+
deliveryRoutes: ['unsupported'],
|
|
29
|
+
deliveryContract:
|
|
30
|
+
'no proven answer continuation after session.idle; use a blocking reply command',
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
})
|
package/src/harnesses.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Every harness Notifai ships. This is the sole source of the harness union. */
|
|
2
|
+
export const HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
|
|
3
|
+
|
|
4
|
+
export type Harness = (typeof HARNESSES)[number]
|
|
5
|
+
|
|
6
|
+
export type DeliveryRoute =
|
|
7
|
+
| 'hook-continuation'
|
|
8
|
+
| 'inbox-socket'
|
|
9
|
+
| 'owned-control-plane'
|
|
10
|
+
| 'cold-resume'
|
|
11
|
+
| 'hold-for-next-turn'
|
|
12
|
+
| 'unsupported'
|
|
13
|
+
|
|
14
|
+
export type StopContinuation =
|
|
15
|
+
| 'decision-block'
|
|
16
|
+
| 'followup-message'
|
|
17
|
+
| 'unsupported'
|
|
18
|
+
|
|
19
|
+
export interface HarnessCapability {
|
|
20
|
+
/** How an answer is admitted to another turn in the already-open session. */
|
|
21
|
+
stopContinuation: StopContinuation
|
|
22
|
+
/** Available answer-delivery routes, best current route first. */
|
|
23
|
+
deliveryRoutes: readonly DeliveryRoute[]
|
|
24
|
+
/** Concise, honest readiness text for doctor and installation guidance. */
|
|
25
|
+
deliveryContract: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Exhaustive runtime contract. Adding a harness without deciding every
|
|
30
|
+
* continuation joint is a type error, not a silently degraded notification.
|
|
31
|
+
*/
|
|
32
|
+
export const HARNESS_CAPABILITIES: Record<Harness, HarnessCapability> = {
|
|
33
|
+
'claude-code': {
|
|
34
|
+
stopContinuation: 'decision-block',
|
|
35
|
+
deliveryRoutes: ['hook-continuation', 'inbox-socket', 'cold-resume', 'hold-for-next-turn'],
|
|
36
|
+
deliveryContract:
|
|
37
|
+
'the Stop hook returns at once and waits out of band, then posts the answer into this same session over its own inbox socket; a session that has stopped is resumed only once a liveness probe proves it stopped',
|
|
38
|
+
},
|
|
39
|
+
codex: {
|
|
40
|
+
stopContinuation: 'decision-block',
|
|
41
|
+
deliveryRoutes: ['hook-continuation', 'cold-resume', 'hold-for-next-turn'],
|
|
42
|
+
deliveryContract:
|
|
43
|
+
'live Stop continuation while the turn is held; once it returns, a stopped thread is resumed behind its writer lock and anything else waits for the next turn',
|
|
44
|
+
},
|
|
45
|
+
cursor: {
|
|
46
|
+
stopContinuation: 'unsupported',
|
|
47
|
+
deliveryRoutes: ['unsupported'],
|
|
48
|
+
deliveryContract:
|
|
49
|
+
'the hook can return a live follow-up, but the invoking shell exposes no exact conversation id; asynchronous ask is unsupported',
|
|
50
|
+
},
|
|
51
|
+
opencode: {
|
|
52
|
+
stopContinuation: 'unsupported',
|
|
53
|
+
deliveryRoutes: ['unsupported'],
|
|
54
|
+
deliveryContract:
|
|
55
|
+
'no proven answer continuation after session.idle; use a blocking reply command',
|
|
56
|
+
},
|
|
57
|
+
}
|