@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.
- package/CHANGELOG.md +73 -0
- package/README.md +12 -4
- package/dist/commands.d.ts +27 -10
- package/dist/commands.js +429 -152
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +20 -11
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +3 -1
- package/dist/config.js +12 -1
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +6 -1
- package/dist/file-lock.js +55 -9
- package/dist/file-lock.js.map +1 -1
- package/dist/harnesses.d.ts +1 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +84 -12
- package/dist/hooks.js +433 -125
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +91 -14
- package/dist/install-hooks.js +226 -34
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/invocation-context.d.ts +42 -0
- package/dist/invocation-context.js +130 -0
- package/dist/invocation-context.js.map +1 -0
- package/dist/logging.d.ts +8 -0
- package/dist/logging.js +8 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +84 -49
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +24 -162
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/send.d.ts +38 -7
- package/dist/send.js +97 -41
- package/dist/send.js.map +1 -1
- package/dist/ui/help.d.ts +0 -1
- package/dist/ui/help.js +6 -3
- package/dist/ui/help.js.map +1 -1
- package/dist/ui/theme.d.ts +0 -15
- package/dist/ui/theme.js +2 -18
- package/dist/ui/theme.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +501 -63
- package/src/commands.ts +547 -187
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +22 -11
- package/src/config.test.ts +178 -18
- package/src/config.ts +14 -1
- package/src/file-lock.test.ts +41 -0
- package/src/file-lock.ts +59 -9
- package/src/harnesses.test.ts +37 -29
- package/src/harnesses.ts +1 -4
- package/src/hooks.test.ts +500 -58
- package/src/hooks.ts +523 -169
- package/src/install-hooks.test.ts +232 -12
- package/src/install-hooks.ts +241 -37
- package/src/interactive.ts +1 -1
- package/src/invocation-context.test.ts +253 -0
- package/src/invocation-context.ts +176 -0
- package/src/logging-process-worker.test.ts +8 -2
- package/src/logging.test.ts +33 -18
- package/src/logging.ts +11 -0
- package/src/main.ts +119 -54
- package/src/opencode-plugin.ts +24 -164
- package/src/question-caps.test.ts +49 -0
- package/src/send.ts +144 -50
- package/src/skill.test.ts +197 -120
- package/src/ui/help.ts +8 -3
- package/src/ui/theme.ts +2 -16
package/src/interactive.ts
CHANGED
|
@@ -132,7 +132,7 @@ export function routingHookActions(input: {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
options.push(
|
|
135
|
-
{ value: 'settings', label: 'Change
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/logging.test.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
47
|
-
env: NodeJS.ProcessEnv
|
|
48
|
-
worker: number
|
|
49
|
-
records: number
|
|
50
|
-
maxBytes: number
|
|
51
|
-
maxFiles: number
|
|
52
|
-
startPath: string
|
|
53
|
-
|
|
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
|
-
|
|
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
|
/**
|