@raidiant/notifai 0.3.0 → 0.4.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.
Files changed (80) hide show
  1. package/README.md +20 -4
  2. package/dist/atomic-file.d.ts +4 -0
  3. package/dist/atomic-file.js +60 -10
  4. package/dist/atomic-file.js.map +1 -1
  5. package/dist/client.d.ts +25 -2
  6. package/dist/client.js +45 -1
  7. package/dist/client.js.map +1 -1
  8. package/dist/commands.d.ts +56 -7
  9. package/dist/commands.js +980 -318
  10. package/dist/commands.js.map +1 -1
  11. package/dist/config-schema.d.ts +1 -1
  12. package/dist/config-schema.js +46 -8
  13. package/dist/config-schema.js.map +1 -1
  14. package/dist/config.d.ts +38 -3
  15. package/dist/config.js +61 -14
  16. package/dist/config.js.map +1 -1
  17. package/dist/file-lock.d.ts +26 -0
  18. package/dist/file-lock.js +268 -0
  19. package/dist/file-lock.js.map +1 -0
  20. package/dist/harnesses.d.ts +28 -0
  21. package/dist/harnesses.js +40 -0
  22. package/dist/harnesses.js.map +1 -0
  23. package/dist/hook-adapter.d.ts +27 -0
  24. package/dist/hook-adapter.js +188 -0
  25. package/dist/hook-adapter.js.map +1 -0
  26. package/dist/hook-input.d.ts +10 -0
  27. package/dist/hook-input.js +64 -0
  28. package/dist/hook-input.js.map +1 -0
  29. package/dist/hooks.d.ts +66 -10
  30. package/dist/hooks.js +751 -276
  31. package/dist/hooks.js.map +1 -1
  32. package/dist/install-hooks.d.ts +40 -16
  33. package/dist/install-hooks.js +217 -73
  34. package/dist/install-hooks.js.map +1 -1
  35. package/dist/interactive.d.ts +1 -1
  36. package/dist/interactive.js +3 -3
  37. package/dist/interactive.js.map +1 -1
  38. package/dist/logging.d.ts +142 -0
  39. package/dist/logging.js +533 -0
  40. package/dist/logging.js.map +1 -0
  41. package/dist/main.js +97 -36
  42. package/dist/main.js.map +1 -1
  43. package/dist/opencode-plugin.d.ts +7 -7
  44. package/dist/opencode-plugin.js +12 -13
  45. package/dist/opencode-plugin.js.map +1 -1
  46. package/dist/ui/config-view.js +3 -0
  47. package/dist/ui/config-view.js.map +1 -1
  48. package/dist/ui/theme.d.ts +1 -1
  49. package/package.json +3 -2
  50. package/src/atomic-file.ts +86 -7
  51. package/src/client.test.ts +12 -0
  52. package/src/client.ts +72 -4
  53. package/src/commands.test.ts +891 -43
  54. package/src/commands.ts +1133 -332
  55. package/src/config-schema.ts +51 -9
  56. package/src/config.test.ts +6 -5
  57. package/src/config.ts +85 -16
  58. package/src/file-lock-process-worker.test.ts +64 -0
  59. package/src/file-lock.test.ts +279 -0
  60. package/src/file-lock.ts +318 -0
  61. package/src/harnesses.test.ts +48 -0
  62. package/src/harnesses.ts +59 -0
  63. package/src/hook-adapter.test.ts +187 -0
  64. package/src/hook-adapter.ts +212 -0
  65. package/src/hook-input.test.ts +39 -0
  66. package/src/hook-input.ts +72 -0
  67. package/src/hooks.test.ts +1318 -240
  68. package/src/hooks.ts +1046 -296
  69. package/src/install-hooks.test.ts +90 -41
  70. package/src/install-hooks.ts +259 -74
  71. package/src/interactive.ts +3 -3
  72. package/src/logging-process-worker.test.ts +39 -0
  73. package/src/logging.test.ts +453 -0
  74. package/src/logging.ts +639 -0
  75. package/src/main.ts +113 -32
  76. package/src/opencode-plugin.ts +15 -16
  77. package/src/session-end-log-lock-worker.test.ts +35 -0
  78. package/src/skill.test.ts +11 -0
  79. package/src/ui/config-view.test.ts +4 -2
  80. package/src/ui/config-view.ts +3 -0
