@raidiant/notifai 3.0.1 → 5.0.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 (71) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +12 -4
  3. package/dist/commands.d.ts +27 -10
  4. package/dist/commands.js +429 -152
  5. package/dist/commands.js.map +1 -1
  6. package/dist/config-schema.js +20 -11
  7. package/dist/config-schema.js.map +1 -1
  8. package/dist/config.d.ts +3 -1
  9. package/dist/config.js +12 -1
  10. package/dist/config.js.map +1 -1
  11. package/dist/file-lock.d.ts +6 -1
  12. package/dist/file-lock.js +55 -9
  13. package/dist/file-lock.js.map +1 -1
  14. package/dist/harnesses.d.ts +1 -1
  15. package/dist/harnesses.js.map +1 -1
  16. package/dist/hooks.d.ts +84 -12
  17. package/dist/hooks.js +433 -125
  18. package/dist/hooks.js.map +1 -1
  19. package/dist/install-hooks.d.ts +91 -14
  20. package/dist/install-hooks.js +226 -34
  21. package/dist/install-hooks.js.map +1 -1
  22. package/dist/interactive.d.ts +1 -1
  23. package/dist/interactive.js +1 -1
  24. package/dist/invocation-context.d.ts +42 -0
  25. package/dist/invocation-context.js +130 -0
  26. package/dist/invocation-context.js.map +1 -0
  27. package/dist/logging.d.ts +8 -0
  28. package/dist/logging.js +8 -0
  29. package/dist/logging.js.map +1 -1
  30. package/dist/main.js +84 -49
  31. package/dist/main.js.map +1 -1
  32. package/dist/opencode-plugin.d.ts +8 -0
  33. package/dist/opencode-plugin.js +24 -162
  34. package/dist/opencode-plugin.js.map +1 -1
  35. package/dist/send.d.ts +38 -7
  36. package/dist/send.js +97 -41
  37. package/dist/send.js.map +1 -1
  38. package/dist/ui/help.d.ts +0 -1
  39. package/dist/ui/help.js +6 -3
  40. package/dist/ui/help.js.map +1 -1
  41. package/dist/ui/theme.d.ts +0 -15
  42. package/dist/ui/theme.js +2 -18
  43. package/dist/ui/theme.js.map +1 -1
  44. package/package.json +2 -2
  45. package/src/commands.test.ts +501 -63
  46. package/src/commands.ts +547 -187
  47. package/src/config-schema.test.ts +1 -1
  48. package/src/config-schema.ts +22 -11
  49. package/src/config.test.ts +178 -18
  50. package/src/config.ts +14 -1
  51. package/src/file-lock.test.ts +41 -0
  52. package/src/file-lock.ts +59 -9
  53. package/src/harnesses.test.ts +37 -29
  54. package/src/harnesses.ts +1 -4
  55. package/src/hooks.test.ts +500 -58
  56. package/src/hooks.ts +523 -169
  57. package/src/install-hooks.test.ts +232 -12
  58. package/src/install-hooks.ts +241 -37
  59. package/src/interactive.ts +1 -1
  60. package/src/invocation-context.test.ts +253 -0
  61. package/src/invocation-context.ts +176 -0
  62. package/src/logging-process-worker.test.ts +8 -2
  63. package/src/logging.test.ts +33 -18
  64. package/src/logging.ts +11 -0
  65. package/src/main.ts +119 -54
  66. package/src/opencode-plugin.ts +24 -164
  67. package/src/question-caps.test.ts +49 -0
  68. package/src/send.ts +144 -50
  69. package/src/skill.test.ts +197 -120
  70. package/src/ui/help.ts +8 -3
  71. package/src/ui/theme.ts +2 -16
