@raidou/pi-notify 0.4.0 → 0.5.1
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 +15 -1
- package/package.json +3 -1
- package/src/config.ts +0 -3
- package/src/dashboard/command.ts +3 -8
- package/src/dashboard/consts.ts +6 -0
- package/src/dashboard/session-store.test.ts +156 -8
- package/src/dashboard/session-store.ts +16 -12
- package/src/dashboard/state-store.test.ts +89 -17
- package/src/dashboard/state-store.ts +88 -32
- package/src/dashboard/ui/columns.ts +102 -0
- package/src/dashboard/ui/dashboard.test.ts +118 -0
- package/src/dashboard/ui/dashboard.ts +122 -0
- package/src/index.ts +2 -11
- package/src/state-tracker.test.ts +227 -12
- package/src/state-tracker.ts +53 -6
- package/src/dashboard/ui.ts +0 -193
- package/src/events.test.ts +0 -115
- package/src/events.ts +0 -31
- package/src/idle.ts +0 -37
- package/src/tool.ts +0 -21
package/README.md
CHANGED
|
@@ -66,7 +66,21 @@ function endBackgroundJob(jobId: string): void {
|
|
|
66
66
|
|
|
67
67
|
Events are automatically cleaned up on `session_shutdown`.
|
|
68
68
|
|
|
69
|
-
##
|
|
69
|
+
## Commands
|
|
70
|
+
|
|
71
|
+
### `/notify-dashboard`
|
|
72
|
+
|
|
73
|
+
Open a full-screen TUI dashboard listing all pi sessions, with summary counts (total, running, idle) and an auto-refresh every second.
|
|
74
|
+
|
|
75
|
+
Columns: `SESSION_ID` (last 6 chars), `PID`, `STATE` (running/idle, color-coded), `PROJECT`, `UPTIME`.
|
|
76
|
+
|
|
77
|
+
Keybindings:
|
|
78
|
+
|
|
79
|
+
- `↑` / `↓` or `j` / `k` — scroll
|
|
80
|
+
- `r` — refresh
|
|
81
|
+
- `q` or `esc` — close
|
|
82
|
+
|
|
83
|
+
### `/notify-test`
|
|
70
84
|
|
|
71
85
|
Run `/notify-test` inside pi to fire a test notification.
|
|
72
86
|
Pass a string argument (e.g., `/notify-test hello`) to override the body.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raidou/pi-notify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Desktop notification extension for the pi coding agent.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@earendil-works/pi-tui": "0.80.7",
|
|
38
38
|
"emittery": "^2.0.0",
|
|
39
|
+
"lodash-es": "^4.18.1",
|
|
39
40
|
"node-notifier": "^10.0.1",
|
|
40
41
|
"proper-lockfile": "^4.1.2"
|
|
41
42
|
},
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
},
|
|
45
46
|
"devDependencies": {
|
|
46
47
|
"@raidou/eslint-config-base": "^4.4.3",
|
|
48
|
+
"@types/lodash-es": "^4.17.12",
|
|
47
49
|
"@types/node": "^22.0.0",
|
|
48
50
|
"@types/proper-lockfile": "^4.1.4",
|
|
49
51
|
"eslint": "^10.7.0",
|
package/src/config.ts
CHANGED
|
@@ -13,7 +13,6 @@ interface NotifyConfig {
|
|
|
13
13
|
readonly notifyTools?: readonly string[]
|
|
14
14
|
readonly events?: NotifyEventsConfig
|
|
15
15
|
readonly finished?: boolean
|
|
16
|
-
readonly finishedThrottleSecs?: number
|
|
17
16
|
readonly onlyNotifyWhenUnfocused?: boolean
|
|
18
17
|
readonly unfocusedActivityThresholdSecs?: number
|
|
19
18
|
readonly tmuxSymbol?: string
|
|
@@ -24,7 +23,6 @@ export interface ResolvedNotifyConfig {
|
|
|
24
23
|
readonly notifyTools: ReadonlySet<string>
|
|
25
24
|
readonly events: NotifyEventsConfig
|
|
26
25
|
readonly finished: boolean
|
|
27
|
-
readonly finishedThrottleMs: number
|
|
28
26
|
readonly onlyNotifyWhenUnfocused: boolean
|
|
29
27
|
readonly unfocusedActivityThresholdMs: number
|
|
30
28
|
readonly tmuxSymbol: string
|
|
@@ -67,7 +65,6 @@ export function loadConfig(): ResolvedNotifyConfig {
|
|
|
67
65
|
notifyTools: new Set(cfg.notifyTools ?? DEFAULT_NOTIFY_TOOLS),
|
|
68
66
|
events,
|
|
69
67
|
finished: cfg.finished ?? true,
|
|
70
|
-
finishedThrottleMs: Math.max(0, (cfg.finishedThrottleSecs ?? 0) * 1000),
|
|
71
68
|
onlyNotifyWhenUnfocused: cfg.onlyNotifyWhenUnfocused ?? true,
|
|
72
69
|
unfocusedActivityThresholdMs: Math.max(
|
|
73
70
|
0,
|
package/src/dashboard/command.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Registrar } from '../shared/registrar.js'
|
|
2
2
|
import { readSessions } from './state-store.js'
|
|
3
|
-
import {
|
|
3
|
+
import { Dashboard } from './ui/dashboard.js'
|
|
4
4
|
|
|
5
5
|
export class DashboardCommand extends Registrar {
|
|
6
6
|
protected override setup(): void {
|
|
@@ -11,7 +11,7 @@ export class DashboardCommand extends Registrar {
|
|
|
11
11
|
|
|
12
12
|
await ctx.ui.custom<unknown>((tui, theme, _keybindings, done) => {
|
|
13
13
|
let closed = false
|
|
14
|
-
const dashboard =
|
|
14
|
+
const dashboard = new Dashboard({
|
|
15
15
|
tui,
|
|
16
16
|
theme,
|
|
17
17
|
initialSessions,
|
|
@@ -24,12 +24,7 @@ export class DashboardCommand extends Registrar {
|
|
|
24
24
|
},
|
|
25
25
|
})
|
|
26
26
|
|
|
27
|
-
return
|
|
28
|
-
render: dashboard.render.bind(dashboard),
|
|
29
|
-
handleInput: dashboard.handleInput.bind(dashboard),
|
|
30
|
-
invalidate: dashboard.invalidate.bind(dashboard),
|
|
31
|
-
dispose: dashboard.dispose.bind(dashboard),
|
|
32
|
-
}
|
|
27
|
+
return dashboard
|
|
33
28
|
})
|
|
34
29
|
|
|
35
30
|
return undefined
|
|
@@ -1,11 +1,33 @@
|
|
|
1
|
-
import { unlinkSync } from 'node:fs'
|
|
2
|
-
import {
|
|
1
|
+
import { rmSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
afterAll,
|
|
7
|
+
afterEach,
|
|
8
|
+
beforeEach,
|
|
9
|
+
describe,
|
|
10
|
+
expect,
|
|
11
|
+
it,
|
|
12
|
+
vi,
|
|
13
|
+
} from 'vitest'
|
|
14
|
+
|
|
15
|
+
vi.mock('./consts.js', async () => {
|
|
16
|
+
const { mkdtempSync } = await import('node:fs')
|
|
17
|
+
const path = await import('node:path')
|
|
18
|
+
const { tmpdir } = await import('node:os')
|
|
19
|
+
|
|
20
|
+
const stateDir = mkdtempSync(path.join(tmpdir(), 'pi-notify-test-'))
|
|
21
|
+
return {
|
|
22
|
+
STATE_FILE: path.join(stateDir, 'state.json'),
|
|
23
|
+
STATE_TMP_FILE: path.join(stateDir, 'state.json.tmp'),
|
|
24
|
+
}
|
|
25
|
+
})
|
|
7
26
|
|
|
8
|
-
import type {
|
|
27
|
+
import type { ResolvedNotifyConfig } from '../config.js'
|
|
28
|
+
import type { JobTracker } from '../jobs.js'
|
|
29
|
+
import { StateTracker } from '../state-tracker.js'
|
|
30
|
+
import { STATE_FILE } from './consts.js'
|
|
9
31
|
import { SessionStore } from './session-store.js'
|
|
10
32
|
import { readState, updateState } from './state-store.js'
|
|
11
33
|
|
|
@@ -113,8 +135,12 @@ const META = {
|
|
|
113
135
|
projectName: 'test-project',
|
|
114
136
|
}
|
|
115
137
|
|
|
138
|
+
afterAll(() => {
|
|
139
|
+
rmSync(dirname(STATE_FILE), { recursive: true, force: true })
|
|
140
|
+
})
|
|
141
|
+
|
|
116
142
|
describe('SessionStore', () => {
|
|
117
|
-
const testLockFile =
|
|
143
|
+
const testLockFile = `${STATE_FILE}.lock`
|
|
118
144
|
|
|
119
145
|
beforeEach(async () => {
|
|
120
146
|
try {
|
|
@@ -122,7 +148,7 @@ describe('SessionStore', () => {
|
|
|
122
148
|
} catch {
|
|
123
149
|
// ignore
|
|
124
150
|
}
|
|
125
|
-
await updateState(() => ({ version:
|
|
151
|
+
await updateState(() => ({ version: 2, sessions: {} }))
|
|
126
152
|
})
|
|
127
153
|
|
|
128
154
|
afterEach(() => {
|
|
@@ -145,9 +171,11 @@ describe('SessionStore', () => {
|
|
|
145
171
|
)
|
|
146
172
|
store.register(vi.fn())
|
|
147
173
|
|
|
148
|
-
expect(emitSpy).toHaveBeenCalledTimes(
|
|
174
|
+
expect(emitSpy).toHaveBeenCalledTimes(4)
|
|
149
175
|
expect(emitSpy).toHaveBeenCalledWith('running', expect.any(Function))
|
|
150
176
|
expect(emitSpy).toHaveBeenCalledWith('idle', expect.any(Function))
|
|
177
|
+
expect(emitSpy).toHaveBeenCalledWith('tool', expect.any(Function))
|
|
178
|
+
expect(emitSpy).toHaveBeenCalledWith('event', expect.any(Function))
|
|
151
179
|
expect(piOnSpy).toHaveBeenCalledWith('session_start', expect.any(Function))
|
|
152
180
|
})
|
|
153
181
|
|
|
@@ -287,4 +315,124 @@ describe('SessionStore', () => {
|
|
|
287
315
|
store.stop()
|
|
288
316
|
}).not.toThrow()
|
|
289
317
|
})
|
|
318
|
+
|
|
319
|
+
it('clears runningSince on idle transition without accumulating duration', async () => {
|
|
320
|
+
await updateState(() => ({ version: 2, sessions: {} }))
|
|
321
|
+
const now = Date.now()
|
|
322
|
+
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now)
|
|
323
|
+
const pi = makeFakePi()
|
|
324
|
+
const stateTracker = makeFakeStateTracker()
|
|
325
|
+
const store = new SessionStore(
|
|
326
|
+
pi as unknown as ExtensionAPI,
|
|
327
|
+
stateTracker as unknown as StateTracker,
|
|
328
|
+
)
|
|
329
|
+
store.register(() => {})
|
|
330
|
+
|
|
331
|
+
pi.emitSessionStart({
|
|
332
|
+
cwd: META.cwd,
|
|
333
|
+
sessionManager: { getSessionId: () => SESSION_ID },
|
|
334
|
+
})
|
|
335
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
336
|
+
|
|
337
|
+
stateTracker.events.emit('running')
|
|
338
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
339
|
+
|
|
340
|
+
nowSpy.mockReturnValue(now + 5000)
|
|
341
|
+
stateTracker.events.emit('idle')
|
|
342
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
343
|
+
|
|
344
|
+
const record = readState().sessions[String(process.pid)]
|
|
345
|
+
expect(record).not.toHaveProperty('runningTime')
|
|
346
|
+
expect(record?.startedRunningAt).toBeUndefined()
|
|
347
|
+
nowSpy.mockRestore()
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('does not update state on tool emission outside notifyTools', async () => {
|
|
351
|
+
await updateState(() => ({ version: 2, sessions: {} }))
|
|
352
|
+
const pi = makeFakePi()
|
|
353
|
+
const jobTracker = {
|
|
354
|
+
hasActiveJobs: false,
|
|
355
|
+
onEnd: () => () => {},
|
|
356
|
+
} as unknown as JobTracker
|
|
357
|
+
const tracker = new StateTracker(
|
|
358
|
+
pi as unknown as ExtensionAPI,
|
|
359
|
+
jobTracker,
|
|
360
|
+
{
|
|
361
|
+
notifyTools: new Set(['read']),
|
|
362
|
+
events: {},
|
|
363
|
+
} as unknown as ResolvedNotifyConfig,
|
|
364
|
+
)
|
|
365
|
+
tracker.register(() => {})
|
|
366
|
+
|
|
367
|
+
const store = new SessionStore(pi as unknown as ExtensionAPI, tracker)
|
|
368
|
+
store.register(() => {})
|
|
369
|
+
|
|
370
|
+
pi.emitSessionStart({
|
|
371
|
+
cwd: META.cwd,
|
|
372
|
+
sessionManager: { getSessionId: () => SESSION_ID },
|
|
373
|
+
})
|
|
374
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
375
|
+
|
|
376
|
+
expect(readState().sessions[String(process.pid)]?.state).toBe('idle')
|
|
377
|
+
|
|
378
|
+
pi.emit('tool_call', {
|
|
379
|
+
type: 'tool_call',
|
|
380
|
+
toolCallId: 't1',
|
|
381
|
+
toolName: 'grep',
|
|
382
|
+
})
|
|
383
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
384
|
+
|
|
385
|
+
const record = readState().sessions[String(process.pid)]
|
|
386
|
+
expect(record?.state).toBe('idle')
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('updates state immediately on event emission', async () => {
|
|
390
|
+
await updateState(() => ({ version: 2, sessions: {} }))
|
|
391
|
+
const pi = makeFakePi()
|
|
392
|
+
const jobTracker = {
|
|
393
|
+
hasActiveJobs: false,
|
|
394
|
+
onEnd: () => () => {},
|
|
395
|
+
} as unknown as JobTracker
|
|
396
|
+
const tracker = new StateTracker(
|
|
397
|
+
pi as unknown as ExtensionAPI,
|
|
398
|
+
jobTracker,
|
|
399
|
+
{
|
|
400
|
+
notifyTools: new Set(['bash']),
|
|
401
|
+
events: { 'permissions:ui_prompt': 'msg' },
|
|
402
|
+
} as unknown as ResolvedNotifyConfig,
|
|
403
|
+
)
|
|
404
|
+
tracker.register(() => {})
|
|
405
|
+
|
|
406
|
+
const store = new SessionStore(pi as unknown as ExtensionAPI, tracker)
|
|
407
|
+
store.register(() => {})
|
|
408
|
+
|
|
409
|
+
pi.emitSessionStart({
|
|
410
|
+
cwd: META.cwd,
|
|
411
|
+
sessionManager: { getSessionId: () => SESSION_ID },
|
|
412
|
+
})
|
|
413
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
414
|
+
|
|
415
|
+
pi.emitEvent('permissions:ui_prompt', {})
|
|
416
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
417
|
+
|
|
418
|
+
const record = readState().sessions[String(process.pid)]
|
|
419
|
+
expect(record?.state).toBe('event:permissions:ui_prompt')
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('ignores tool and event emissions before session_start', async () => {
|
|
423
|
+
await updateState(() => ({ version: 2, sessions: {} }))
|
|
424
|
+
const pi = makeFakePi()
|
|
425
|
+
const stateTracker = makeFakeStateTracker()
|
|
426
|
+
const store = new SessionStore(
|
|
427
|
+
pi as unknown as ExtensionAPI,
|
|
428
|
+
stateTracker as unknown as StateTracker,
|
|
429
|
+
)
|
|
430
|
+
store.register(() => {})
|
|
431
|
+
|
|
432
|
+
stateTracker.events.emit('tool', 'grep')
|
|
433
|
+
stateTracker.events.emit('event', 'permissions:ui_prompt')
|
|
434
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
435
|
+
|
|
436
|
+
expect(readState().sessions).toEqual({})
|
|
437
|
+
})
|
|
290
438
|
})
|
|
@@ -5,13 +5,9 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
|
5
5
|
|
|
6
6
|
import { Registrar } from '../shared/registrar.js'
|
|
7
7
|
import type { StateTracker } from '../state-tracker.js'
|
|
8
|
-
import type { SessionRecord } from './state-store.js'
|
|
8
|
+
import type { SessionRecord, SessionState } from './state-store.js'
|
|
9
9
|
import { updateState } from './state-store.js'
|
|
10
10
|
|
|
11
|
-
export interface SessionUpdate {
|
|
12
|
-
state: 'running' | 'idle'
|
|
13
|
-
}
|
|
14
|
-
|
|
15
11
|
export class SessionStore extends Registrar {
|
|
16
12
|
private readonly stateTracker: StateTracker
|
|
17
13
|
private sessionId: string | undefined = undefined
|
|
@@ -23,7 +19,7 @@ export class SessionStore extends Registrar {
|
|
|
23
19
|
this.stateTracker = stateTracker
|
|
24
20
|
}
|
|
25
21
|
|
|
26
|
-
private async saveSession(
|
|
22
|
+
private async saveSession(nextState: SessionState): Promise<void> {
|
|
27
23
|
if (this.sessionId === undefined || this.meta === undefined) return
|
|
28
24
|
|
|
29
25
|
const now = Date.now()
|
|
@@ -33,7 +29,9 @@ export class SessionStore extends Registrar {
|
|
|
33
29
|
|
|
34
30
|
await updateState((state) => {
|
|
35
31
|
const existing = state.sessions[id]
|
|
36
|
-
const
|
|
32
|
+
const isRunning = nextState === 'running'
|
|
33
|
+
|
|
34
|
+
const startRunningAt = isRunning ? now : undefined
|
|
37
35
|
|
|
38
36
|
const record: SessionRecord = {
|
|
39
37
|
pid: meta.pid,
|
|
@@ -41,8 +39,8 @@ export class SessionStore extends Registrar {
|
|
|
41
39
|
cwd: meta.cwd,
|
|
42
40
|
projectName: meta.projectName,
|
|
43
41
|
startedAt: existing?.startedAt ?? now,
|
|
44
|
-
state:
|
|
45
|
-
|
|
42
|
+
state: nextState,
|
|
43
|
+
startedRunningAt: startRunningAt,
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
return {
|
|
@@ -68,15 +66,21 @@ export class SessionStore extends Registrar {
|
|
|
68
66
|
const sessionId = ctx.sessionManager.getSessionId()
|
|
69
67
|
this.sessionId = sessionId
|
|
70
68
|
this.meta = meta
|
|
71
|
-
void this.saveSession(
|
|
69
|
+
void this.saveSession('idle')
|
|
72
70
|
})
|
|
73
71
|
|
|
74
72
|
this.unsubscribes.push(
|
|
75
73
|
this.stateTracker.events.on('running', async () => {
|
|
76
|
-
await this.saveSession(
|
|
74
|
+
await this.saveSession('running')
|
|
77
75
|
}),
|
|
78
76
|
this.stateTracker.events.on('idle', async () => {
|
|
79
|
-
await this.saveSession(
|
|
77
|
+
await this.saveSession('idle')
|
|
78
|
+
}),
|
|
79
|
+
this.stateTracker.events.on('tool', async ({ data }) => {
|
|
80
|
+
await this.saveSession(`tool_call:${data}`)
|
|
81
|
+
}),
|
|
82
|
+
this.stateTracker.events.on('event', async ({ data }) => {
|
|
83
|
+
await this.saveSession(`event:${data}`)
|
|
80
84
|
}),
|
|
81
85
|
)
|
|
82
86
|
}
|
|
@@ -1,10 +1,89 @@
|
|
|
1
|
-
import { unlinkSync } from 'node:fs'
|
|
2
|
-
import {
|
|
1
|
+
import { rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
afterAll,
|
|
6
|
+
afterEach,
|
|
7
|
+
beforeEach,
|
|
8
|
+
describe,
|
|
9
|
+
expect,
|
|
10
|
+
it,
|
|
11
|
+
vi,
|
|
12
|
+
} from 'vitest'
|
|
6
13
|
|
|
7
|
-
|
|
14
|
+
vi.mock('./consts.js', async () => {
|
|
15
|
+
const { mkdtempSync } = await import('node:fs')
|
|
16
|
+
const path = await import('node:path')
|
|
17
|
+
const { tmpdir } = await import('node:os')
|
|
18
|
+
|
|
19
|
+
const stateDir = mkdtempSync(path.join(tmpdir(), 'pi-notify-test-'))
|
|
20
|
+
return {
|
|
21
|
+
STATE_FILE: path.join(stateDir, 'state.json'),
|
|
22
|
+
STATE_TMP_FILE: path.join(stateDir, 'state.json.tmp'),
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterAll(() => {
|
|
27
|
+
rmSync(dirname(STATE_FILE), { recursive: true, force: true })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
import { STATE_FILE } from './consts.js'
|
|
31
|
+
import {
|
|
32
|
+
isProcessAlive,
|
|
33
|
+
readSessions,
|
|
34
|
+
readState,
|
|
35
|
+
updateState,
|
|
36
|
+
} from './state-store.js'
|
|
37
|
+
|
|
38
|
+
describe('readState', () => {
|
|
39
|
+
const testLockFile = `${STATE_FILE}.lock`
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
try {
|
|
43
|
+
unlinkSync(testLockFile)
|
|
44
|
+
unlinkSync(STATE_FILE)
|
|
45
|
+
} catch {
|
|
46
|
+
// ignore
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('writes state with version 2', async () => {
|
|
51
|
+
await updateState((state) => ({ ...state, sessions: {} }))
|
|
52
|
+
expect(readState().version).toBe(2)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('reads v1 records as-is with absent v2 fields', () => {
|
|
56
|
+
const record = {
|
|
57
|
+
pid: process.pid,
|
|
58
|
+
sessionId: 'v1-session',
|
|
59
|
+
cwd: process.cwd(),
|
|
60
|
+
projectName: 'v1-project',
|
|
61
|
+
startedAt: Date.now(),
|
|
62
|
+
state: 'idle',
|
|
63
|
+
}
|
|
64
|
+
writeFileSync(
|
|
65
|
+
STATE_FILE,
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
version: 1,
|
|
68
|
+
sessions: { [String(process.pid)]: record },
|
|
69
|
+
}),
|
|
70
|
+
'utf8',
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const state = readState()
|
|
74
|
+
expect(state.version).toBe(2)
|
|
75
|
+
expect(state.sessions[String(process.pid)]).toEqual(record)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('drops malformed session records instead of crashing', () => {
|
|
79
|
+
writeFileSync(
|
|
80
|
+
STATE_FILE,
|
|
81
|
+
JSON.stringify({ version: 2, sessions: { x: null } }),
|
|
82
|
+
'utf8',
|
|
83
|
+
)
|
|
84
|
+
expect(readState().sessions).toEqual({ x: undefined })
|
|
85
|
+
})
|
|
86
|
+
})
|
|
8
87
|
|
|
9
88
|
describe('isProcessAlive', () => {
|
|
10
89
|
it('returns true for current process pid', () => {
|
|
@@ -52,8 +131,7 @@ describe('isProcessAlive', () => {
|
|
|
52
131
|
})
|
|
53
132
|
|
|
54
133
|
describe('readSessions', () => {
|
|
55
|
-
const
|
|
56
|
-
const testLockFile = join(testDir, 'state.json.lock')
|
|
134
|
+
const testLockFile = `${STATE_FILE}.lock`
|
|
57
135
|
|
|
58
136
|
beforeEach(async () => {
|
|
59
137
|
try {
|
|
@@ -61,7 +139,7 @@ describe('readSessions', () => {
|
|
|
61
139
|
} catch {
|
|
62
140
|
// ignore
|
|
63
141
|
}
|
|
64
|
-
await updateState(() => ({ version:
|
|
142
|
+
await updateState(() => ({ version: 2, sessions: {} }))
|
|
65
143
|
})
|
|
66
144
|
|
|
67
145
|
afterEach(() => {
|
|
@@ -90,7 +168,6 @@ describe('readSessions', () => {
|
|
|
90
168
|
projectName: 'alive-project',
|
|
91
169
|
startedAt: Date.now(),
|
|
92
170
|
state: 'running',
|
|
93
|
-
stateChangedAt: Date.now(),
|
|
94
171
|
},
|
|
95
172
|
[deadKey]: {
|
|
96
173
|
pid: deadPid,
|
|
@@ -99,7 +176,6 @@ describe('readSessions', () => {
|
|
|
99
176
|
projectName: 'dead-project',
|
|
100
177
|
startedAt: Date.now(),
|
|
101
178
|
state: 'running',
|
|
102
|
-
stateChangedAt: Date.now(),
|
|
103
179
|
},
|
|
104
180
|
},
|
|
105
181
|
}))
|
|
@@ -128,7 +204,6 @@ describe('readSessions', () => {
|
|
|
128
204
|
projectName: 'test-project',
|
|
129
205
|
startedAt: Date.now(),
|
|
130
206
|
state: 'running',
|
|
131
|
-
stateChangedAt: Date.now(),
|
|
132
207
|
},
|
|
133
208
|
},
|
|
134
209
|
}))
|
|
@@ -140,8 +215,7 @@ describe('readSessions', () => {
|
|
|
140
215
|
})
|
|
141
216
|
|
|
142
217
|
describe('updateState', () => {
|
|
143
|
-
const
|
|
144
|
-
const testLockFile = join(testDir, 'state.json.lock')
|
|
218
|
+
const testLockFile = `${STATE_FILE}.lock`
|
|
145
219
|
|
|
146
220
|
beforeEach(() => {
|
|
147
221
|
try {
|
|
@@ -171,7 +245,6 @@ describe('updateState', () => {
|
|
|
171
245
|
projectName: 'test-project',
|
|
172
246
|
startedAt: Date.now(),
|
|
173
247
|
state: 'running',
|
|
174
|
-
stateChangedAt: Date.now(),
|
|
175
248
|
},
|
|
176
249
|
},
|
|
177
250
|
}))
|
|
@@ -191,7 +264,6 @@ describe('updateState', () => {
|
|
|
191
264
|
projectName: 'concurrent-test',
|
|
192
265
|
startedAt: Date.now(),
|
|
193
266
|
state: 'running',
|
|
194
|
-
stateChangedAt: Date.now(),
|
|
195
267
|
},
|
|
196
268
|
},
|
|
197
269
|
}))
|
|
@@ -204,7 +276,7 @@ describe('updateState', () => {
|
|
|
204
276
|
[sessionId]: state.sessions[sessionId]
|
|
205
277
|
? {
|
|
206
278
|
...state.sessions[sessionId],
|
|
207
|
-
|
|
279
|
+
startedAt: Date.now(),
|
|
208
280
|
}
|
|
209
281
|
: undefined,
|
|
210
282
|
},
|
|
@@ -216,7 +288,7 @@ describe('updateState', () => {
|
|
|
216
288
|
[sessionId]: state.sessions[sessionId]
|
|
217
289
|
? {
|
|
218
290
|
...state.sessions[sessionId],
|
|
219
|
-
|
|
291
|
+
startedAt: Date.now() + 1,
|
|
220
292
|
}
|
|
221
293
|
: undefined,
|
|
222
294
|
},
|