@@ -17,7 +17,7 @@
17
17
  * a list and not an essay.
18
18
  */
19
19
  import { CLI_SOUNDS, INTERRUPTION_LEVELS } from '@raidiant/notifai-protocol'
20
- import { configBounds, type ConfigKey } from './config.js'
20
+ import { LOG_LEVELS, configBounds, type ConfigKey } from './config.js'
21
21
 
22
22
  export type ConfigKind = 'string' | 'url' | 'integer' | 'boolean' | 'enum' | 'list'
23
23
 
@@ -25,7 +25,7 @@ export type ConfigKind = 'string' | 'url' | 'integer' | 'boolean' | 'enum' | 'li
25
25
  * Which surface a key belongs to. Grouping is the difference between fourteen
26
26
  * flat keys and four short lists a reader can hold in their head.
27
27
  */
28
- export type ConfigGroup = 'questions' | 'delivery' | 'project' | 'connection'
28
+ export type ConfigGroup = 'questions' | 'delivery' | 'project' | 'diagnostics' | 'connection'
29
29
 
30
30
  export const CONFIG_GROUPS: { id: ConfigGroup; title: string; blurb: string }[] = [
31
31
  {
@@ -43,6 +43,11 @@ export const CONFIG_GROUPS: { id: ConfigGroup; title: string; blurb: string }[]
43
43
  title: 'This project',
44
44
  blurb: 'How work in this directory identifies itself, and the house rules agents read before notifying.',
45
45
  },
46
+ {
47
+ id: 'diagnostics',
48
+ title: 'Local logs',
49
+ blurb: 'What this machine records about its own activity, so an agent can find out afterwards what happened.',
50
+ },
46
51
  {
47
52
  id: 'connection',
48
53
  title: 'Connection',
@@ -94,8 +99,8 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
94
99
  kind: 'boolean',
95
100
  summary: 'Whether sitting at this keyboard holds a question back',
96
101
  detail:
97
- 'On (the default), a question waits while you are using this machine, because you are right there and a push would be redundant. Off means notify me anyway — `ask_grace_seconds` still runs, so the terminal is still offered the question first; it just no longer needs you to walk away before it may leave.\n\nThis is separate from `ask_notifications`, which switches routing off altogether: wanting to be reached while working is the opposite of not wanting to be reached.',
98
- example: 'true',
102
+ 'Off (the default) means notify me even while I am using this machine. `ask_grace_seconds` still runs, so the terminal is offered the question first; I just do not need to walk away before it may leave. Turn this on when local keyboard or mouse activity should keep questions in the terminal.\n\nThis is separate from `ask_notifications`, which switches routing off altogether: wanting to be reached only while away is different from not wanting to be reached.',
103
+ example: 'false',
99
104
  },
100
105
  away_after_seconds: {
101
106
  label: 'Away after',
@@ -112,10 +117,10 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
112
117
  group: 'questions',
113
118
  kind: 'integer',
114
119
  unit: 's',
115
- summary: 'How long a question stays terminal-only before it may reach your devices',
120
+ summary: 'Optional delay before a question may reach your devices',
116
121
  detail:
117
- 'Measured from the moment the agent asked. The question appears in the terminal and a timer starts; only once it elapses may the question go to your devices.\n\nA timer and nothing more. It is honoured whether or not presence is consulted at all, because how long to wait and whether anyone is watching are separate questions, and answering one should not answer the other.',
118
- example: '300',
122
+ 'Zero (the default) sends the question to your devices as soon as the agent turn ends. Set a positive duration to offer the terminal an exclusive answer window first; the timer is measured from the moment the agent asked.\n\nA timer and nothing more. It is honoured whether or not presence is consulted at all, because how long to wait and whether anyone is watching are separate questions, and answering one should not answer the other.',
123
+ example: '0',
119
124
  },
120
125
  hook_reply_timeout_seconds: {
121
126
  label: 'Hook wait for an answer',
@@ -124,8 +129,8 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
124
129
  unit: 's',
125
130
  summary: 'How long the harness hook blocks waiting for your answer',
126
131
  detail:
127
- 'After a question has gone out, the harness hook holds the agent for up to this long waiting for your answer before falling through to the harness\'s own prompt.\n\nThe ceiling exists because both Claude Code and Codex kill a command hook at 600 seconds. Grace plus this value plus the installer\'s headroom has to stay under that, or the hook is killed mid-wait — after you have already answered.',
128
- example: '180',
132
+ 'After a question has gone out, the harness continuation owner waits up to this long for your answer. The device reply window uses the same bound, so Notifai never accepts an answer after an agent with no dormant-session wake has already returned.\n\nThe ceiling exists because command-hook harnesses bound their Stop processes. Grace plus this value plus installer headroom stays inside that budget.',
133
+ example: '480',
129
134
  },
130
135
 
131
136
  sound: {
@@ -222,6 +227,43 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
222
227
  example: 'Only failures and completed deploys',
223
228
  },
224
229
 
230
+ log_level: {
231
+ label: 'Log level',
232
+ group: 'diagnostics',
233
+ kind: 'enum',
234
+ choices: LOG_LEVELS,
235
+ choiceHints: {
236
+ off: 'record nothing',
237
+ error: 'only failures',
238
+ info: 'commands, notifications, and every question decision',
239
+ debug: 'adds config resolution and per-request detail',
240
+ },
241
+ summary: 'How much this machine records about what it did',
242
+ detail:
243
+ 'Notifai keeps a local record of what it did — every command, every notification, and in particular every decision a harness hook made about whether a question could leave the terminal. Hooks run headless, so without this there is no account of them at all: `notifai logs` is how an agent finds out afterwards why a question never reached your phone.\n\nThe log never leaves this machine. It is not uploaded, and nothing sends it anywhere.\n\n`debug` also records which config layer won and the detail of each request, which is what to turn on when something is behaving in a way the ordinary log does not explain.',
244
+ example: 'info',
245
+ },
246
+ log_max_bytes: {
247
+ label: 'Log file size',
248
+ group: 'diagnostics',
249
+ kind: 'integer',
250
+ summary: 'How large the log grows before it is rotated',
251
+ detail:
252
+ 'When the active log reaches this size it is renamed aside and a fresh one starts. Together with the number of files kept, this is the whole disk budget: nothing here grows without a ceiling.',
253
+ advanced: true,
254
+ example: '2000000',
255
+ },
256
+ log_max_files: {
257
+ label: 'Log files kept',
258
+ group: 'diagnostics',
259
+ kind: 'integer',
260
+ summary: 'How many rotated log files are kept before the oldest is deleted',
261
+ detail:
262
+ 'Counts the active file. At the defaults — three files of two megabytes — the logs occupy about six megabytes, which is weeks of ordinary agent traffic.\n\nRaise it when you need to look further back than the log currently reaches; lower it to one to keep only the file being written.',
263
+ advanced: true,
264
+ example: '3',
265
+ },
266
+
225
267
  base_url: {
226
268
  label: 'Service URL',
227
269
  group: 'connection',
@@ -88,15 +88,16 @@ describe('harness config layers', () => {
88
88
  expect(loadConfig({ cwd, env, sessionId: 'a/b' }).away_after_seconds.value).toBe(120)
89
89
  })
90
90
 
91
- it('defaults the hook behaviour to on, gated by the presence check rather than a flag', () => {
91
+ it('defaults question routing to on without requiring the user to be idle', () => {
92
92
  const { env, cwd } = setup({})
93
93
  const config = loadConfig({ cwd, env })
94
94
  expect(config.ask_notifications.value).toBe(true)
95
+ expect(config.require_idle).toEqual({ value: false, source: 'default' })
95
96
  expect(config.away_after_seconds.value).toBe(120)
96
- // 300 grace + 180 reply + 60 installer headroom = 540, inside the 600s
97
- // ceiling both harnesses kill a command hook at.
98
- expect(config.hook_reply_timeout_seconds.value).toBe(180)
99
- expect(config.ask_grace_seconds.value).toBe(300)
97
+ // Immediate escalation by default leaves the whole hook budget available
98
+ // for the reply wait. Users can still opt into a terminal-first grace.
99
+ expect(config.hook_reply_timeout_seconds.value).toBe(480)
100
+ expect(config.ask_grace_seconds).toEqual({ value: 0, source: 'default' })
100
101
  })
101
102
  })
102
103
 
package/src/config.ts CHANGED
@@ -4,6 +4,7 @@ import os from 'node:os'
4
4
  import path from 'node:path'
5
5
  import { parse as parseToml } from 'smol-toml'
6
6
  import type { CLI_SOUNDS, INTERRUPTION_LEVELS } from '@raidiant/notifai-protocol'
7
+ import { HOOK_TIMING } from './harnesses.js'
7
8
 
8
9
  /**
9
10
  * Layered configuration with provenance. Most specific wins:
@@ -39,6 +40,14 @@ export interface ResolvedValue<T> {
39
40
  type CliSound = (typeof CLI_SOUNDS)[number]
40
41
  type CliInterruptionLevel = (typeof INTERRUPTION_LEVELS)[number]
41
42
 
43
+ /**
44
+ * How much the local log records. Defined here rather than in `logging.ts`
45
+ * because it is a configuration vocabulary first — `logging.ts` reads config,
46
+ * so the dependency has to point this way.
47
+ */
48
+ export const LOG_LEVELS = ['off', 'error', 'info', 'debug'] as const
49
+ export type LogLevel = (typeof LOG_LEVELS)[number]
50
+
42
51
  export interface CliConfig {
43
52
  base_url: ResolvedValue<string>
44
53
  wait_seconds: ResolvedValue<number>
@@ -81,8 +90,8 @@ export interface CliConfig {
81
90
  away_after_seconds: ResolvedValue<number>
82
91
  /**
83
92
  * How long a hook blocks waiting for a companion-device answer before falling through
84
- * to the harness's own prompt. Must stay under the harness hook timeout
85
- * (600s in both Claude Code and Codex) or the harness kills us mid-wait.
93
+ * to the harness's own prompt. Runtime arbitration keeps the complete Stop
94
+ * invocation inside its fixed 480s owner budget and 540s adapter ceiling.
86
95
  */
87
96
  hook_reply_timeout_seconds: ResolvedValue<number>
88
97
  /**
@@ -96,6 +105,24 @@ export interface CliConfig {
96
105
  * questions, and answering one should not answer the other.
97
106
  */
98
107
  ask_grace_seconds: ResolvedValue<number>
108
+ /**
109
+ * How much of what this CLI does is recorded to the local log.
110
+ *
111
+ * The log exists for agents: a hook runs headless and its most consequential
112
+ * act is usually a decision to do nothing, which leaves no other trace. `off`
113
+ * disables the sink entirely; `debug` adds config resolution and request
114
+ * detail, which is verbose enough to want a reason.
115
+ */
116
+ log_level: ResolvedValue<LogLevel>
117
+ /**
118
+ * Bytes the active log file may reach before it is rotated. With
119
+ * `log_max_files` this is the whole disk budget — nothing here grows without
120
+ * a ceiling, because a log that fills a disk is a bug that outlives its
121
+ * usefulness.
122
+ */
123
+ log_max_bytes: ResolvedValue<number>
124
+ /** Log files kept in total, the active one included. */
125
+ log_max_files: ResolvedValue<number>
99
126
  }
100
127
 
101
128
  export const CONFIG_KEYS = [
@@ -113,6 +140,9 @@ export const CONFIG_KEYS = [
113
140
  'away_after_seconds',
114
141
  'hook_reply_timeout_seconds',
115
142
  'ask_grace_seconds',
143
+ 'log_level',
144
+ 'log_max_bytes',
145
+ 'log_max_files',
116
146
  ] as const
117
147
  export type ConfigKey = (typeof CONFIG_KEYS)[number]
118
148
 
@@ -129,8 +159,21 @@ export const NUMERIC_CONFIG_KEYS: readonly ConfigKey[] = [
129
159
  'away_after_seconds',
130
160
  'hook_reply_timeout_seconds',
131
161
  'ask_grace_seconds',
162
+ 'log_max_bytes',
163
+ 'log_max_files',
132
164
  ]
133
165
 
166
+ /**
167
+ * Keys whose value must be one of a fixed set.
168
+ *
169
+ * Config is readable from a repository, so an unrecognised value is untrusted
170
+ * input rather than a typo — `coerce` drops it and the layer below wins, which
171
+ * is the same discipline the numeric bounds follow.
172
+ */
173
+ export const ENUM_CONFIG_VALUES: Partial<Record<ConfigKey, readonly string[]>> = {
174
+ log_level: LOG_LEVELS,
175
+ }
176
+
134
177
  const DEFAULTS = {
135
178
  base_url: 'https://notifai.fly.dev',
136
179
  wait_seconds: 10,
@@ -144,17 +187,29 @@ const DEFAULTS = {
144
187
  // Installing the hooks is the opt-in; these exist to switch the behaviour
145
188
  // back off for a project or a session without uninstalling anything.
146
189
  ask_notifications: true,
147
- // On by default because it is the less surprising of the two: a question
148
- // appearing on your phone while you are typing the answer into the terminal
149
- // is worse as a default than one that waits for you to leave.
150
- require_idle: true,
190
+ // Questions reach devices immediately by default. Both a terminal-first
191
+ // grace and presence gating remain independent opt-in controls.
192
+ require_idle: false,
151
193
  away_after_seconds: 120,
152
- // 300 + 180 + the installer's 60s headroom = 540, inside the 600s
153
- // command-hook ceiling both harnesses enforce.
154
- hook_reply_timeout_seconds: 180,
155
- ask_grace_seconds: 300,
194
+ // Runtime waits stay inside a 480s budget. Harnesses that require a declared
195
+ // Stop limit use a preference-stable 540s; Codex uses its host-owned Stop
196
+ // default. Neither definition changes when these preferences do.
197
+ hook_reply_timeout_seconds: 480,
198
+ ask_grace_seconds: 0,
199
+ // On by default: the log is the only account of what a headless hook did, and
200
+ // a diagnostic nobody switched on before the thing went wrong is not one.
201
+ log_level: 'info',
202
+ // 2 MB × 3 files caps the logs at roughly 6 MB, which is weeks of ordinary
203
+ // agent traffic and small enough that nobody has to think about it.
204
+ log_max_bytes: 2_000_000,
205
+ log_max_files: 3,
156
206
  } satisfies Record<ConfigKey, unknown>
157
207
 
208
+ /** The shipped value beneath every user-controlled configuration layer. */
209
+ export function configDefaultValue(key: ConfigKey): unknown {
210
+ return DEFAULTS[key]
211
+ }
212
+
158
213
  export function globalConfigDir(env: NodeJS.ProcessEnv = process.env): string {
159
214
  const xdg = env['XDG_CONFIG_HOME']
160
215
  const base = xdg && xdg !== '' ? xdg : path.join(os.homedir(), '.config')
@@ -245,17 +300,24 @@ export function findProjectLocalConfigPath(startDir: string): string | null {
245
300
  * Ranges every layer is clamped to. Config is readable from a repository, so an
246
301
  * out-of-range value is attacker input, not a typo: `away_after_seconds = -1`
247
302
  * would make a user who just typed count as absent, and an oversized reply
248
- * timeout would push the generated hook past the harness's 600s ceiling so the
249
- * hook is killed after the user has already answered.
303
+ * timeout would exhaust the hook process budget and kill it after the user has
304
+ * already answered.
250
305
  */
251
306
  const NUMERIC_BOUNDS: Partial<Record<ConfigKey, { min: number; max: number }>> = {
252
307
  wait_seconds: { min: 0, max: 300 },
253
308
  ttl_seconds: { min: 0, max: 7 * 24 * 3600 },
254
309
  away_after_seconds: { min: 5, max: 24 * 3600 },
255
- // 540 + the installer's 60s headroom stays inside the 600s command-hook
256
- // budget shared by Claude Code and Codex.
257
- hook_reply_timeout_seconds: { min: 10, max: 540 },
258
- ask_grace_seconds: { min: 0, max: 540 },
310
+ // The runtime clamps grace + reply work below the host process ceiling; this
311
+ // maximum is never copied into the installed definition.
312
+ // The wire protocol's minimum reply window is 60s; matching it prevents the
313
+ // server from accepting an answer after a shorter local wait has returned.
314
+ hook_reply_timeout_seconds: { min: 60, max: 540 },
315
+ ask_grace_seconds: { min: 0, max: HOOK_TIMING.maxGraceSeconds },
316
+ // The floor keeps rotation from thrashing (a file smaller than a handful of
317
+ // records would rotate on nearly every write); the ceiling is the point past
318
+ // which "local diagnostics" has become "a database on your disk".
319
+ log_max_bytes: { min: 64_000, max: 100_000_000 },
320
+ log_max_files: { min: 1, max: 20 },
259
321
  }
260
322
 
261
323
  /** Drops values a layer must not be trusted to supply, rather than throwing. */
@@ -266,6 +328,10 @@ function coerce(key: ConfigKey, value: unknown): unknown | undefined {
266
328
  return Math.min(bounds.max, Math.max(bounds.min, Math.trunc(value)))
267
329
  }
268
330
  if (BOOLEAN_CONFIG_KEYS.includes(key)) return typeof value === 'boolean' ? value : undefined
331
+ const allowed = ENUM_CONFIG_VALUES[key]
332
+ if (allowed !== undefined) {
333
+ return typeof value === 'string' && allowed.includes(value) ? value : undefined
334
+ }
269
335
  return value
270
336
  }
271
337
 
@@ -343,5 +409,8 @@ export function loadConfig(options: {
343
409
  away_after_seconds: resolve('away_after_seconds'),
344
410
  hook_reply_timeout_seconds: resolve('hook_reply_timeout_seconds'),
345
411
  ask_grace_seconds: resolve('ask_grace_seconds'),
412
+ log_level: resolve('log_level'),
413
+ log_max_bytes: resolve('log_max_bytes'),
414
+ log_max_files: resolve('log_max_files'),
346
415
  }
347
416
  }
@@ -0,0 +1,64 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { withFileLock, type FileLockObservation } from './file-lock.js'
4
+
5
+ const worker = process.env['NOTIFAI_FILE_LOCK_WORKER']
6
+ const sleep = new Int32Array(new SharedArrayBuffer(4))
7
+
8
+ function waitFor(file: string): void {
9
+ const deadline = Date.now() + 15_000
10
+ while (!existsSync(file)) {
11
+ if (Date.now() >= deadline) throw new Error(`timed out waiting for ${file}`)
12
+ Atomics.wait(sleep, 0, 0, 2)
13
+ }
14
+ }
15
+
16
+ if (worker === undefined) {
17
+ describe('file lock process worker', () => {
18
+ it('stays dormant outside the cross-process regressions', () => {
19
+ expect(worker).toBeUndefined()
20
+ })
21
+ })
22
+ } else {
23
+ describe('file lock process worker', () => {
24
+ it('runs one instrumented lock contender', () => {
25
+ const pauseAt = process.env['NOTIFAI_FILE_LOCK_PAUSE_AT']
26
+ const pausePath = process.env['NOTIFAI_FILE_LOCK_PAUSED']!
27
+ const continuePath = process.env['NOTIFAI_FILE_LOCK_CONTINUE']!
28
+ const enteredPath = process.env['NOTIFAI_FILE_LOCK_ENTERED']!
29
+ const releasePath = process.env['NOTIFAI_FILE_LOCK_RELEASE']!
30
+ const waitPath = process.env['NOTIFAI_FILE_LOCK_WAITS']!
31
+ let waits = 0
32
+
33
+ withFileLock(
34
+ process.env['NOTIFAI_FILE_LOCK_PATH']!,
35
+ () => {
36
+ writeFileSync(enteredPath, worker)
37
+ if (process.env['NOTIFAI_FILE_LOCK_ACTION'] === 'crash') {
38
+ process.kill(process.pid, 'SIGKILL')
39
+ throw new Error('SIGKILL did not terminate the worker')
40
+ }
41
+ waitFor(releasePath)
42
+ },
43
+ {
44
+ waitMs: 15_000,
45
+ observe(observation: FileLockObservation) {
46
+ if (
47
+ observation.phase === 'choosing-published' ||
48
+ observation.phase === 'stale-entry'
49
+ ) {
50
+ writeFileSync(pausePath, observation.entry)
51
+ if (pauseAt === observation.phase) waitFor(continuePath)
52
+ }
53
+ if (observation.phase === 'waiting') {
54
+ waits += 1
55
+ writeFileSync(waitPath, String(waits))
56
+ }
57
+ },
58
+ },
59
+ )
60
+
61
+ expect(readFileSync(enteredPath, 'utf8')).toBe(worker)
62
+ })
63
+ })
64
+ }
@@ -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
+ })