@@ -132,7 +132,7 @@ export function routingHookActions(input: {
132
132
  }
133
133
 
134
134
  options.push(
135
- { value: 'settings', label: 'Change presence settings', hint: 'when a question may leave this terminal' },
135
+ { value: 'settings', label: 'Change question settings', hint: 'when a question may leave this terminal' },
136
136
  { value: 'back', label: '← Back' },
137
137
  )
138
138
  return options
@@ -0,0 +1,253 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { sessionLabelFromId } from '@raidiant/notifai-protocol'
3
+ import {
4
+ buildSourceContext,
5
+ inferInvocationContext,
6
+ portableBasename,
7
+ projectSlugFrom,
8
+ truncateContext,
9
+ type GitCommand,
10
+ } from './invocation-context.js'
11
+
12
+ function fixtureGit(values: Record<string, string | null>): GitCommand {
13
+ return (_cwd, args) => values[args.join(' ')] ?? null
14
+ }
15
+
16
+ describe('projectSlugFrom', () => {
17
+ it.each([
18
+ ['My Project', 'my-project'],
19
+ ['.Hidden Repo', 'hidden-repo'],
20
+ ['API_v2.0', 'api_v2.0'],
21
+ ['---', null],
22
+ ['项目', null],
23
+ ])('turns %s into %s', (name, expected) => {
24
+ expect(projectSlugFrom(name)).toBe(expected)
25
+ })
26
+
27
+ it('keeps the Project identifier inside its 64-character bound', () => {
28
+ expect(projectSlugFrom(`repo-${'x'.repeat(100)}`)).toHaveLength(64)
29
+ })
30
+ })
31
+
32
+ describe('portableBasename', () => {
33
+ it.each([
34
+ ['/Users/person/code/notifai/', 'notifai'],
35
+ ['C:\\Users\\Person\\code\\notifai\\', 'notifai'],
36
+ ['\\\\server\\share\\worktrees\\release\\', 'release'],
37
+ ])('extracts the basename from %s', (value, expected) => {
38
+ expect(portableBasename(value)).toBe(expected)
39
+ })
40
+ })
41
+
42
+ describe('inferInvocationContext', () => {
43
+ it('uses the common-directory parent for a repository from a nested cwd', () => {
44
+ const context = inferInvocationContext(
45
+ '/code/Signal Garden/packages/api',
46
+ fixtureGit({
47
+ 'rev-parse --git-common-dir': '../../.git',
48
+ 'rev-parse --git-dir': '../../.git',
49
+ 'rev-parse --show-toplevel': '/code/Signal Garden',
50
+ 'rev-parse --abbrev-ref HEAD': 'feature/alerts',
51
+ }),
52
+ )
53
+ expect(context).toEqual({ project: 'signal-garden', branch: 'feature/alerts' })
54
+ })
55
+
56
+ it('keeps one Project across linked worktrees and exposes only the worktree basename', () => {
57
+ const context = inferInvocationContext(
58
+ '/Users/name/worktrees/notifai-release/apps/cli',
59
+ fixtureGit({
60
+ 'rev-parse --git-common-dir': '/Users/name/code/notifai/.git',
61
+ 'rev-parse --git-dir': '/Users/name/code/notifai/.git/worktrees/notifai-release',
62
+ 'rev-parse --show-toplevel': '/Users/name/worktrees/notifai-release/',
63
+ 'rev-parse --abbrev-ref HEAD': 'release/content',
64
+ }),
65
+ )
66
+ expect(context).toEqual({
67
+ project: 'notifai',
68
+ branch: 'release/content',
69
+ worktree: 'notifai-release',
70
+ })
71
+ expect(JSON.stringify(context)).not.toContain('/Users/name')
72
+ })
73
+
74
+ it('handles drive-letter paths and compares them case-insensitively', () => {
75
+ const context = inferInvocationContext(
76
+ 'D:\\Worktrees\\Alerts\\src',
77
+ fixtureGit({
78
+ 'rev-parse --git-common-dir': 'C:\\Code\\Notifai\\.git\\',
79
+ 'rev-parse --git-dir': 'c:\\code\\notifai\\.git\\worktrees\\Alerts',
80
+ 'rev-parse --show-toplevel': 'D:\\Worktrees\\Alerts\\',
81
+ 'rev-parse --abbrev-ref HEAD': 'feature/windows',
82
+ }),
83
+ )
84
+ expect(context).toEqual({
85
+ project: 'notifai',
86
+ branch: 'feature/windows',
87
+ worktree: 'Alerts',
88
+ })
89
+ expect(JSON.stringify(context)).not.toContain('D:\\Worktrees')
90
+ expect(JSON.stringify(context)).not.toContain('C:\\Code')
91
+ })
92
+
93
+ it('handles a UNC common directory and trailing separators', () => {
94
+ const context = inferInvocationContext(
95
+ '\\\\server\\share\\trees\\topic\\src',
96
+ fixtureGit({
97
+ 'rev-parse --git-common-dir': '\\\\server\\share\\Notifai\\.git\\',
98
+ 'rev-parse --git-dir': '\\\\server\\share\\Notifai\\.git\\worktrees\\topic\\',
99
+ 'rev-parse --show-toplevel': '\\\\server\\share\\trees\\topic\\',
100
+ 'rev-parse --abbrev-ref HEAD': 'topic',
101
+ }),
102
+ )
103
+ expect(context).toEqual({ project: 'notifai', branch: 'topic', worktree: 'topic' })
104
+ })
105
+
106
+ it('falls back to the cwd basename outside Git', () => {
107
+ expect(inferInvocationContext('/tmp/My Scratch/', fixtureGit({}))).toEqual({
108
+ project: 'my-scratch',
109
+ })
110
+ })
111
+
112
+ it('omits an unusable Project instead of inventing one', () => {
113
+ expect(inferInvocationContext('/tmp/项目', fixtureGit({}))).toEqual({ project: null })
114
+ })
115
+
116
+ it('omits branch and worktree on detached HEAD in an ordinary checkout', () => {
117
+ expect(
118
+ inferInvocationContext(
119
+ '/code/repo',
120
+ fixtureGit({
121
+ 'rev-parse --git-common-dir': '.git',
122
+ 'rev-parse --git-dir': '.git',
123
+ 'rev-parse --show-toplevel': '/code/repo',
124
+ 'rev-parse --abbrev-ref HEAD': 'HEAD',
125
+ }),
126
+ ),
127
+ ).toEqual({ project: 'repo' })
128
+ })
129
+
130
+ it('bounds branch and worktree display strings without leaking their parents', () => {
131
+ const longBranch = `feature/${'界'.repeat(140)}`
132
+ const longWorktree = `tree-${'x'.repeat(80)}`
133
+ const context = inferInvocationContext(
134
+ '/private/work/tree/src',
135
+ fixtureGit({
136
+ 'rev-parse --git-common-dir': '/private/main/repo/.git',
137
+ 'rev-parse --git-dir': '/private/main/repo/.git/worktrees/tree',
138
+ 'rev-parse --show-toplevel': `/private/work/${longWorktree}`,
139
+ 'rev-parse --abbrev-ref HEAD': longBranch,
140
+ }),
141
+ )
142
+ expect(Array.from(context.branch ?? '')).toHaveLength(128)
143
+ expect(Array.from(context.worktree ?? '')).toHaveLength(64)
144
+ expect(context.branch).toMatch(/…$/)
145
+ expect(context.worktree).toMatch(/…$/)
146
+ expect(JSON.stringify(context)).not.toContain('/private/')
147
+ })
148
+ })
149
+
150
+ describe('truncateContext', () => {
151
+ it('counts Unicode characters rather than UTF-16 code units', () => {
152
+ expect(truncateContext('A😀BC', 3)).toBe('A😀…')
153
+ })
154
+ })
155
+
156
+ describe('buildSourceContext', () => {
157
+ const invocation = { project: 'repo', branch: 'main', worktree: 'topic' }
158
+
159
+ it('applies flag, environment, then inferred precedence per session field', () => {
160
+ const built = buildSourceContext({
161
+ env: {
162
+ NOTIFAI_SESSION_ID: 'env-id',
163
+ NOTIFAI_SESSION_LABEL: 'Environment Label',
164
+ },
165
+ invocation,
166
+ sessionId: 'flag-id',
167
+ sessionLabel: 'Flag Label',
168
+ activeHarness: { harness: 'claude-code', sessionId: 'inferred-id' },
169
+ })
170
+ expect(built).toEqual({
171
+ ok: true,
172
+ source: {
173
+ session_id: 'flag-id',
174
+ session_label: 'Flag Label',
175
+ harness: 'claude-code',
176
+ branch: 'main',
177
+ worktree: 'topic',
178
+ },
179
+ })
180
+ })
181
+
182
+ it('uses environment values ahead of an inferred exact session', () => {
183
+ expect(
184
+ buildSourceContext({
185
+ env: {
186
+ NOTIFAI_SESSION_ID: 'env-id',
187
+ NOTIFAI_SESSION_LABEL: 'Environment Label',
188
+ },
189
+ invocation: { project: 'repo' },
190
+ activeHarness: { harness: 'codex', sessionId: 'inferred-id' },
191
+ }),
192
+ ).toEqual({
193
+ ok: true,
194
+ source: {
195
+ session_id: 'env-id',
196
+ session_label: 'Environment Label',
197
+ harness: 'codex',
198
+ },
199
+ })
200
+ })
201
+
202
+ it('generates a stable non-identifying label for an inferred exact session', () => {
203
+ const sessionId = 'opaque-thread-1234567890'
204
+ expect(
205
+ buildSourceContext({
206
+ env: {},
207
+ invocation: { project: 'repo', branch: 'main' },
208
+ activeHarness: { harness: 'opencode', sessionId },
209
+ }),
210
+ ).toEqual({
211
+ ok: true,
212
+ source: {
213
+ session_id: sessionId,
214
+ session_label: sessionLabelFromId(sessionId),
215
+ harness: 'opencode',
216
+ branch: 'main',
217
+ },
218
+ })
219
+ expect(sessionLabelFromId(sessionId)).not.toContain('1234567890')
220
+ })
221
+
222
+ it('does not emit a harness or label when no exact session id is available', () => {
223
+ expect(
224
+ buildSourceContext({
225
+ env: {},
226
+ invocation: { project: 'repo', branch: 'main' },
227
+ activeHarness: { harness: 'cursor' },
228
+ }),
229
+ ).toEqual({ ok: true, source: { branch: 'main' } })
230
+ })
231
+
232
+ it('ignores the deleted NOTIFAI_SESSION variable', () => {
233
+ expect(
234
+ buildSourceContext({
235
+ env: { NOTIFAI_SESSION: 'legacy-id' },
236
+ invocation: { project: 'repo' },
237
+ }),
238
+ ).toEqual({ ok: true })
239
+ })
240
+
241
+ it('rejects a display label without an exact session id', () => {
242
+ expect(
243
+ buildSourceContext({
244
+ env: {},
245
+ invocation: { project: 'repo' },
246
+ sessionLabel: 'Unbound Label',
247
+ }),
248
+ ).toEqual({
249
+ ok: false,
250
+ error: '--session-label (or NOTIFAI_SESSION_LABEL) needs an exact session id.',
251
+ })
252
+ })
253
+ })
@@ -0,0 +1,176 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import path from 'node:path'
3
+ import {
4
+ sessionLabelFromId,
5
+ type SourceContextT,
6
+ } from '@raidiant/notifai-protocol'
7
+ import type { Harness } from './harnesses.js'
8
+
9
+ export type GitCommand = (cwd: string, args: readonly string[]) => string | null
10
+
11
+ const runGit: GitCommand = (cwd, args) => {
12
+ try {
13
+ return execFileSync('git', ['-C', cwd, ...args], {
14
+ encoding: 'utf8',
15
+ stdio: ['ignore', 'pipe', 'ignore'],
16
+ timeout: 2_000,
17
+ windowsHide: true,
18
+ }).trim()
19
+ } catch {
20
+ return null
21
+ }
22
+ }
23
+
24
+ /** The useful, privacy-safe context inferred from one invocation directory. */
25
+ export interface InvocationContext {
26
+ project: string | null
27
+ branch?: string
28
+ worktree?: string
29
+ }
30
+
31
+ /** Contract-valid Project slug, or null when the name has no safe characters. */
32
+ export function projectSlugFrom(name: string): string | null {
33
+ const slug = name
34
+ .toLowerCase()
35
+ .replaceAll(/[^a-z0-9._-]+/g, '-')
36
+ .replaceAll(/^[^a-z0-9]+|[^a-z0-9._-]+$/g, '')
37
+ .slice(0, 64)
38
+ return slug.length > 0 && /^[a-z0-9]/.test(slug) ? slug : null
39
+ }
40
+
41
+ function usesWindowsPaths(...values: string[]): boolean {
42
+ return values.some((value) => /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value))
43
+ }
44
+
45
+ function pathApi(...values: string[]): typeof path.posix | typeof path.win32 {
46
+ return usesWindowsPaths(...values) ? path.win32 : path.posix
47
+ }
48
+
49
+ /** Basename that can be fixture-tested for POSIX, drive, and UNC paths on any OS. */
50
+ export function portableBasename(value: string): string {
51
+ const api = pathApi(value)
52
+ return api.basename(api.normalize(value))
53
+ }
54
+
55
+ function resolveGitPath(cwd: string, value: string): string {
56
+ const api = pathApi(cwd, value)
57
+ return api.isAbsolute(value) ? api.normalize(value) : api.resolve(cwd, value)
58
+ }
59
+
60
+ function samePath(left: string, right: string): boolean {
61
+ const api = pathApi(left, right)
62
+ const normalizedLeft = api.normalize(left)
63
+ const normalizedRight = api.normalize(right)
64
+ return api === path.win32
65
+ ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
66
+ : normalizedLeft === normalizedRight
67
+ }
68
+
69
+ /** Truncate a display string by Unicode characters and keep the ellipsis inside the bound. */
70
+ export function truncateContext(value: string, maxLength: number): string {
71
+ const characters = Array.from(value)
72
+ if (characters.length <= maxLength) return value
73
+ return `${characters.slice(0, Math.max(0, maxLength - 1)).join('')}…`
74
+ }
75
+
76
+ /**
77
+ * Infer stable Project identity plus branch/worktree display context.
78
+ *
79
+ * Linked worktrees share the parent of Git's common directory, while a non-Git
80
+ * directory falls back to its own basename. Only basenames and branch names are
81
+ * returned; no absolute path can leave this function.
82
+ */
83
+ export function inferInvocationContext(
84
+ cwd: string,
85
+ git: GitCommand = runGit,
86
+ ): InvocationContext {
87
+ const commonRaw = git(cwd, ['rev-parse', '--git-common-dir'])
88
+ if (commonRaw === null || commonRaw === '') {
89
+ return { project: projectSlugFrom(portableBasename(cwd)) }
90
+ }
91
+
92
+ const commonDir = resolveGitPath(cwd, commonRaw)
93
+ const api = pathApi(cwd, commonDir)
94
+ const gitDirRaw = git(cwd, ['rev-parse', '--git-dir'])
95
+ const topLevelRaw = git(cwd, ['rev-parse', '--show-toplevel'])
96
+ const linked =
97
+ gitDirRaw !== null &&
98
+ gitDirRaw !== '' &&
99
+ !samePath(resolveGitPath(cwd, gitDirRaw), commonDir)
100
+
101
+ // In an ordinary checkout the worktree root is the useful Project root. A
102
+ // linked worktree needs its shared repository identity instead: standard Git
103
+ // stores that in <main>/.git, while submodules and --separate-git-dir use a
104
+ // named common directory whose basename is the only stable, path-free label.
105
+ // Treating every common directory as `.git` would infer `modules` or `public`
106
+ // for submodules — an implementation detail, not the repository's identity.
107
+ const commonName = api.basename(commonDir)
108
+ const projectName = linked
109
+ ? commonName.toLowerCase() === '.git'
110
+ ? api.basename(api.dirname(commonDir))
111
+ : commonName
112
+ : topLevelRaw !== null && topLevelRaw !== ''
113
+ ? portableBasename(topLevelRaw)
114
+ : commonName.toLowerCase() === '.git'
115
+ ? api.basename(api.dirname(commonDir))
116
+ : commonName
117
+ const project = projectSlugFrom(projectName)
118
+
119
+ const branchRaw = git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
120
+ const branch =
121
+ branchRaw === null || branchRaw === '' || branchRaw === 'HEAD'
122
+ ? undefined
123
+ : truncateContext(branchRaw, 128)
124
+
125
+ const worktree =
126
+ linked && topLevelRaw !== null && topLevelRaw !== ''
127
+ ? truncateContext(portableBasename(topLevelRaw), 64)
128
+ : undefined
129
+
130
+ return {
131
+ project,
132
+ ...(branch !== undefined ? { branch } : {}),
133
+ ...(worktree !== undefined && worktree !== '' ? { worktree } : {}),
134
+ }
135
+ }
136
+
137
+ export interface SourceContextInput {
138
+ env: NodeJS.ProcessEnv
139
+ invocation: InvocationContext
140
+ sessionId?: string
141
+ sessionLabel?: string
142
+ activeHarness?: { harness: Harness; sessionId?: string }
143
+ }
144
+
145
+ export type SourceContextBuild =
146
+ | { ok: true; source?: SourceContextT }
147
+ | { ok: false; error: string }
148
+
149
+ /** Resolve per-field source precedence without fabricating session identity. */
150
+ export function buildSourceContext(input: SourceContextInput): SourceContextBuild {
151
+ const sessionId =
152
+ input.sessionId ?? input.env['NOTIFAI_SESSION_ID'] ?? input.activeHarness?.sessionId
153
+ const explicitLabel = input.sessionLabel ?? input.env['NOTIFAI_SESSION_LABEL']
154
+ if (explicitLabel !== undefined && (sessionId === undefined || sessionId === '')) {
155
+ return {
156
+ ok: false,
157
+ error: '--session-label (or NOTIFAI_SESSION_LABEL) needs an exact session id.',
158
+ }
159
+ }
160
+ if (sessionId === '') {
161
+ return { ok: false, error: '--session-id must not be empty.' }
162
+ }
163
+
164
+ const sessionLabel =
165
+ sessionId === undefined ? undefined : (explicitLabel ?? sessionLabelFromId(sessionId))
166
+ const source: SourceContextT = {
167
+ ...(sessionId !== undefined ? { session_id: sessionId } : {}),
168
+ ...(sessionLabel !== undefined ? { session_label: sessionLabel } : {}),
169
+ ...(sessionId !== undefined && input.activeHarness !== undefined
170
+ ? { harness: input.activeHarness.harness }
171
+ : {}),
172
+ ...(input.invocation.branch !== undefined ? { branch: input.invocation.branch } : {}),
173
+ ...(input.invocation.worktree !== undefined ? { worktree: input.invocation.worktree } : {}),
174
+ }
175
+ return Object.keys(source).length === 0 ? { ok: true } : { ok: true, source }
176
+ }
@@ -19,7 +19,10 @@ if (worker === undefined) {
19
19
  const maxBytes = Number(process.env['NOTIFAI_ROTATION_MAX_BYTES'])
20
20
  const maxFiles = Number(process.env['NOTIFAI_ROTATION_MAX_FILES'])
21
21
  writeFileSync(readyPath, 'ready')
22
- const startDeadline = Date.now() + 10_000
22
+ // The parent's absolute deadline, not a countdown of this worker's own:
23
+ // the barrier fails for everyone at one instant, so a worker that started
24
+ // early cannot walk out on a parent still waiting for its last sibling.
25
+ const startDeadline = Number(process.env['NOTIFAI_ROTATION_DEADLINE'])
23
26
  while (!existsSync(startPath)) {
24
27
  if (Date.now() >= startDeadline) throw new Error('rotation parent never released the barrier')
25
28
  await new Promise((resolve) => setTimeout(resolve, 2))
@@ -33,7 +36,10 @@ if (worker === undefined) {
33
36
  logger.info('cli.end', { worker, sequence, pad: 'p'.repeat(140) })
34
37
  await new Promise((resolve) => setTimeout(resolve, 1))
35
38
  }
36
- expect(logger.enabled).toBe(true)
39
+ // The sink switches itself off for the rest of a process the first time a
40
+ // write throws, so this is the whole point of the worker: every record it
41
+ // was given reached the shared file through the cross-process lock.
42
+ expect(logger.enabled, 'the shared-log sink switched itself off mid-run').toBe(true)
37
43
  })
