@raidiant/notifai 10.1.1 → 10.1.2
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 +20 -0
- package/dist/commands-core.d.ts +1 -1
- package/dist/commands-init.js +9 -6
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.js +3 -0
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-skill.js +72 -54
- package/dist/commands-skill.js.map +1 -1
- package/dist/invocation-context.d.ts +1 -0
- package/dist/invocation-context.js +9 -1
- package/dist/invocation-context.js.map +1 -1
- package/dist/native-skills.d.ts +13 -3
- package/dist/native-skills.js +51 -12
- package/dist/native-skills.js.map +1 -1
- package/dist/node-floor.d.ts +2 -1
- package/dist/node-floor.js +9 -5
- package/dist/node-floor.js.map +1 -1
- package/dist/orca-session-title.d.ts +5 -4
- package/dist/orca-session-title.js +32 -19
- package/dist/orca-session-title.js.map +1 -1
- package/dist/platform.d.ts +11 -17
- package/dist/platform.js +20 -23
- package/dist/platform.js.map +1 -1
- package/dist/release.d.ts +5 -5
- package/dist/release.js +5 -5
- package/dist/setup-destinations.js +1 -1
- package/dist/setup-destinations.js.map +1 -1
- package/dist/skill-integrity.d.ts +49 -0
- package/dist/skill-integrity.js +154 -0
- package/dist/skill-integrity.js.map +1 -0
- package/dist/skill-source/manifest.json +16 -0
- package/dist/skill-source/notifai/SKILL.md +390 -0
- package/dist/skill-source/notifai/references/harness-setup.md +220 -0
- package/package.json +11 -14
- package/src/atomic-file.ts +0 -198
- package/src/claude-wake.test.ts +0 -321
- package/src/claude-wake.ts +0 -499
- package/src/cli-bin.test.ts +0 -176
- package/src/cli-bin.ts +0 -233
- package/src/cli-release.test.ts +0 -46
- package/src/cli-release.ts +0 -78
- package/src/cli-update-contract.ts +0 -9
- package/src/cli-update.test.ts +0 -125
- package/src/client.test.ts +0 -390
- package/src/client.ts +0 -352
- package/src/codex-wake.test.ts +0 -254
- package/src/codex-wake.ts +0 -301
- package/src/command-session.ts +0 -38
- package/src/commands-agent-sessions.ts +0 -72
- package/src/commands-auth.ts +0 -238
- package/src/commands-config.ts +0 -336
- package/src/commands-core.ts +0 -312
- package/src/commands-devices.ts +0 -180
- package/src/commands-doctor.ts +0 -1501
- package/src/commands-guidance.ts +0 -181
- package/src/commands-harness-context.ts +0 -207
- package/src/commands-hook-shape.ts +0 -69
- package/src/commands-hooks.ts +0 -2318
- package/src/commands-init.ts +0 -1055
- package/src/commands-io.ts +0 -108
- package/src/commands-logs.ts +0 -243
- package/src/commands-project.ts +0 -47
- package/src/commands-send-support.test.ts +0 -156
- package/src/commands-send-support.ts +0 -311
- package/src/commands-send.ts +0 -875
- package/src/commands-setup-proof.test.ts +0 -72
- package/src/commands-setup-proof.ts +0 -124
- package/src/commands-skill.test.ts +0 -44
- package/src/commands-skill.ts +0 -204
- package/src/commands-sounds.test.ts +0 -106
- package/src/commands-sounds.ts +0 -49
- package/src/commands-update.ts +0 -180
- package/src/commands.test.ts +0 -8297
- package/src/commands.ts +0 -77
- package/src/config-schema.test.ts +0 -153
- package/src/config-schema.ts +0 -349
- package/src/config.test.ts +0 -581
- package/src/config.ts +0 -475
- package/src/credentials.test.ts +0 -403
- package/src/credentials.ts +0 -403
- package/src/file-lock-process-worker.test.ts +0 -64
- package/src/file-lock.test.ts +0 -320
- package/src/file-lock.ts +0 -393
- package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
- package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
- package/src/generated-session-label.ts +0 -155
- package/src/guidance-content.ts +0 -278
- package/src/guidance-render.ts +0 -56
- package/src/guidance.test.ts +0 -327
- package/src/guidance.ts +0 -233
- package/src/harness-integration.test.ts +0 -294
- package/src/harnesses.test.ts +0 -149
- package/src/harnesses.ts +0 -164
- package/src/hook-adapter.test.ts +0 -496
- package/src/hook-adapter.ts +0 -424
- package/src/hook-input.test.ts +0 -39
- package/src/hook-input.ts +0 -72
- package/src/hooks.test.ts +0 -5824
- package/src/hooks.ts +0 -4011
- package/src/install-hooks.test.ts +0 -1614
- package/src/install-hooks.ts +0 -1501
- package/src/interactive.test.ts +0 -387
- package/src/interactive.ts +0 -858
- package/src/invocation-context.test.ts +0 -352
- package/src/invocation-context.ts +0 -197
- package/src/logging-process-worker.test.ts +0 -45
- package/src/logging.test.ts +0 -544
- package/src/logging.ts +0 -698
- package/src/main-run.ts +0 -45
- package/src/main.ts +0 -12
- package/src/native-skills.test.ts +0 -115
- package/src/native-skills.ts +0 -162
- package/src/node-floor.test.ts +0 -27
- package/src/node-floor.ts +0 -37
- package/src/onboarding-docs.test.ts +0 -25
- package/src/openclaw-plugin.ts +0 -409
- package/src/opencode-plugin.ts +0 -298
- package/src/orca-session-title.test.ts +0 -54
- package/src/orca-session-title.ts +0 -103
- package/src/pack-feedback-slice.test.ts +0 -212
- package/src/pack-feedback-slice.ts +0 -187
- package/src/platform.test.ts +0 -152
- package/src/platform.ts +0 -173
- package/src/program.test.ts +0 -317
- package/src/program.ts +0 -842
- package/src/project-enablement.test.ts +0 -55
- package/src/project-enablement.ts +0 -99
- package/src/question-caps.test.ts +0 -49
- package/src/question-settlement-process.ts +0 -47
- package/src/question-timing.ts +0 -56
- package/src/readiness.test.ts +0 -25
- package/src/readiness.ts +0 -212
- package/src/release.ts +0 -51
- package/src/send-attempts.test.ts +0 -143
- package/src/send-attempts.ts +0 -188
- package/src/send.test.ts +0 -28
- package/src/send.ts +0 -436
- package/src/session-activation.ts +0 -61
- package/src/session-end-log-lock-worker.test.ts +0 -35
- package/src/session-labels.test.ts +0 -490
- package/src/session-labels.ts +0 -474
- package/src/setup-destinations.test.ts +0 -41
- package/src/setup-destinations.ts +0 -50
- package/src/skill.test.ts +0 -426
- package/src/sound-ref.test.ts +0 -31
- package/src/sound-ref.ts +0 -34
- package/src/trust-policy.test.ts +0 -701
- package/src/ui/banner.ts +0 -95
- package/src/ui/config-view.test.ts +0 -90
- package/src/ui/config-view.ts +0 -138
- package/src/ui/help.test.ts +0 -16
- package/src/ui/help.ts +0 -128
- package/src/ui/theme.test.ts +0 -92
- package/src/ui/theme.ts +0 -265
- package/src/url-policy.ts +0 -399
- package/tsconfig.json +0 -10
|
@@ -1,352 +0,0 @@
|
|
|
1
|
-
import { mkdtempSync } from 'node:fs'
|
|
2
|
-
import os from 'node:os'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
import { describe, expect, it } from 'vitest'
|
|
5
|
-
import {
|
|
6
|
-
buildSourceContext,
|
|
7
|
-
inferInvocationContext,
|
|
8
|
-
portableBasename,
|
|
9
|
-
projectSlugFrom,
|
|
10
|
-
truncateContext,
|
|
11
|
-
type GitCommand,
|
|
12
|
-
} from './invocation-context.js'
|
|
13
|
-
|
|
14
|
-
function fixtureGit(values: Record<string, string | null>): GitCommand {
|
|
15
|
-
return (_cwd, args) => values[args.join(' ')] ?? null
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function stateEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
|
|
19
|
-
return {
|
|
20
|
-
XDG_STATE_HOME: mkdtempSync(path.join(os.tmpdir(), 'notifai-source-context-')),
|
|
21
|
-
...overrides,
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
describe('projectSlugFrom', () => {
|
|
26
|
-
it.each([
|
|
27
|
-
['My Project', 'my-project'],
|
|
28
|
-
['.Hidden Repo', 'hidden-repo'],
|
|
29
|
-
['API_v2.0', 'api_v2.0'],
|
|
30
|
-
['---', null],
|
|
31
|
-
['项目', null],
|
|
32
|
-
])('turns %s into %s', (name, expected) => {
|
|
33
|
-
expect(projectSlugFrom(name)).toBe(expected)
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
it('keeps the Project identifier inside its 64-character bound', () => {
|
|
37
|
-
expect(projectSlugFrom(`repo-${'x'.repeat(100)}`)).toHaveLength(64)
|
|
38
|
-
})
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
describe('portableBasename', () => {
|
|
42
|
-
it.each([
|
|
43
|
-
['/Users/person/code/notifai/', 'notifai'],
|
|
44
|
-
['C:\\Users\\Person\\code\\notifai\\', 'notifai'],
|
|
45
|
-
['\\\\server\\share\\worktrees\\release\\', 'release'],
|
|
46
|
-
])('extracts the basename from %s', (value, expected) => {
|
|
47
|
-
expect(portableBasename(value)).toBe(expected)
|
|
48
|
-
})
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
describe('inferInvocationContext', () => {
|
|
52
|
-
it('uses the common-directory parent for a repository from a nested cwd', () => {
|
|
53
|
-
const context = inferInvocationContext(
|
|
54
|
-
'/code/Signal Garden/packages/api',
|
|
55
|
-
fixtureGit({
|
|
56
|
-
'rev-parse --git-common-dir': '../../.git',
|
|
57
|
-
'rev-parse --git-dir': '../../.git',
|
|
58
|
-
'rev-parse --show-toplevel': '/code/Signal Garden',
|
|
59
|
-
'rev-parse --abbrev-ref HEAD': 'feature/alerts',
|
|
60
|
-
}),
|
|
61
|
-
)
|
|
62
|
-
expect(context).toEqual({ project: 'signal-garden', branch: 'feature/alerts' })
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('keeps one Project across linked worktrees and exposes only the worktree basename', () => {
|
|
66
|
-
const context = inferInvocationContext(
|
|
67
|
-
'/Users/name/worktrees/notifai-release/apps/cli',
|
|
68
|
-
fixtureGit({
|
|
69
|
-
'rev-parse --git-common-dir': '/Users/name/code/notifai/.git',
|
|
70
|
-
'rev-parse --git-dir': '/Users/name/code/notifai/.git/worktrees/notifai-release',
|
|
71
|
-
'rev-parse --show-toplevel': '/Users/name/worktrees/notifai-release/',
|
|
72
|
-
'rev-parse --abbrev-ref HEAD': 'release/content',
|
|
73
|
-
}),
|
|
74
|
-
)
|
|
75
|
-
expect(context).toEqual({
|
|
76
|
-
project: 'notifai',
|
|
77
|
-
branch: 'release/content',
|
|
78
|
-
worktree: 'notifai-release',
|
|
79
|
-
})
|
|
80
|
-
expect(JSON.stringify(context)).not.toContain('/Users/name')
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
it('handles drive-letter paths and compares them case-insensitively', () => {
|
|
84
|
-
const context = inferInvocationContext(
|
|
85
|
-
'D:\\Worktrees\\Alerts\\src',
|
|
86
|
-
fixtureGit({
|
|
87
|
-
'rev-parse --git-common-dir': 'C:\\Code\\Notifai\\.git\\',
|
|
88
|
-
'rev-parse --git-dir': 'c:\\code\\notifai\\.git\\worktrees\\Alerts',
|
|
89
|
-
'rev-parse --show-toplevel': 'D:\\Worktrees\\Alerts\\',
|
|
90
|
-
'rev-parse --abbrev-ref HEAD': 'feature/windows',
|
|
91
|
-
}),
|
|
92
|
-
)
|
|
93
|
-
expect(context).toEqual({
|
|
94
|
-
project: 'notifai',
|
|
95
|
-
branch: 'feature/windows',
|
|
96
|
-
worktree: 'Alerts',
|
|
97
|
-
})
|
|
98
|
-
expect(JSON.stringify(context)).not.toContain('D:\\Worktrees')
|
|
99
|
-
expect(JSON.stringify(context)).not.toContain('C:\\Code')
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
it('handles a UNC common directory and trailing separators', () => {
|
|
103
|
-
const context = inferInvocationContext(
|
|
104
|
-
'\\\\server\\share\\trees\\topic\\src',
|
|
105
|
-
fixtureGit({
|
|
106
|
-
'rev-parse --git-common-dir': '\\\\server\\share\\Notifai\\.git\\',
|
|
107
|
-
'rev-parse --git-dir': '\\\\server\\share\\Notifai\\.git\\worktrees\\topic\\',
|
|
108
|
-
'rev-parse --show-toplevel': '\\\\server\\share\\trees\\topic\\',
|
|
109
|
-
'rev-parse --abbrev-ref HEAD': 'topic',
|
|
110
|
-
}),
|
|
111
|
-
)
|
|
112
|
-
expect(context).toEqual({ project: 'notifai', branch: 'topic', worktree: 'topic' })
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
it('falls back to the cwd basename outside Git', () => {
|
|
116
|
-
expect(inferInvocationContext('/tmp/My Scratch/', fixtureGit({}))).toEqual({
|
|
117
|
-
project: 'my-scratch',
|
|
118
|
-
})
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
it('omits an unusable Project instead of inventing one', () => {
|
|
122
|
-
expect(inferInvocationContext('/tmp/项目', fixtureGit({}))).toEqual({ project: null })
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
it('omits branch and worktree on detached HEAD in an ordinary checkout', () => {
|
|
126
|
-
expect(
|
|
127
|
-
inferInvocationContext(
|
|
128
|
-
'/code/repo',
|
|
129
|
-
fixtureGit({
|
|
130
|
-
'rev-parse --git-common-dir': '.git',
|
|
131
|
-
'rev-parse --git-dir': '.git',
|
|
132
|
-
'rev-parse --show-toplevel': '/code/repo',
|
|
133
|
-
'rev-parse --abbrev-ref HEAD': 'HEAD',
|
|
134
|
-
}),
|
|
135
|
-
),
|
|
136
|
-
).toEqual({ project: 'repo' })
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
it('bounds branch and worktree display strings without leaking their parents', () => {
|
|
140
|
-
const longBranch = `feature/${'界'.repeat(140)}`
|
|
141
|
-
const longWorktree = `tree-${'x'.repeat(80)}`
|
|
142
|
-
const context = inferInvocationContext(
|
|
143
|
-
'/private/work/tree/src',
|
|
144
|
-
fixtureGit({
|
|
145
|
-
'rev-parse --git-common-dir': '/private/main/repo/.git',
|
|
146
|
-
'rev-parse --git-dir': '/private/main/repo/.git/worktrees/tree',
|
|
147
|
-
'rev-parse --show-toplevel': `/private/work/${longWorktree}`,
|
|
148
|
-
'rev-parse --abbrev-ref HEAD': longBranch,
|
|
149
|
-
}),
|
|
150
|
-
)
|
|
151
|
-
expect(Array.from(context.branch ?? '')).toHaveLength(128)
|
|
152
|
-
expect(Array.from(context.worktree ?? '')).toHaveLength(64)
|
|
153
|
-
expect(context.branch).toMatch(/…$/)
|
|
154
|
-
expect(context.worktree).toMatch(/…$/)
|
|
155
|
-
expect(JSON.stringify(context)).not.toContain('/private/')
|
|
156
|
-
})
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
describe('truncateContext', () => {
|
|
160
|
-
it('counts Unicode characters rather than UTF-16 code units', () => {
|
|
161
|
-
expect(truncateContext('A😀BC', 3)).toBe('A😀…')
|
|
162
|
-
})
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
describe('buildSourceContext', () => {
|
|
166
|
-
const invocation = { project: 'repo', branch: 'main', worktree: 'topic' }
|
|
167
|
-
const now = new Date(2026, 7, 20, 14, 5).getTime()
|
|
168
|
-
|
|
169
|
-
it('applies flag, environment, then inferred precedence per session field', () => {
|
|
170
|
-
const built = buildSourceContext({
|
|
171
|
-
env: stateEnv({
|
|
172
|
-
NOTIFAI_SESSION_ID: 'env-id',
|
|
173
|
-
NOTIFAI_SESSION_LABEL: 'Environment Label',
|
|
174
|
-
}),
|
|
175
|
-
invocation,
|
|
176
|
-
sessionId: 'flag-id',
|
|
177
|
-
sessionLabel: 'Flag Label',
|
|
178
|
-
activeHarness: { harness: 'claude-code', sessionId: 'flag-id' },
|
|
179
|
-
now,
|
|
180
|
-
})
|
|
181
|
-
expect(built).toEqual({
|
|
182
|
-
ok: true,
|
|
183
|
-
source: {
|
|
184
|
-
session_id: 'flag-id',
|
|
185
|
-
session_label: 'Flag Label',
|
|
186
|
-
harness: 'claude-code',
|
|
187
|
-
branch: 'main',
|
|
188
|
-
worktree: 'topic',
|
|
189
|
-
},
|
|
190
|
-
})
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
it('uses environment values ahead of an inferred exact session', () => {
|
|
194
|
-
expect(
|
|
195
|
-
buildSourceContext({
|
|
196
|
-
env: stateEnv({
|
|
197
|
-
NOTIFAI_SESSION_ID: 'env-id',
|
|
198
|
-
NOTIFAI_SESSION_LABEL: 'Environment Label',
|
|
199
|
-
}),
|
|
200
|
-
invocation: { project: 'repo' },
|
|
201
|
-
activeHarness: { harness: 'codex', sessionId: 'env-id' },
|
|
202
|
-
now,
|
|
203
|
-
}),
|
|
204
|
-
).toEqual({
|
|
205
|
-
ok: true,
|
|
206
|
-
source: {
|
|
207
|
-
session_id: 'env-id',
|
|
208
|
-
session_label: 'Environment Label',
|
|
209
|
-
harness: 'codex',
|
|
210
|
-
},
|
|
211
|
-
})
|
|
212
|
-
})
|
|
213
|
-
|
|
214
|
-
it('uses and freezes a trusted harness-native title', () => {
|
|
215
|
-
const env = stateEnv()
|
|
216
|
-
const sessionId = 'opencode-thread'
|
|
217
|
-
expect(
|
|
218
|
-
buildSourceContext({
|
|
219
|
-
env,
|
|
220
|
-
invocation: { project: 'repo', branch: 'main' },
|
|
221
|
-
activeHarness: {
|
|
222
|
-
harness: 'opencode',
|
|
223
|
-
sessionId,
|
|
224
|
-
sessionLabel: 'Semantic session names',
|
|
225
|
-
},
|
|
226
|
-
now,
|
|
227
|
-
}),
|
|
228
|
-
).toEqual({
|
|
229
|
-
ok: true,
|
|
230
|
-
source: {
|
|
231
|
-
session_id: sessionId,
|
|
232
|
-
session_label: 'Semantic session names',
|
|
233
|
-
harness: 'opencode',
|
|
234
|
-
branch: 'main',
|
|
235
|
-
},
|
|
236
|
-
})
|
|
237
|
-
expect(
|
|
238
|
-
buildSourceContext({
|
|
239
|
-
env,
|
|
240
|
-
invocation: { project: 'repo' },
|
|
241
|
-
activeHarness: { harness: 'opencode', sessionId, sessionLabel: 'Changed title' },
|
|
242
|
-
now: now + 1_000,
|
|
243
|
-
}),
|
|
244
|
-
).toEqual({
|
|
245
|
-
ok: true,
|
|
246
|
-
source: {
|
|
247
|
-
session_id: sessionId,
|
|
248
|
-
session_label: 'Semantic session names',
|
|
249
|
-
harness: 'opencode',
|
|
250
|
-
},
|
|
251
|
-
})
|
|
252
|
-
})
|
|
253
|
-
|
|
254
|
-
it('does not freeze an OpenCode placeholder while its semantic title is pending', () => {
|
|
255
|
-
expect(
|
|
256
|
-
buildSourceContext({
|
|
257
|
-
env: stateEnv(),
|
|
258
|
-
invocation: { project: 'repo' },
|
|
259
|
-
activeHarness: {
|
|
260
|
-
harness: 'opencode',
|
|
261
|
-
sessionId: 'pending-session',
|
|
262
|
-
sessionLabelPending: true,
|
|
263
|
-
},
|
|
264
|
-
now,
|
|
265
|
-
}),
|
|
266
|
-
).toEqual({
|
|
267
|
-
ok: false,
|
|
268
|
-
error:
|
|
269
|
-
"OpenCode is still generating this session's title; retry shortly or pass --session-label.",
|
|
270
|
-
})
|
|
271
|
-
})
|
|
272
|
-
|
|
273
|
-
it('uses a stable generated fallback when no semantic name exists', () => {
|
|
274
|
-
const env = stateEnv()
|
|
275
|
-
const sessionId = 'opaque-thread-1234567890'
|
|
276
|
-
const expected = {
|
|
277
|
-
ok: true,
|
|
278
|
-
source: {
|
|
279
|
-
session_id: sessionId,
|
|
280
|
-
session_label: 'Ivory Koala',
|
|
281
|
-
harness: 'opencode' as const,
|
|
282
|
-
branch: 'main',
|
|
283
|
-
},
|
|
284
|
-
}
|
|
285
|
-
expect(
|
|
286
|
-
buildSourceContext({
|
|
287
|
-
env,
|
|
288
|
-
invocation: { project: 'repo', branch: 'main' },
|
|
289
|
-
activeHarness: { harness: 'opencode', sessionId },
|
|
290
|
-
now,
|
|
291
|
-
}),
|
|
292
|
-
).toEqual(expected)
|
|
293
|
-
expect(expected.source.session_label).not.toContain('1234567890')
|
|
294
|
-
})
|
|
295
|
-
|
|
296
|
-
it('does not borrow harness identity or title for an explicit different session id', () => {
|
|
297
|
-
expect(
|
|
298
|
-
buildSourceContext({
|
|
299
|
-
env: stateEnv(),
|
|
300
|
-
invocation: { project: 'repo' },
|
|
301
|
-
sessionId: 'other-session',
|
|
302
|
-
activeHarness: {
|
|
303
|
-
harness: 'claude-code',
|
|
304
|
-
sessionId: 'current-session',
|
|
305
|
-
sessionLabel: 'Current work',
|
|
306
|
-
},
|
|
307
|
-
now,
|
|
308
|
-
}),
|
|
309
|
-
).toEqual({
|
|
310
|
-
ok: true,
|
|
311
|
-
source: {
|
|
312
|
-
session_id: 'other-session',
|
|
313
|
-
session_label: 'Golden Lynx',
|
|
314
|
-
},
|
|
315
|
-
})
|
|
316
|
-
})
|
|
317
|
-
|
|
318
|
-
it('does not emit a harness or label when no exact session id is available', () => {
|
|
319
|
-
expect(
|
|
320
|
-
buildSourceContext({
|
|
321
|
-
env: stateEnv(),
|
|
322
|
-
invocation: { project: 'repo', branch: 'main' },
|
|
323
|
-
activeHarness: { harness: 'cursor' },
|
|
324
|
-
now,
|
|
325
|
-
}),
|
|
326
|
-
).toEqual({ ok: true, source: { branch: 'main' } })
|
|
327
|
-
})
|
|
328
|
-
|
|
329
|
-
it('ignores the deleted NOTIFAI_SESSION variable', () => {
|
|
330
|
-
expect(
|
|
331
|
-
buildSourceContext({
|
|
332
|
-
env: stateEnv({ NOTIFAI_SESSION: 'legacy-id' }),
|
|
333
|
-
invocation: { project: 'repo' },
|
|
334
|
-
now,
|
|
335
|
-
}),
|
|
336
|
-
).toEqual({ ok: true })
|
|
337
|
-
})
|
|
338
|
-
|
|
339
|
-
it('rejects a display label without an exact session id', () => {
|
|
340
|
-
expect(
|
|
341
|
-
buildSourceContext({
|
|
342
|
-
env: stateEnv(),
|
|
343
|
-
invocation: { project: 'repo' },
|
|
344
|
-
sessionLabel: 'Unbound Label',
|
|
345
|
-
now,
|
|
346
|
-
}),
|
|
347
|
-
).toEqual({
|
|
348
|
-
ok: false,
|
|
349
|
-
error: '--session-label (or NOTIFAI_SESSION_LABEL) needs an exact Agent Session id.',
|
|
350
|
-
})
|
|
351
|
-
})
|
|
352
|
-
})
|
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process'
|
|
2
|
-
import path from 'node:path'
|
|
3
|
-
import { type SourceContextT } from '@raidiant/notifai-protocol'
|
|
4
|
-
import type { SourceContextHarness } from './harnesses.js'
|
|
5
|
-
import { resolveSessionLabel } from './session-labels.js'
|
|
6
|
-
|
|
7
|
-
export type GitCommand = (cwd: string, args: readonly string[]) => string | null
|
|
8
|
-
|
|
9
|
-
const runGit: GitCommand = (cwd, args) => {
|
|
10
|
-
try {
|
|
11
|
-
return execFileSync('git', ['-C', cwd, ...args], {
|
|
12
|
-
encoding: 'utf8',
|
|
13
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
14
|
-
timeout: 2_000,
|
|
15
|
-
windowsHide: true,
|
|
16
|
-
}).trim()
|
|
17
|
-
} catch {
|
|
18
|
-
return null
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** The useful, privacy-safe context inferred from one invocation directory. */
|
|
23
|
-
export interface InvocationContext {
|
|
24
|
-
project: string | null
|
|
25
|
-
branch?: string
|
|
26
|
-
worktree?: string
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Contract-valid Project slug, or null when the name has no safe characters. */
|
|
30
|
-
export function projectSlugFrom(name: string): string | null {
|
|
31
|
-
const slug = name
|
|
32
|
-
.toLowerCase()
|
|
33
|
-
.replaceAll(/[^a-z0-9._-]+/g, '-')
|
|
34
|
-
.replaceAll(/^[^a-z0-9]+|[^a-z0-9._-]+$/g, '')
|
|
35
|
-
.slice(0, 64)
|
|
36
|
-
return slug.length > 0 && /^[a-z0-9]/.test(slug) ? slug : null
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function usesWindowsPaths(...values: string[]): boolean {
|
|
40
|
-
return values.some((value) => /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value))
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function pathApi(...values: string[]): typeof path.posix | typeof path.win32 {
|
|
44
|
-
return usesWindowsPaths(...values) ? path.win32 : path.posix
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Basename that can be fixture-tested for POSIX, drive, and UNC paths on any OS. */
|
|
48
|
-
export function portableBasename(value: string): string {
|
|
49
|
-
const api = pathApi(value)
|
|
50
|
-
return api.basename(api.normalize(value))
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function resolveGitPath(cwd: string, value: string): string {
|
|
54
|
-
const api = pathApi(cwd, value)
|
|
55
|
-
return api.isAbsolute(value) ? api.normalize(value) : api.resolve(cwd, value)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function samePath(left: string, right: string): boolean {
|
|
59
|
-
const api = pathApi(left, right)
|
|
60
|
-
const normalizedLeft = api.normalize(left)
|
|
61
|
-
const normalizedRight = api.normalize(right)
|
|
62
|
-
return api === path.win32
|
|
63
|
-
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
|
64
|
-
: normalizedLeft === normalizedRight
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Truncate a display string by Unicode characters and keep the ellipsis inside the bound. */
|
|
68
|
-
export function truncateContext(value: string, maxLength: number): string {
|
|
69
|
-
const characters = Array.from(value)
|
|
70
|
-
if (characters.length <= maxLength) return value
|
|
71
|
-
return `${characters.slice(0, Math.max(0, maxLength - 1)).join('')}…`
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Infer stable Project identity plus branch/worktree display context.
|
|
76
|
-
*
|
|
77
|
-
* Linked worktrees share the parent of Git's common directory, while a non-Git
|
|
78
|
-
* directory falls back to its own basename. Only basenames and branch names are
|
|
79
|
-
* returned; no absolute path can leave this function.
|
|
80
|
-
*/
|
|
81
|
-
export function inferInvocationContext(
|
|
82
|
-
cwd: string,
|
|
83
|
-
git: GitCommand = runGit,
|
|
84
|
-
): InvocationContext {
|
|
85
|
-
const commonRaw = git(cwd, ['rev-parse', '--git-common-dir'])
|
|
86
|
-
if (commonRaw === null || commonRaw === '') {
|
|
87
|
-
return { project: projectSlugFrom(portableBasename(cwd)) }
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const commonDir = resolveGitPath(cwd, commonRaw)
|
|
91
|
-
const api = pathApi(cwd, commonDir)
|
|
92
|
-
const gitDirRaw = git(cwd, ['rev-parse', '--git-dir'])
|
|
93
|
-
const topLevelRaw = git(cwd, ['rev-parse', '--show-toplevel'])
|
|
94
|
-
const linked =
|
|
95
|
-
gitDirRaw !== null &&
|
|
96
|
-
gitDirRaw !== '' &&
|
|
97
|
-
!samePath(resolveGitPath(cwd, gitDirRaw), commonDir)
|
|
98
|
-
|
|
99
|
-
// In an ordinary checkout the worktree root is the useful Project root. A
|
|
100
|
-
// linked worktree needs its shared repository identity instead: standard Git
|
|
101
|
-
// stores that in <main>/.git, while submodules and --separate-git-dir use a
|
|
102
|
-
// named common directory whose basename is the only stable, path-free label.
|
|
103
|
-
// Treating every common directory as `.git` would infer `modules` or `public`
|
|
104
|
-
// for submodules — an implementation detail, not the repository's identity.
|
|
105
|
-
const commonName = api.basename(commonDir)
|
|
106
|
-
const projectName = linked
|
|
107
|
-
? commonName.toLowerCase() === '.git'
|
|
108
|
-
? api.basename(api.dirname(commonDir))
|
|
109
|
-
: commonName
|
|
110
|
-
: topLevelRaw !== null && topLevelRaw !== ''
|
|
111
|
-
? portableBasename(topLevelRaw)
|
|
112
|
-
: commonName.toLowerCase() === '.git'
|
|
113
|
-
? api.basename(api.dirname(commonDir))
|
|
114
|
-
: commonName
|
|
115
|
-
const project = projectSlugFrom(projectName)
|
|
116
|
-
|
|
117
|
-
const branchRaw = git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
118
|
-
const branch =
|
|
119
|
-
branchRaw === null || branchRaw === '' || branchRaw === 'HEAD'
|
|
120
|
-
? undefined
|
|
121
|
-
: truncateContext(branchRaw, 128)
|
|
122
|
-
|
|
123
|
-
const worktree =
|
|
124
|
-
linked && topLevelRaw !== null && topLevelRaw !== ''
|
|
125
|
-
? truncateContext(portableBasename(topLevelRaw), 64)
|
|
126
|
-
: undefined
|
|
127
|
-
|
|
128
|
-
return {
|
|
129
|
-
project,
|
|
130
|
-
...(branch !== undefined ? { branch } : {}),
|
|
131
|
-
...(worktree !== undefined && worktree !== '' ? { worktree } : {}),
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
export interface SourceContextInput {
|
|
136
|
-
env: NodeJS.ProcessEnv
|
|
137
|
-
invocation: InvocationContext
|
|
138
|
-
sessionId?: string
|
|
139
|
-
sessionLabel?: string
|
|
140
|
-
activeHarness?: {
|
|
141
|
-
harness: SourceContextHarness
|
|
142
|
-
sessionId?: string
|
|
143
|
-
sessionLabel?: string
|
|
144
|
-
sessionLabelPending?: boolean
|
|
145
|
-
}
|
|
146
|
-
now?: number
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export type SourceContextBuild =
|
|
150
|
-
| { ok: true; source?: SourceContextT }
|
|
151
|
-
| { ok: false; error: string }
|
|
152
|
-
|
|
153
|
-
/** Resolve per-field Source Context precedence without fabricating Agent Session identity. */
|
|
154
|
-
export function buildSourceContext(input: SourceContextInput): SourceContextBuild {
|
|
155
|
-
const sessionId =
|
|
156
|
-
input.sessionId ?? input.env['NOTIFAI_SESSION_ID'] ?? input.activeHarness?.sessionId
|
|
157
|
-
const explicitLabel = input.sessionLabel ?? input.env['NOTIFAI_SESSION_LABEL']
|
|
158
|
-
if (explicitLabel !== undefined && (sessionId === undefined || sessionId === '')) {
|
|
159
|
-
return {
|
|
160
|
-
ok: false,
|
|
161
|
-
error: '--session-label (or NOTIFAI_SESSION_LABEL) needs an exact Agent Session id.',
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
if (sessionId === '') {
|
|
165
|
-
return { ok: false, error: '--session-id must not be empty.' }
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const activeOwnsSession =
|
|
169
|
-
sessionId !== undefined && input.activeHarness?.sessionId === sessionId
|
|
170
|
-
const harness = activeOwnsSession ? input.activeHarness?.harness : undefined
|
|
171
|
-
const label =
|
|
172
|
-
sessionId === undefined
|
|
173
|
-
? undefined
|
|
174
|
-
: resolveSessionLabel({
|
|
175
|
-
env: input.env,
|
|
176
|
-
sessionId,
|
|
177
|
-
...(harness === undefined ? {} : { harness }),
|
|
178
|
-
...(explicitLabel === undefined ? {} : { explicitLabel }),
|
|
179
|
-
...(activeOwnsSession && input.activeHarness?.sessionLabel !== undefined
|
|
180
|
-
? { harnessLabel: input.activeHarness.sessionLabel }
|
|
181
|
-
: {}),
|
|
182
|
-
...(activeOwnsSession && input.activeHarness?.sessionLabelPending === true
|
|
183
|
-
? { harnessLabelPending: true }
|
|
184
|
-
: {}),
|
|
185
|
-
...(input.now === undefined ? {} : { now: input.now }),
|
|
186
|
-
})
|
|
187
|
-
if (label !== undefined && !label.ok) return label
|
|
188
|
-
|
|
189
|
-
const source: SourceContextT = {
|
|
190
|
-
...(sessionId !== undefined ? { session_id: sessionId } : {}),
|
|
191
|
-
...(label !== undefined ? { session_label: label.label } : {}),
|
|
192
|
-
...(harness !== undefined ? { harness } : {}),
|
|
193
|
-
...(input.invocation.branch !== undefined ? { branch: input.invocation.branch } : {}),
|
|
194
|
-
...(input.invocation.worktree !== undefined ? { worktree: input.invocation.worktree } : {}),
|
|
195
|
-
}
|
|
196
|
-
return Object.keys(source).length === 0 ? { ok: true } : { ok: true, source }
|
|
197
|
-
}
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { existsSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { describe, expect, it } from 'vitest'
|
|
3
|
-
import { createLogger } from './logging.js'
|
|
4
|
-
|
|
5
|
-
const worker = process.env['NOTIFAI_ROTATION_WORKER']
|
|
6
|
-
|
|
7
|
-
if (worker === undefined) {
|
|
8
|
-
describe('rotation process worker', () => {
|
|
9
|
-
it('stays dormant outside the concurrency regression', () => {
|
|
10
|
-
expect(worker).toBeUndefined()
|
|
11
|
-
})
|
|
12
|
-
})
|
|
13
|
-
} else {
|
|
14
|
-
describe('rotation process worker', () => {
|
|
15
|
-
it('writes one share of an overlapping log stream', async () => {
|
|
16
|
-
const readyPath = process.env['NOTIFAI_ROTATION_READY']!
|
|
17
|
-
const startPath = process.env['NOTIFAI_ROTATION_START']!
|
|
18
|
-
const records = Number(process.env['NOTIFAI_ROTATION_RECORDS'])
|
|
19
|
-
const maxBytes = Number(process.env['NOTIFAI_ROTATION_MAX_BYTES'])
|
|
20
|
-
const maxFiles = Number(process.env['NOTIFAI_ROTATION_MAX_FILES'])
|
|
21
|
-
writeFileSync(readyPath, 'ready')
|
|
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'])
|
|
26
|
-
while (!existsSync(startPath)) {
|
|
27
|
-
if (Date.now() >= startDeadline) throw new Error('rotation parent never released the barrier')
|
|
28
|
-
await new Promise((resolve) => setTimeout(resolve, 2))
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const logger = createLogger({
|
|
32
|
-
runId: `r_worker_${worker}`,
|
|
33
|
-
settings: { maxBytes, maxFiles },
|
|
34
|
-
})
|
|
35
|
-
for (let sequence = 0; sequence < records; sequence += 1) {
|
|
36
|
-
logger.info('cli.end', { worker, sequence, pad: 'p'.repeat(140) })
|
|
37
|
-
await new Promise((resolve) => setTimeout(resolve, 1))
|
|
38
|
-
}
|
|
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)
|
|
43
|
-
})
|
|
44
|
-
})
|
|
45
|
-
}
|