@raidiant/notifai 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -4
- package/dist/atomic-file.d.ts +4 -0
- package/dist/atomic-file.js +60 -10
- package/dist/atomic-file.js.map +1 -1
- package/dist/client.d.ts +25 -2
- package/dist/client.js +45 -1
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +56 -7
- package/dist/commands.js +980 -318
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +46 -8
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +38 -3
- package/dist/config.js +61 -14
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +26 -0
- package/dist/file-lock.js +268 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/harnesses.d.ts +28 -0
- package/dist/harnesses.js +40 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/hook-adapter.d.ts +27 -0
- package/dist/hook-adapter.js +188 -0
- package/dist/hook-adapter.js.map +1 -0
- package/dist/hook-input.d.ts +10 -0
- package/dist/hook-input.js +64 -0
- package/dist/hook-input.js.map +1 -0
- package/dist/hooks.d.ts +66 -10
- package/dist/hooks.js +751 -276
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +40 -16
- package/dist/install-hooks.js +217 -73
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +3 -3
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +142 -0
- package/dist/logging.js +533 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.js +97 -36
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -7
- package/dist/opencode-plugin.js +12 -13
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/ui/config-view.js +3 -0
- package/dist/ui/config-view.js.map +1 -1
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +3 -2
- package/src/atomic-file.ts +86 -7
- package/src/claude-wake.test.ts +320 -0
- package/src/claude-wake.ts +489 -0
- package/src/client.test.ts +12 -0
- package/src/client.ts +72 -4
- package/src/codex-wake.test.ts +253 -0
- package/src/codex-wake.ts +288 -0
- package/src/commands.test.ts +1261 -77
- package/src/commands.ts +1482 -375
- package/src/config-schema.test.ts +3 -3
- package/src/config-schema.ts +57 -44
- package/src/config.test.ts +22 -16
- package/src/config.ts +81 -66
- package/src/file-lock-process-worker.test.ts +64 -0
- package/src/file-lock.test.ts +279 -0
- package/src/file-lock.ts +318 -0
- package/src/harnesses.test.ts +34 -0
- package/src/harnesses.ts +57 -0
- package/src/hook-adapter.test.ts +187 -0
- package/src/hook-adapter.ts +212 -0
- package/src/hook-input.test.ts +39 -0
- package/src/hook-input.ts +72 -0
- package/src/hooks.test.ts +1985 -417
- package/src/hooks.ts +1338 -461
- package/src/install-hooks.test.ts +131 -41
- package/src/install-hooks.ts +284 -84
- package/src/interactive.ts +3 -8
- package/src/logging-process-worker.test.ts +39 -0
- package/src/logging.test.ts +453 -0
- package/src/logging.ts +639 -0
- package/src/main.ts +113 -32
- package/src/opencode-plugin.ts +15 -16
- package/src/session-end-log-lock-worker.test.ts +35 -0
- package/src/skill.test.ts +34 -0
- package/src/ui/config-view.test.ts +9 -7
- package/src/ui/config-view.ts +3 -0
- package/src/idle.test.ts +0 -32
- package/src/idle.ts +0 -41
|
@@ -73,14 +73,14 @@ describe('acceptedValues', () => {
|
|
|
73
73
|
})
|
|
74
74
|
|
|
75
75
|
it('names both values for a toggle', () => {
|
|
76
|
-
expect(acceptedValues('
|
|
76
|
+
expect(acceptedValues('ask_notifications')).toBe('true or false')
|
|
77
77
|
})
|
|
78
78
|
})
|
|
79
79
|
|
|
80
80
|
describe('formatValue', () => {
|
|
81
81
|
it('renders toggles as words rather than JSON', () => {
|
|
82
|
-
expect(formatValue('
|
|
83
|
-
expect(formatValue('
|
|
82
|
+
expect(formatValue('ask_notifications', true)).toBe('yes')
|
|
83
|
+
expect(formatValue('ask_notifications', false)).toBe('no')
|
|
84
84
|
})
|
|
85
85
|
|
|
86
86
|
it('explains what an unset value means instead of printing null', () => {
|
package/src/config-schema.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What every configuration key means, in the reader's words.
|
|
3
3
|
*
|
|
4
|
-
* `config.ts` already documented
|
|
4
|
+
* `config.ts` already documented every key carefully — but in TypeScript
|
|
5
5
|
* comments, which is the one audience that never needed them. Someone running
|
|
6
|
-
* `notifai config show` saw `
|
|
7
|
-
* it did short of reading the source, so this module is that knowledge
|
|
8
|
-
* where it can be printed.
|
|
6
|
+
* `notifai config show` saw `ask_grace_seconds = 0` and had no way to learn
|
|
7
|
+
* what it did short of reading the source, so this module is that knowledge
|
|
8
|
+
* moved to where it can be printed.
|
|
9
9
|
*
|
|
10
10
|
* It is the single source for `config show`, `config set`'s errors, `config
|
|
11
11
|
* explain`, the help footer, and the interactive settings screen. One
|
|
@@ -13,25 +13,25 @@
|
|
|
13
13
|
*
|
|
14
14
|
* `summary` is the one-line form that fits beside a value in a list.
|
|
15
15
|
* `detail` is the paragraph shown once the reader has asked for this key
|
|
16
|
-
* specifically — progressive disclosure, so
|
|
17
|
-
*
|
|
16
|
+
* specifically — progressive disclosure, so the list of settings stays a list
|
|
17
|
+
* 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
|
|
|
24
24
|
/**
|
|
25
|
-
* Which surface a key belongs to. Grouping is the difference between
|
|
26
|
-
*
|
|
25
|
+
* Which surface a key belongs to. Grouping is the difference between a flat
|
|
26
|
+
* list of keys and a few short ones 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
|
{
|
|
32
32
|
id: 'questions',
|
|
33
|
-
title: 'Questions
|
|
34
|
-
blurb: '
|
|
33
|
+
title: 'Questions',
|
|
34
|
+
blurb: 'Whether a question an agent registered may leave this terminal and reach your devices, and how soon.',
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
id: 'delivery',
|
|
@@ -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',
|
|
@@ -88,44 +93,15 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
88
93
|
'The master switch for question routing. When this is off, a question an agent registers with `notifai ask` stays in the terminal and never leaves this machine, whatever the presence settings say. Turn it off to stop being reached for a while without uninstalling the harness hooks.',
|
|
89
94
|
example: 'true',
|
|
90
95
|
},
|
|
91
|
-
require_idle: {
|
|
92
|
-
label: 'Only when I have stepped away',
|
|
93
|
-
group: 'questions',
|
|
94
|
-
kind: 'boolean',
|
|
95
|
-
summary: 'Whether sitting at this keyboard holds a question back',
|
|
96
|
-
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',
|
|
99
|
-
},
|
|
100
|
-
away_after_seconds: {
|
|
101
|
-
label: 'Away after',
|
|
102
|
-
group: 'questions',
|
|
103
|
-
kind: 'integer',
|
|
104
|
-
unit: 's',
|
|
105
|
-
summary: 'Keyboard and mouse idle time before this machine counts as unattended',
|
|
106
|
-
detail:
|
|
107
|
-
'How long the keyboard and mouse must be quiet before you count as away. Only consulted while `require_idle` is on.\n\nWhere the operating system exposes no idle signal, silence since your last prompt is used instead, which is the conservative reading.',
|
|
108
|
-
example: '120',
|
|
109
|
-
},
|
|
110
96
|
ask_grace_seconds: {
|
|
111
97
|
label: 'Terminal-first grace',
|
|
112
98
|
group: 'questions',
|
|
113
99
|
kind: 'integer',
|
|
114
100
|
unit: 's',
|
|
115
|
-
summary: '
|
|
101
|
+
summary: 'Optional delay before a question may reach your devices',
|
|
116
102
|
detail:
|
|
117
|
-
'
|
|
118
|
-
example: '
|
|
119
|
-
},
|
|
120
|
-
hook_reply_timeout_seconds: {
|
|
121
|
-
label: 'Hook wait for an answer',
|
|
122
|
-
group: 'questions',
|
|
123
|
-
kind: 'integer',
|
|
124
|
-
unit: 's',
|
|
125
|
-
summary: 'How long the harness hook blocks waiting for your answer',
|
|
126
|
-
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',
|
|
103
|
+
'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. Whether you happen to be at this keyboard does not change it: waiting no longer holds your terminal, so there is nothing your being here would protect you from.',
|
|
104
|
+
example: '0',
|
|
129
105
|
},
|
|
130
106
|
|
|
131
107
|
sound: {
|
|
@@ -222,6 +198,43 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
222
198
|
example: 'Only failures and completed deploys',
|
|
223
199
|
},
|
|
224
200
|
|
|
201
|
+
log_level: {
|
|
202
|
+
label: 'Log level',
|
|
203
|
+
group: 'diagnostics',
|
|
204
|
+
kind: 'enum',
|
|
205
|
+
choices: LOG_LEVELS,
|
|
206
|
+
choiceHints: {
|
|
207
|
+
off: 'record nothing',
|
|
208
|
+
error: 'only failures',
|
|
209
|
+
info: 'commands, notifications, and every question decision',
|
|
210
|
+
debug: 'adds config resolution and per-request detail',
|
|
211
|
+
},
|
|
212
|
+
summary: 'How much this machine records about what it did',
|
|
213
|
+
detail:
|
|
214
|
+
'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.',
|
|
215
|
+
example: 'info',
|
|
216
|
+
},
|
|
217
|
+
log_max_bytes: {
|
|
218
|
+
label: 'Log file size',
|
|
219
|
+
group: 'diagnostics',
|
|
220
|
+
kind: 'integer',
|
|
221
|
+
summary: 'How large the log grows before it is rotated',
|
|
222
|
+
detail:
|
|
223
|
+
'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.',
|
|
224
|
+
advanced: true,
|
|
225
|
+
example: '2000000',
|
|
226
|
+
},
|
|
227
|
+
log_max_files: {
|
|
228
|
+
label: 'Log files kept',
|
|
229
|
+
group: 'diagnostics',
|
|
230
|
+
kind: 'integer',
|
|
231
|
+
summary: 'How many rotated log files are kept before the oldest is deleted',
|
|
232
|
+
detail:
|
|
233
|
+
'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.',
|
|
234
|
+
advanced: true,
|
|
235
|
+
example: '3',
|
|
236
|
+
},
|
|
237
|
+
|
|
225
238
|
base_url: {
|
|
226
239
|
label: 'Service URL',
|
|
227
240
|
group: 'connection',
|
package/src/config.test.ts
CHANGED
|
@@ -45,22 +45,22 @@ function setup(options: {
|
|
|
45
45
|
describe('harness config layers', () => {
|
|
46
46
|
it('orders session over project-local over project over global', () => {
|
|
47
47
|
const { env, cwd } = setup({
|
|
48
|
-
globalToml: '
|
|
49
|
-
projectToml: '
|
|
50
|
-
projectLocalToml: '
|
|
51
|
-
sessionToml: '
|
|
48
|
+
globalToml: 'ask_grace_seconds = 10\n',
|
|
49
|
+
projectToml: 'ask_grace_seconds = 20\n',
|
|
50
|
+
projectLocalToml: 'ask_grace_seconds = 30\n',
|
|
51
|
+
sessionToml: 'ask_grace_seconds = 40\n',
|
|
52
52
|
sessionId: 'abc',
|
|
53
53
|
})
|
|
54
|
-
expect(loadConfig({ cwd, env, sessionId: 'abc' }).
|
|
54
|
+
expect(loadConfig({ cwd, env, sessionId: 'abc' }).ask_grace_seconds).toMatchObject({
|
|
55
55
|
value: 40,
|
|
56
56
|
source: expect.stringMatching(/^session:/),
|
|
57
57
|
})
|
|
58
58
|
// Same files, different session: the session layer simply does not apply.
|
|
59
|
-
expect(loadConfig({ cwd, env, sessionId: 'other' }).
|
|
59
|
+
expect(loadConfig({ cwd, env, sessionId: 'other' }).ask_grace_seconds).toMatchObject({
|
|
60
60
|
value: 30,
|
|
61
61
|
source: expect.stringMatching(/^project-local:/),
|
|
62
62
|
})
|
|
63
|
-
expect(loadConfig({ cwd, env }).
|
|
63
|
+
expect(loadConfig({ cwd, env }).ask_grace_seconds.value).toBe(30)
|
|
64
64
|
})
|
|
65
65
|
|
|
66
66
|
it('keeps a personal toggle out of the committed project file', () => {
|
|
@@ -75,28 +75,34 @@ describe('harness config layers', () => {
|
|
|
75
75
|
|
|
76
76
|
it('keeps hostile or colliding session ids inside their own file', () => {
|
|
77
77
|
const { env, cwd } = setup({
|
|
78
|
-
sessionToml: '
|
|
78
|
+
sessionToml: 'ask_grace_seconds = 99\n',
|
|
79
79
|
sessionId: '../../etc/passwd',
|
|
80
80
|
})
|
|
81
81
|
// The id round-trips to its own state, and nothing escapes the directory.
|
|
82
|
-
expect(loadConfig({ cwd, env, sessionId: '../../etc/passwd' }).
|
|
82
|
+
expect(loadConfig({ cwd, env, sessionId: '../../etc/passwd' }).ask_grace_seconds.value).toBe(99)
|
|
83
83
|
expect(sessionConfigPath('../../etc/passwd', env)).toContain('/notifai/sessions/')
|
|
84
84
|
expect(sessionConfigPath('../../etc/passwd', env)).not.toContain('..')
|
|
85
85
|
// Ids that a sanitiser would have collapsed together must stay distinct:
|
|
86
86
|
// sharing a file meant sharing presence and pending questions.
|
|
87
87
|
expect(sessionConfigPath('a/b', env)).not.toBe(sessionConfigPath('a?b', env))
|
|
88
|
-
expect(loadConfig({ cwd, env, sessionId: 'a/b' }).
|
|
88
|
+
expect(loadConfig({ cwd, env, sessionId: 'a/b' }).ask_grace_seconds.value).toBe(0)
|
|
89
89
|
})
|
|
90
90
|
|
|
91
|
-
it('defaults
|
|
91
|
+
it('defaults question routing to on and reaching the user straight away', () => {
|
|
92
92
|
const { env, cwd } = setup({})
|
|
93
93
|
const config = loadConfig({ cwd, env })
|
|
94
94
|
expect(config.ask_notifications.value).toBe(true)
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
// Nothing here asks where the user is standing. A question goes out when
|
|
96
|
+
// the turn ends unless they choose a terminal-first window.
|
|
97
|
+
expect(config.ask_grace_seconds).toEqual({ value: 0, source: 'default' })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('bounds the terminal-first window so a reply window always survives it', () => {
|
|
101
|
+
// Config is readable from a repository, so an unbounded grace is hostile
|
|
102
|
+
// input: it would eat the waiter's whole ceiling and leave no window the
|
|
103
|
+
// server would accept an answer into.
|
|
104
|
+
const { env, cwd } = setup({ globalToml: 'ask_grace_seconds = 100000\n' })
|
|
105
|
+
expect(loadConfig({ cwd, env }).ask_grace_seconds.value).toBe(360)
|
|
100
106
|
})
|
|
101
107
|
})
|
|
102
108
|
|
package/src/config.ts
CHANGED
|
@@ -39,6 +39,14 @@ export interface ResolvedValue<T> {
|
|
|
39
39
|
type CliSound = (typeof CLI_SOUNDS)[number]
|
|
40
40
|
type CliInterruptionLevel = (typeof INTERRUPTION_LEVELS)[number]
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* How much the local log records. Defined here rather than in `logging.ts`
|
|
44
|
+
* because it is a configuration vocabulary first — `logging.ts` reads config,
|
|
45
|
+
* so the dependency has to point this way.
|
|
46
|
+
*/
|
|
47
|
+
export const LOG_LEVELS = ['off', 'error', 'info', 'debug'] as const
|
|
48
|
+
export type LogLevel = (typeof LOG_LEVELS)[number]
|
|
49
|
+
|
|
42
50
|
export interface CliConfig {
|
|
43
51
|
base_url: ResolvedValue<string>
|
|
44
52
|
wait_seconds: ResolvedValue<number>
|
|
@@ -51,51 +59,36 @@ export interface CliConfig {
|
|
|
51
59
|
project: ResolvedValue<string | null>
|
|
52
60
|
/** Free-text notification criteria agents consult before sending. */
|
|
53
61
|
notify_criteria: ResolvedValue<string | null>
|
|
54
|
-
/**
|
|
62
|
+
/** Whether a registered agent question may reach companion devices at all. */
|
|
55
63
|
ask_notifications: ResolvedValue<boolean>
|
|
56
64
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* On, the default, is the original behaviour: a question stays in the
|
|
62
|
-
* terminal while the keyboard is in use, because the person is right there
|
|
63
|
-
* and a push would be redundant. Off says notify me anyway — the grace window
|
|
64
|
-
* still runs, so the question is still offered to the terminal first, it just
|
|
65
|
-
* no longer needs the user to walk away before it may leave.
|
|
65
|
+
* Terminal-first grace window, measured from the moment the question was
|
|
66
|
+
* sent: the agent asks in the terminal and starts a timer, and only once it
|
|
67
|
+
* elapses does the question reach companion devices.
|
|
66
68
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
69
|
+
* A timer, and only a timer. Whether the user happens to be at this keyboard
|
|
70
|
+
* decides nothing — the waiter no longer holds their terminal, so there is
|
|
71
|
+
* nothing for their presence to protect them from.
|
|
70
72
|
*/
|
|
71
|
-
|
|
73
|
+
ask_grace_seconds: ResolvedValue<number>
|
|
72
74
|
/**
|
|
73
|
-
*
|
|
74
|
-
* When the OS idle signal is unavailable, silence since the user's last prompt
|
|
75
|
-
* is the conservative fallback. Below the threshold the hooks stay out of the
|
|
76
|
-
* way entirely, because a blocking hook also blocks the terminal prompt the
|
|
77
|
-
* present user would use.
|
|
75
|
+
* How much of what this CLI does is recorded to the local log.
|
|
78
76
|
*
|
|
79
|
-
*
|
|
77
|
+
* The log exists for agents: a hook runs headless and its most consequential
|
|
78
|
+
* act is usually a decision to do nothing, which leaves no other trace. `off`
|
|
79
|
+
* disables the sink entirely; `debug` adds config resolution and request
|
|
80
|
+
* detail, which is verbose enough to want a reason.
|
|
80
81
|
*/
|
|
81
|
-
|
|
82
|
+
log_level: ResolvedValue<LogLevel>
|
|
82
83
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
84
|
+
* Bytes the active log file may reach before it is rotated. With
|
|
85
|
+
* `log_max_files` this is the whole disk budget — nothing here grows without
|
|
86
|
+
* a ceiling, because a log that fills a disk is a bug that outlives its
|
|
87
|
+
* usefulness.
|
|
86
88
|
*/
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
* sent: the agent asks in the terminal and starts a timer, and only once it
|
|
91
|
-
* elapses does the question reach companion devices.
|
|
92
|
-
*
|
|
93
|
-
* A timer, and only a timer. It is configured independently of
|
|
94
|
-
* `away_after_seconds` and honoured whether or not presence is consulted at
|
|
95
|
-
* all — how long to wait and whether anyone is watching are separate
|
|
96
|
-
* questions, and answering one should not answer the other.
|
|
97
|
-
*/
|
|
98
|
-
ask_grace_seconds: ResolvedValue<number>
|
|
89
|
+
log_max_bytes: ResolvedValue<number>
|
|
90
|
+
/** Log files kept in total, the active one included. */
|
|
91
|
+
log_max_files: ResolvedValue<number>
|
|
99
92
|
}
|
|
100
93
|
|
|
101
94
|
export const CONFIG_KEYS = [
|
|
@@ -109,28 +102,36 @@ export const CONFIG_KEYS = [
|
|
|
109
102
|
'project',
|
|
110
103
|
'notify_criteria',
|
|
111
104
|
'ask_notifications',
|
|
112
|
-
'require_idle',
|
|
113
|
-
'away_after_seconds',
|
|
114
|
-
'hook_reply_timeout_seconds',
|
|
115
105
|
'ask_grace_seconds',
|
|
106
|
+
'log_level',
|
|
107
|
+
'log_max_bytes',
|
|
108
|
+
'log_max_files',
|
|
116
109
|
] as const
|
|
117
110
|
export type ConfigKey = (typeof CONFIG_KEYS)[number]
|
|
118
111
|
|
|
119
112
|
/** Keys whose TOML value must parse as a boolean; used by `config set`. */
|
|
120
|
-
export const BOOLEAN_CONFIG_KEYS: readonly ConfigKey[] = [
|
|
121
|
-
'ask_notifications',
|
|
122
|
-
'require_idle',
|
|
123
|
-
]
|
|
113
|
+
export const BOOLEAN_CONFIG_KEYS: readonly ConfigKey[] = ['ask_notifications']
|
|
124
114
|
|
|
125
115
|
/** Keys whose TOML value must parse as an integer within its configured bounds. */
|
|
126
116
|
export const NUMERIC_CONFIG_KEYS: readonly ConfigKey[] = [
|
|
127
117
|
'wait_seconds',
|
|
128
118
|
'ttl_seconds',
|
|
129
|
-
'away_after_seconds',
|
|
130
|
-
'hook_reply_timeout_seconds',
|
|
131
119
|
'ask_grace_seconds',
|
|
120
|
+
'log_max_bytes',
|
|
121
|
+
'log_max_files',
|
|
132
122
|
]
|
|
133
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Keys whose value must be one of a fixed set.
|
|
126
|
+
*
|
|
127
|
+
* Config is readable from a repository, so an unrecognised value is untrusted
|
|
128
|
+
* input rather than a typo — `coerce` drops it and the layer below wins, which
|
|
129
|
+
* is the same discipline the numeric bounds follow.
|
|
130
|
+
*/
|
|
131
|
+
export const ENUM_CONFIG_VALUES: Partial<Record<ConfigKey, readonly string[]>> = {
|
|
132
|
+
log_level: LOG_LEVELS,
|
|
133
|
+
}
|
|
134
|
+
|
|
134
135
|
const DEFAULTS = {
|
|
135
136
|
base_url: 'https://notifai.fly.dev',
|
|
136
137
|
wait_seconds: 10,
|
|
@@ -144,17 +145,23 @@ const DEFAULTS = {
|
|
|
144
145
|
// Installing the hooks is the opt-in; these exist to switch the behaviour
|
|
145
146
|
// back off for a project or a session without uninstalling anything.
|
|
146
147
|
ask_notifications: true,
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
148
|
+
// Questions reach devices as soon as the turn ends. A terminal-first grace
|
|
149
|
+
// window is an opt-in delay on top of that.
|
|
150
|
+
ask_grace_seconds: 0,
|
|
151
|
+
// On by default: the log is the only account of what a headless hook did, and
|
|
152
|
+
// a diagnostic nobody switched on before the thing went wrong is not one.
|
|
153
|
+
log_level: 'info',
|
|
154
|
+
// 2 MB × 3 files caps the logs at roughly 6 MB, which is weeks of ordinary
|
|
155
|
+
// agent traffic and small enough that nobody has to think about it.
|
|
156
|
+
log_max_bytes: 2_000_000,
|
|
157
|
+
log_max_files: 3,
|
|
156
158
|
} satisfies Record<ConfigKey, unknown>
|
|
157
159
|
|
|
160
|
+
/** The shipped value beneath every user-controlled configuration layer. */
|
|
161
|
+
export function configDefaultValue(key: ConfigKey): unknown {
|
|
162
|
+
return DEFAULTS[key]
|
|
163
|
+
}
|
|
164
|
+
|
|
158
165
|
export function globalConfigDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
159
166
|
const xdg = env['XDG_CONFIG_HOME']
|
|
160
167
|
const base = xdg && xdg !== '' ? xdg : path.join(os.homedir(), '.config')
|
|
@@ -243,19 +250,23 @@ export function findProjectLocalConfigPath(startDir: string): string | null {
|
|
|
243
250
|
|
|
244
251
|
/**
|
|
245
252
|
* Ranges every layer is clamped to. Config is readable from a repository, so an
|
|
246
|
-
* out-of-range value is attacker input, not a typo:
|
|
247
|
-
* would
|
|
248
|
-
*
|
|
249
|
-
* hook is killed after the user has already answered.
|
|
253
|
+
* out-of-range value is attacker input, not a typo: an unbounded grace window
|
|
254
|
+
* would consume the waiter's whole ceiling and silently leave no time at all in
|
|
255
|
+
* which the user's answer could still be accepted.
|
|
250
256
|
*/
|
|
251
257
|
const NUMERIC_BOUNDS: Partial<Record<ConfigKey, { min: number; max: number }>> = {
|
|
252
258
|
wait_seconds: { min: 0, max: 300 },
|
|
253
259
|
ttl_seconds: { min: 0, max: 7 * 24 * 3600 },
|
|
254
|
-
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
ask_grace_seconds: { min: 0, max:
|
|
260
|
+
// A plain number, no longer derived from a hook budget's leftovers. Six
|
|
261
|
+
// minutes leaves two clear minutes of the waiter's 480s ceiling, so the
|
|
262
|
+
// longest window a user can choose still leaves an answer window the server
|
|
263
|
+
// will accept — with room to spare for a timer that wakes late.
|
|
264
|
+
ask_grace_seconds: { min: 0, max: 360 },
|
|
265
|
+
// The floor keeps rotation from thrashing (a file smaller than a handful of
|
|
266
|
+
// records would rotate on nearly every write); the ceiling is the point past
|
|
267
|
+
// which "local diagnostics" has become "a database on your disk".
|
|
268
|
+
log_max_bytes: { min: 64_000, max: 100_000_000 },
|
|
269
|
+
log_max_files: { min: 1, max: 20 },
|
|
259
270
|
}
|
|
260
271
|
|
|
261
272
|
/** Drops values a layer must not be trusted to supply, rather than throwing. */
|
|
@@ -266,6 +277,10 @@ function coerce(key: ConfigKey, value: unknown): unknown | undefined {
|
|
|
266
277
|
return Math.min(bounds.max, Math.max(bounds.min, Math.trunc(value)))
|
|
267
278
|
}
|
|
268
279
|
if (BOOLEAN_CONFIG_KEYS.includes(key)) return typeof value === 'boolean' ? value : undefined
|
|
280
|
+
const allowed = ENUM_CONFIG_VALUES[key]
|
|
281
|
+
if (allowed !== undefined) {
|
|
282
|
+
return typeof value === 'string' && allowed.includes(value) ? value : undefined
|
|
283
|
+
}
|
|
269
284
|
return value
|
|
270
285
|
}
|
|
271
286
|
|
|
@@ -339,9 +354,9 @@ export function loadConfig(options: {
|
|
|
339
354
|
project: resolve('project'),
|
|
340
355
|
notify_criteria: resolve('notify_criteria'),
|
|
341
356
|
ask_notifications: resolve('ask_notifications'),
|
|
342
|
-
require_idle: resolve('require_idle'),
|
|
343
|
-
away_after_seconds: resolve('away_after_seconds'),
|
|
344
|
-
hook_reply_timeout_seconds: resolve('hook_reply_timeout_seconds'),
|
|
345
357
|
ask_grace_seconds: resolve('ask_grace_seconds'),
|
|
358
|
+
log_level: resolve('log_level'),
|
|
359
|
+
log_max_bytes: resolve('log_max_bytes'),
|
|
360
|
+
log_max_files: resolve('log_max_files'),
|
|
346
361
|
}
|
|
347
362
|
}
|
|
@@ -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
|
+
}
|