38
44
  })
39
45
  }
@@ -35,22 +35,30 @@ function lines(env: NodeJS.ProcessEnv): LogRecord[] {
35
35
  .map((line) => JSON.parse(line) as LogRecord)
36
36
  }
37
37
 
38
- async function waitUntil(predicate: () => boolean, timeoutMs = 10_000): Promise<void> {
39
- const deadline = Date.now() + timeoutMs
38
+ async function waitUntil(predicate: () => boolean, deadline: number): Promise<void> {
40
39
  while (!predicate()) {
41
40
  if (Date.now() >= deadline) throw new Error('timed out waiting for rotation workers')
42
41
  await new Promise((resolve) => setTimeout(resolve, 5))
43
42
  }
44
43
  }
45
44
 
46
- function rotationWorker(
47
- env: NodeJS.ProcessEnv,
48
- worker: number,
49
- records: number,
50
- maxBytes: number,
51
- maxFiles: number,
52
- startPath: string,
53
- ): { readyPath: string; done: Promise<void> } {
45
+ interface RotationRun {
46
+ env: NodeJS.ProcessEnv
47
+ worker: number
48
+ records: number
49
+ maxBytes: number
50
+ maxFiles: number
51
+ startPath: string
52
+ /**
53
+ * When the rendezvous has failed, as an absolute instant every participant
54
+ * reads the same way. A per-side countdown would let the first worker to
55
+ * report ready abandon a parent that is still waiting for the last one.
56
+ */
57
+ deadline: number
58
+ }
59
+
60
+ function rotationWorker(run: RotationRun): { readyPath: string; done: Promise<void> } {
61
+ const { env, worker } = run
54
62
  const readyPath = path.join(env['XDG_STATE_HOME']!, `rotation-ready-${worker}`)
55
63
  const vitest = path.join(process.cwd(), 'node_modules', 'vitest', 'vitest.mjs')
56
64
  const child = spawn(
@@ -62,11 +70,12 @@ function rotationWorker(
62
70
  ...process.env,
63
71
  ...env,
64
72
  NOTIFAI_ROTATION_WORKER: String(worker),
65
- NOTIFAI_ROTATION_RECORDS: String(records),
66
- NOTIFAI_ROTATION_MAX_BYTES: String(maxBytes),
67
- NOTIFAI_ROTATION_MAX_FILES: String(maxFiles),
73
+ NOTIFAI_ROTATION_RECORDS: String(run.records),
74
+ NOTIFAI_ROTATION_MAX_BYTES: String(run.maxBytes),
75
+ NOTIFAI_ROTATION_MAX_FILES: String(run.maxFiles),
68
76
  NOTIFAI_ROTATION_READY: readyPath,
69
- NOTIFAI_ROTATION_START: startPath,
77
+ NOTIFAI_ROTATION_START: run.startPath,
78
+ NOTIFAI_ROTATION_DEADLINE: String(run.deadline),
70
79
  },
71
80
  stdio: ['ignore', 'pipe', 'pipe'],
72
81
  },
@@ -263,11 +272,15 @@ describe('rotation', () => {
263
272
  const maxBytes = 4_000
264
273
  const maxFiles = 100
265
274
  const startPath = path.join(env['XDG_STATE_HOME']!, 'rotation-start')
275
+ // Four cold Vitest processes have to reach the barrier before any of them
276
+ // writes, and a busy CI host schedules them far apart. The budget is shared,
277
+ // so it bounds the whole rendezvous rather than each side of it.
278
+ const deadline = Date.now() + 20_000
266
279
  const children = Array.from({ length: workers }, (_, worker) =>
267
- rotationWorker(env, worker, recordsPerWorker, maxBytes, maxFiles, startPath),
280
+ rotationWorker({ env, worker, records: recordsPerWorker, maxBytes, maxFiles, startPath, deadline }),
268
281
  )
269
282
 
270
- await waitUntil(() => children.every(({ readyPath }) => existsSync(readyPath)))
283
+ await waitUntil(() => children.every(({ readyPath }) => existsSync(readyPath)), deadline)
271
284
  writeFileSync(startPath, 'go')
272
285
  await Promise.all(children.map(({ done }) => done))
273
286
 
@@ -293,8 +306,10 @@ describe('rotation', () => {
293
306
  // name race may lose or duplicate one.
294
307
  expect(retainedRecords).toBe(workers * recordsPerWorker)
295
308
  expect(seen.size).toBe(workers * recordsPerWorker)
296
- expect([...seen.values()].every((count) => count === 1)).toBe(true)
297
- })
309
+ // Name the offending records: a bare boolean would report a duplication bug
310
+ // as "expected false to be true" and leave the next reader nothing to go on.
311
+ expect([...seen].filter(([, count]) => count !== 1)).toEqual([])
312
+ }, 90_000)
298
313
  })
299
314
 
300
315
  describe('failure isolation', () => {
package/src/logging.ts CHANGED
@@ -61,6 +61,17 @@ export const LOG_SCHEMA_VERSION = 1
61
61
  export { LOG_LEVELS, type LogLevel }
62
62
  export type RecordLevel = Exclude<LogLevel, 'off'>
63
63
 
64
+ /**
65
+ * The levels a record can actually carry.
66
+ *
67
+ * `off` is a setting that stops writing, never a severity anything is written
68
+ * at — so offering it as a retrieval filter accepted a query that could only
69
+ * ever match nothing, and then blamed the result on too narrow a search.
70
+ */
71
+ export const RECORD_LEVELS: readonly RecordLevel[] = LOG_LEVELS.filter(
72
+ (level): level is RecordLevel => level !== 'off',
73
+ )
74
+
64
75
  const LEVEL_RANK: Record<LogLevel, number> = { off: 0, error: 1, info: 2, debug: 3 }
65
76
 
66
77
  /**