@gotcos/glasses-server 6.47.0 → 6.48.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 +12 -0
- package/README.md +24 -0
- package/bin/cli.cjs +22 -0
- package/bin/hooks/cos-session-hook +43 -0
- package/managed-runtime-contract.json +7 -1
- package/package.json +3 -1
- package/server/index.ts +9 -0
- package/server/lib/claude-hooks-installer.ts +403 -0
- package/server/lib/claude-session-registry.ts +25 -0
- package/server/lib/session-hook-events.ts +200 -0
- package/server/lib/session-hook-ledger.ts +129 -0
- package/server/lib/session-hook-spool.ts +264 -0
- package/server/lib/session-hooks-runtime.ts +229 -0
- package/server/lib/session-signal-store.ts +361 -0
- package/server/lib/session-state-derive.ts +211 -0
- package/server/routes/agent-sessions.ts +56 -6
- package/server/routes/claude-sessions.ts +32 -5
- package/server/routes/health.ts +2 -0
- package/server/routes/session-hooks.ts +70 -0
- package/server/scripts/hooks-cli.ts +48 -0
- package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +0 -2
|
@@ -43,6 +43,7 @@ export interface RawClaudeSession {
|
|
|
43
43
|
status?: unknown
|
|
44
44
|
updatedAt?: unknown
|
|
45
45
|
waitingFor?: unknown
|
|
46
|
+
statusUpdatedAt?: unknown
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
export interface ClaudePeer {
|
|
@@ -63,6 +64,30 @@ export interface ClaudePeer {
|
|
|
63
64
|
startedAt: number | null
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* A peer plus the facts the state deriver needs and the wire must not carry: the full
|
|
69
|
+
* session id (the wire shortens it on purpose) and when `status` last moved. Built by
|
|
70
|
+
* `readClaudePeerRecords`; `readClaudePeers` strips it back to the wire shape.
|
|
71
|
+
*/
|
|
72
|
+
export interface ClaudePeerRecord extends ClaudePeer {
|
|
73
|
+
sessionId: string
|
|
74
|
+
pid: number
|
|
75
|
+
statusUpdatedAt: number | null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function peerRecordFacts(raw: RawClaudeSession): { sessionId: string; pid: number; statusUpdatedAt: number | null } | null {
|
|
79
|
+
const pid = Number(raw.pid)
|
|
80
|
+
const sessionId = typeof raw.sessionId === 'string' ? raw.sessionId : ''
|
|
81
|
+
if (!Number.isInteger(pid) || pid <= 0 || !sessionId) return null
|
|
82
|
+
return { sessionId: sessionId.toLowerCase(), pid, statusUpdatedAt: millis(raw.statusUpdatedAt) }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The wire shape and nothing else: a record is never serialized as is. */
|
|
86
|
+
export function toWirePeer(record: ClaudePeerRecord): ClaudePeer {
|
|
87
|
+
const { sessionId: _sessionId, pid: _pid, statusUpdatedAt: _statusUpdatedAt, ...peer } = record
|
|
88
|
+
return peer
|
|
89
|
+
}
|
|
90
|
+
|
|
66
91
|
export interface PeerProbes {
|
|
67
92
|
/** signal-0 liveness. EPERM (another user) must resolve to false, not true. */
|
|
68
93
|
isAlive: (pid: number) => boolean
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// The hook envelope: what `bin/hooks/cos-session-hook` writes, and the only shape the
|
|
2
|
+
// ingester, the ledger and the signal store ever read.
|
|
3
|
+
//
|
|
4
|
+
// One file per Claude Code hook event, spooled by a POSIX sh script that never talks to
|
|
5
|
+
// this server (PermissionRequest excepted). The script wraps the payload Claude piped to
|
|
6
|
+
// it: `{"ts":<ms>,"ppid":<pid>,"event":"<name>","payload":<stdin>}`.
|
|
7
|
+
//
|
|
8
|
+
// FIELD NAMES COME FROM RECORDED 2.1.272 PAYLOADS WHERE ONE EXISTS, never from the docs
|
|
9
|
+
// alone. The docs said `start_reason`, `end_reason`, `stop_reason` and a `tool_use_id` on
|
|
10
|
+
// PermissionRequest; the recordings (2026-09-15) carry `source`, `reason`, no stop reason
|
|
11
|
+
// at all, and no tool_use_id on PermissionRequest. Fixtures under
|
|
12
|
+
// `__fixtures__/session-hooks-6.48.0/` are those recordings; their README lists the
|
|
13
|
+
// interactive-only events (Notification, StopFailure, sub-agents, compaction, model switch)
|
|
14
|
+
// whose shapes are still the documented ones until a Desktop recording lands.
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
|
|
18
|
+
/** Events the installer subscribes and the reducer understands. Anything else is dropped. */
|
|
19
|
+
export const HOOK_EVENT_NAMES = [
|
|
20
|
+
'SessionStart',
|
|
21
|
+
'SessionEnd',
|
|
22
|
+
'UserPromptSubmit',
|
|
23
|
+
'Stop',
|
|
24
|
+
'StopFailure',
|
|
25
|
+
'PermissionRequest',
|
|
26
|
+
'PermissionDenied',
|
|
27
|
+
'PreToolUse',
|
|
28
|
+
'PostToolUse',
|
|
29
|
+
'PostToolUseFailure',
|
|
30
|
+
'Notification',
|
|
31
|
+
'SubagentStart',
|
|
32
|
+
'SubagentStop',
|
|
33
|
+
'PostCompact',
|
|
34
|
+
'PostModelSwitch',
|
|
35
|
+
] as const
|
|
36
|
+
|
|
37
|
+
export type HookEventName = typeof HOOK_EVENT_NAMES[number]
|
|
38
|
+
|
|
39
|
+
const EVENT_SET: ReadonlySet<string> = new Set(HOOK_EVENT_NAMES)
|
|
40
|
+
|
|
41
|
+
export function isHookEventName(value: unknown): value is HookEventName {
|
|
42
|
+
return typeof value === 'string' && EVENT_SET.has(value)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A full Claude session id: the transcript basename and the registry `sessionId`. */
|
|
46
|
+
export const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
47
|
+
|
|
48
|
+
/** The spool file is capped by the script at 1 MiB of stdin; the envelope adds a few bytes. */
|
|
49
|
+
export const HOOK_ENVELOPE_MAX_BYTES = 1_048_576 + 256
|
|
50
|
+
|
|
51
|
+
export interface HookEnvelope {
|
|
52
|
+
/** Milliseconds, stamped by the script when the hook ran. */
|
|
53
|
+
ts: number
|
|
54
|
+
/** The hook process's parent pid: the claude process, or the `sh -c` wrapper. */
|
|
55
|
+
ppid: number | null
|
|
56
|
+
event: HookEventName
|
|
57
|
+
sessionId: string
|
|
58
|
+
payload: Record<string, unknown>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What a regex can still read off the head of a file that did not parse: enough for the
|
|
63
|
+
* store to apply a payload-less event so a cut Stop still turns a row idle, never enough
|
|
64
|
+
* to trust any other field.
|
|
65
|
+
*/
|
|
66
|
+
export interface SalvagedHookHead {
|
|
67
|
+
sessionId: string
|
|
68
|
+
event: HookEventName
|
|
69
|
+
ts: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type ParsedHookEnvelope =
|
|
73
|
+
| { ok: true; envelope: HookEnvelope }
|
|
74
|
+
| { ok: false; reason: 'too_large' | 'not_json' | 'not_envelope' | 'unknown_event' | 'no_session'; salvaged: SalvagedHookHead | null }
|
|
75
|
+
|
|
76
|
+
const SALVAGE_SESSION_RE = /"session_id"\s*:\s*"([0-9a-f-]{36})"/i
|
|
77
|
+
const SALVAGE_EVENT_RE = /"event"\s*:\s*"([A-Za-z]+)"/
|
|
78
|
+
const SALVAGE_TS_RE = /"ts"\s*:\s*(\d{10,16})/
|
|
79
|
+
|
|
80
|
+
function salvage(text: string): SalvagedHookHead | null {
|
|
81
|
+
const head = text.slice(0, 4096)
|
|
82
|
+
const session = SALVAGE_SESSION_RE.exec(head)?.[1]
|
|
83
|
+
const event = SALVAGE_EVENT_RE.exec(head)?.[1]
|
|
84
|
+
const ts = Number(SALVAGE_TS_RE.exec(head)?.[1])
|
|
85
|
+
if (!session || !SESSION_ID_RE.test(session) || !isHookEventName(event) || !Number.isFinite(ts)) return null
|
|
86
|
+
return { sessionId: session.toLowerCase(), event, ts }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function parseHookEnvelope(text: string): ParsedHookEnvelope {
|
|
90
|
+
if (text.length > HOOK_ENVELOPE_MAX_BYTES) return { ok: false, reason: 'too_large', salvaged: salvage(text) }
|
|
91
|
+
let raw: unknown
|
|
92
|
+
try { raw = JSON.parse(text) } catch { return { ok: false, reason: 'not_json', salvaged: salvage(text) } }
|
|
93
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, reason: 'not_envelope', salvaged: null }
|
|
94
|
+
const r = raw as Record<string, unknown>
|
|
95
|
+
const ts = Number(r.ts)
|
|
96
|
+
const payload = r.payload
|
|
97
|
+
if (!Number.isFinite(ts) || !payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
98
|
+
return { ok: false, reason: 'not_envelope', salvaged: salvage(text) }
|
|
99
|
+
}
|
|
100
|
+
if (!isHookEventName(r.event)) return { ok: false, reason: 'unknown_event', salvaged: null }
|
|
101
|
+
const p = payload as Record<string, unknown>
|
|
102
|
+
const sessionId = typeof p.session_id === 'string' && SESSION_ID_RE.test(p.session_id) ? p.session_id.toLowerCase() : null
|
|
103
|
+
if (!sessionId) return { ok: false, reason: 'no_session', salvaged: null }
|
|
104
|
+
const ppid = Number(r.ppid)
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
envelope: {
|
|
108
|
+
ts,
|
|
109
|
+
ppid: Number.isInteger(ppid) && ppid > 0 ? ppid : null,
|
|
110
|
+
event: r.event,
|
|
111
|
+
sessionId,
|
|
112
|
+
payload: p,
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Pin the fingerprint the broker and the reducer share: tool name + canonical input. */
|
|
118
|
+
export function toolFingerprint(toolName: string, toolInput: unknown): string {
|
|
119
|
+
return createHash('sha256').update(toolName).update('\0').update(canonicalJson(toolInput)).digest('hex')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function canonicalJson(value: unknown): string {
|
|
123
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null)
|
|
124
|
+
if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']'
|
|
125
|
+
const o = value as Record<string, unknown>
|
|
126
|
+
return '{' + Object.keys(o).sort().map(k => JSON.stringify(k) + ':' + canonicalJson(o[k])).join(',') + '}'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The 80-char target a waiting line shows: the command, path or URL the tool was called with. */
|
|
130
|
+
export function toolTarget(toolInput: unknown, max = 80): string {
|
|
131
|
+
if (!toolInput || typeof toolInput !== 'object') return ''
|
|
132
|
+
const i = toolInput as Record<string, unknown>
|
|
133
|
+
const candidate = [i.command, i.file_path, i.path, i.url, i.pattern, i.query, i.prompt, i.description]
|
|
134
|
+
.find(v => typeof v === 'string' && v.trim().length > 0)
|
|
135
|
+
const text = typeof candidate === 'string' ? candidate.replace(/\s+/g, ' ').trim() : ''
|
|
136
|
+
return text.length > max ? text.slice(0, max - 1) + '…' : text
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Text fields kept from a payload. Everything else is dropped before the ledger sees it. */
|
|
140
|
+
export const HOOK_TEXT_MAX = 400
|
|
141
|
+
|
|
142
|
+
export function clipText(value: unknown, max = HOOK_TEXT_MAX): string {
|
|
143
|
+
if (typeof value !== 'string') return ''
|
|
144
|
+
const text = value.trim()
|
|
145
|
+
return text.length > max ? text.slice(0, max - 1) + '…' : text
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* What the ledger keeps of a payload. A PostToolUse carries the whole tool_response (a
|
|
150
|
+
* Read can exceed 100 KB) and a Stop the whole reply; neither belongs in a durable
|
|
151
|
+
* 10 MB ring. The reducer needs exactly these.
|
|
152
|
+
*/
|
|
153
|
+
export function projectHookPayload(event: HookEventName, payload: Record<string, unknown>): Record<string, unknown> {
|
|
154
|
+
const str = (k: string) => (typeof payload[k] === 'string' ? payload[k] as string : undefined)
|
|
155
|
+
const base: Record<string, unknown> = {
|
|
156
|
+
session_id: str('session_id'),
|
|
157
|
+
...(str('transcript_path') ? { transcript_path: str('transcript_path') } : {}),
|
|
158
|
+
...(str('cwd') ? { cwd: str('cwd') } : {}),
|
|
159
|
+
...(str('permission_mode') ? { permission_mode: str('permission_mode') } : {}),
|
|
160
|
+
...(str('prompt_id') ? { prompt_id: str('prompt_id') } : {}),
|
|
161
|
+
}
|
|
162
|
+
switch (event) {
|
|
163
|
+
case 'SessionStart':
|
|
164
|
+
return { ...base, source: str('source'), ...(str('model') ? { model: str('model') } : {}) }
|
|
165
|
+
case 'SessionEnd':
|
|
166
|
+
return { ...base, reason: str('reason') }
|
|
167
|
+
case 'UserPromptSubmit':
|
|
168
|
+
return { ...base, prompt: clipText(payload.prompt) }
|
|
169
|
+
case 'Stop':
|
|
170
|
+
return { ...base, last_assistant_message: clipText(payload.last_assistant_message), stop_hook_active: payload.stop_hook_active === true }
|
|
171
|
+
case 'StopFailure':
|
|
172
|
+
return { ...base, error: clipText(payload.error ?? payload.error_type ?? payload.message, 120), matcher: str('matcher') ?? str('error_type') }
|
|
173
|
+
case 'PermissionRequest':
|
|
174
|
+
case 'PermissionDenied':
|
|
175
|
+
case 'PreToolUse':
|
|
176
|
+
case 'PostToolUse':
|
|
177
|
+
case 'PostToolUseFailure': {
|
|
178
|
+
const toolName = str('tool_name') ?? ''
|
|
179
|
+
return {
|
|
180
|
+
...base,
|
|
181
|
+
tool_name: toolName,
|
|
182
|
+
tool_target: toolTarget(payload.tool_input),
|
|
183
|
+
tool_fingerprint: toolFingerprint(toolName, payload.tool_input ?? null),
|
|
184
|
+
...(str('tool_use_id') ? { tool_use_id: str('tool_use_id') } : {}),
|
|
185
|
+
...(event === 'PermissionRequest' && Array.isArray(payload.permission_suggestions)
|
|
186
|
+
? { permission_suggestions: payload.permission_suggestions.slice(0, 4) }
|
|
187
|
+
: {}),
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
case 'Notification':
|
|
191
|
+
return { ...base, notification_type: str('notification_type'), message: clipText(payload.message, 160) }
|
|
192
|
+
case 'SubagentStart':
|
|
193
|
+
case 'SubagentStop':
|
|
194
|
+
return { ...base, agent_id: str('agent_id'), agent_type: str('agent_type') }
|
|
195
|
+
case 'PostModelSwitch':
|
|
196
|
+
return { ...base, from_model: str('from_model'), to_model: str('to_model') }
|
|
197
|
+
case 'PostCompact':
|
|
198
|
+
return base
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// The durable trail of hook events: `session-hook-events.jsonl`, one projected row per
|
|
2
|
+
// envelope, rotated once at 10 MB, replayed on boot to warm the signal store.
|
|
3
|
+
//
|
|
4
|
+
// The ledger keeps the PROJECTION (`projectHookPayload`), never the raw payload: a
|
|
5
|
+
// PostToolUse response or a full reply would churn the ring in minutes. Each row carries
|
|
6
|
+
// the spool filename as its key so a crash between append and unlink replays without
|
|
7
|
+
// applying the same event twice.
|
|
8
|
+
|
|
9
|
+
import { appendFileSync, existsSync, readFileSync, renameSync, statSync } from 'node:fs'
|
|
10
|
+
import type { HookEnvelope, HookEventName } from './session-hook-events.js'
|
|
11
|
+
import { projectHookPayload, isHookEventName, SESSION_ID_RE } from './session-hook-events.js'
|
|
12
|
+
|
|
13
|
+
export const LEDGER_ROTATE_BYTES = 10 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
export interface LedgerRow {
|
|
16
|
+
key: string
|
|
17
|
+
ts: number
|
|
18
|
+
ppid: number | null
|
|
19
|
+
event: HookEventName
|
|
20
|
+
session_id: string
|
|
21
|
+
/** Classified at ingest, while the pid was alive: a COS-spawned child's event. */
|
|
22
|
+
child?: boolean
|
|
23
|
+
payload: Record<string, unknown>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function codeOf(error: unknown): string {
|
|
27
|
+
const code = (error as { code?: unknown })?.code
|
|
28
|
+
return typeof code === 'string' && code ? code : (error instanceof Error ? error.name || 'Error' : 'error')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readText(path: string): string {
|
|
32
|
+
if (!existsSync(path)) return ''
|
|
33
|
+
try { return readFileSync(path, 'utf-8') } catch { return '' }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function oldestTs(text: string): number | null {
|
|
37
|
+
for (const line of text.split('\n')) {
|
|
38
|
+
if (!line.trim()) continue
|
|
39
|
+
try {
|
|
40
|
+
const ts = (JSON.parse(line) as { ts?: unknown }).ts
|
|
41
|
+
if (typeof ts === 'number') return ts
|
|
42
|
+
} catch { continue }
|
|
43
|
+
}
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class SessionHookLedger {
|
|
48
|
+
readonly path: string
|
|
49
|
+
private lastError: string | null = null
|
|
50
|
+
private appended = 0
|
|
51
|
+
|
|
52
|
+
constructor(path: string) {
|
|
53
|
+
this.path = path
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Append one row. Returns false (and remembers why) when the disk refused. */
|
|
57
|
+
append(key: string, env: HookEnvelope, child = false): boolean {
|
|
58
|
+
let row: LedgerRow
|
|
59
|
+
try {
|
|
60
|
+
row = {
|
|
61
|
+
key,
|
|
62
|
+
ts: env.ts,
|
|
63
|
+
ppid: env.ppid,
|
|
64
|
+
event: env.event,
|
|
65
|
+
session_id: env.sessionId,
|
|
66
|
+
...(child ? { child: true } : {}),
|
|
67
|
+
payload: projectHookPayload(env.event, env.payload),
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
// A pathological payload must not escape into the sweep; the caller rejects the file.
|
|
71
|
+
this.lastError = codeOf(error)
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
this.rotateIfNeeded()
|
|
76
|
+
appendFileSync(this.path, JSON.stringify(row) + '\n', { mode: 0o600 })
|
|
77
|
+
this.appended++
|
|
78
|
+
this.lastError = null
|
|
79
|
+
return true
|
|
80
|
+
} catch (error) {
|
|
81
|
+
this.lastError = codeOf(error)
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private rotateIfNeeded(): void {
|
|
87
|
+
let size = 0
|
|
88
|
+
try { size = statSync(this.path).size } catch { return }
|
|
89
|
+
if (size < LEDGER_ROTATE_BYTES) return
|
|
90
|
+
try { renameSync(this.path, this.path + '.1') } catch { /* the next append tries again */ }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Replay rows newer than `sinceMs`. The rotated `.1` is read too whenever the current
|
|
95
|
+
* file does not reach back to `sinceMs` (a rotation inside the window would otherwise
|
|
96
|
+
* hide the rows that matter most, and forget their keys). Malformed lines are skipped;
|
|
97
|
+
* a boot must never fail on a torn last line.
|
|
98
|
+
*/
|
|
99
|
+
replay(sinceMs: number, apply: (env: HookEnvelope, key: string, child: boolean) => void): { rows: number; applied: number; keys: Set<string> } {
|
|
100
|
+
const keys = new Set<string>()
|
|
101
|
+
let rows = 0
|
|
102
|
+
let applied = 0
|
|
103
|
+
const current = readText(this.path)
|
|
104
|
+
const oldest = oldestTs(current)
|
|
105
|
+
const texts = oldest !== null && oldest > sinceMs ? [readText(this.path + '.1'), current] : [current]
|
|
106
|
+
for (const line of texts.join('\n').split('\n')) {
|
|
107
|
+
if (!line.trim()) continue
|
|
108
|
+
let row: unknown
|
|
109
|
+
try { row = JSON.parse(line) } catch { continue }
|
|
110
|
+
if (!row || typeof row !== 'object') continue
|
|
111
|
+
const r = row as Partial<LedgerRow>
|
|
112
|
+
rows++
|
|
113
|
+
if (typeof r.key !== 'string' || typeof r.ts !== 'number' || !isHookEventName(r.event)) continue
|
|
114
|
+
if (typeof r.session_id !== 'string' || !SESSION_ID_RE.test(r.session_id)) continue
|
|
115
|
+
keys.add(r.key)
|
|
116
|
+
if (r.ts < sinceMs) continue
|
|
117
|
+
const payload = r.payload && typeof r.payload === 'object' ? r.payload as Record<string, unknown> : {}
|
|
118
|
+
apply({ ts: r.ts, ppid: typeof r.ppid === 'number' ? r.ppid : null, event: r.event, sessionId: r.session_id.toLowerCase(), payload: { session_id: r.session_id, ...payload } }, r.key, r.child === true)
|
|
119
|
+
applied++
|
|
120
|
+
}
|
|
121
|
+
return { rows, applied, keys }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
stats(): { bytes: number; appended: number; lastError: string | null } {
|
|
125
|
+
let bytes = 0
|
|
126
|
+
try { bytes = statSync(this.path).size } catch { /* none yet */ }
|
|
127
|
+
return { bytes, appended: this.appended, lastError: this.lastError }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// The spool ingester: reads the one-file-per-event spool `bin/hooks/cos-session-hook`
|
|
2
|
+
// writes, ledgers each event, applies it to the signal store, and unlinks the file.
|
|
3
|
+
//
|
|
4
|
+
// THE FILE IS THE BUS. The hook script never talks to this server, so a session's
|
|
5
|
+
// hooks keep spooling while the managed runtime restarts or is stopped for a week; the
|
|
6
|
+
// startup drain applies the backlog in filename order (millisecond timestamp, then pid)
|
|
7
|
+
// and `state_since` comes from each file's own stamp, not its arrival.
|
|
8
|
+
//
|
|
9
|
+
// Same poll discipline as `session-transcript-watcher.ts`: a `fs.watch` on the directory
|
|
10
|
+
// is only a wake-up, the 2 s readdir sweep is the guaranteed path, one sweep at a time
|
|
11
|
+
// (`ticking`), timer unref'd so it never holds the process open. A sweep applies at most
|
|
12
|
+
// BATCH files, so a 2,000-file backlog is drained in bounded slices of the event loop.
|
|
13
|
+
//
|
|
14
|
+
// Off is still a drain. With `COS_SESSION_HOOKS=0` the ingester runs with `apply` set to
|
|
15
|
+
// nothing: it ledgers, unlinks and stamps `.last-drain`, because the script's own guard
|
|
16
|
+
// stops spooling when that stamp goes stale, and a spool nobody reads must not grow.
|
|
17
|
+
|
|
18
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, utimesSync, watch, writeFileSync, type FSWatcher } from 'node:fs'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
import type { HookEnvelope } from './session-hook-events.js'
|
|
21
|
+
import { parseHookEnvelope } from './session-hook-events.js'
|
|
22
|
+
import type { SessionHookLedger } from './session-hook-ledger.js'
|
|
23
|
+
|
|
24
|
+
export const SPOOL_SWEEP_MS = 2_000
|
|
25
|
+
export const SPOOL_BATCH = 200
|
|
26
|
+
export const REJECTED_KEEP = 1_000
|
|
27
|
+
export const REJECTED_MAX_AGE_MS = 7 * 24 * 60 * 60_000
|
|
28
|
+
export const LAST_DRAIN_STAMP = '.last-drain'
|
|
29
|
+
|
|
30
|
+
const SPOOL_FILE_RE = /^(\d{10,16})-(\d+)-([A-Za-z]+)\.json$/
|
|
31
|
+
|
|
32
|
+
/** A code, never a message: raw fs messages carry the home path and health is public. */
|
|
33
|
+
export function errorCode(error: unknown): string {
|
|
34
|
+
const code = (error as { code?: unknown })?.code
|
|
35
|
+
if (typeof code === 'string' && code) return code
|
|
36
|
+
return error instanceof Error ? error.name || 'Error' : 'error'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A hook killed mid-write leaves `.tmp.*`; reap those older than this. */
|
|
40
|
+
export const TMP_MAX_AGE_MS = 10 * 60_000
|
|
41
|
+
/** The startup drain never loops more times than a full spool needs. */
|
|
42
|
+
export const STARTUP_MAX_PASSES = Math.ceil(2_000 / SPOOL_BATCH) + 1
|
|
43
|
+
|
|
44
|
+
export interface SpoolIngesterOptions {
|
|
45
|
+
dir: string
|
|
46
|
+
ledger: SessionHookLedger
|
|
47
|
+
/** Apply a parsed envelope to the store. Absent when the feature is off. */
|
|
48
|
+
apply?: (env: HookEnvelope, child: boolean) => void
|
|
49
|
+
/** Classify the envelope's ppid at INGEST time, while the pid is still alive. */
|
|
50
|
+
isChild?: (env: HookEnvelope) => boolean
|
|
51
|
+
/** Keys already in the ledger at boot, so a replayed file is not applied twice. */
|
|
52
|
+
seenKeys?: Set<string>
|
|
53
|
+
sweepMs?: number
|
|
54
|
+
batch?: number
|
|
55
|
+
now?: () => number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SpoolStats {
|
|
59
|
+
ingested: number
|
|
60
|
+
rejected: number
|
|
61
|
+
salvaged: number
|
|
62
|
+
duplicates: number
|
|
63
|
+
/** Files a sweep visited and could not remove (ledger refused, unreadable). */
|
|
64
|
+
stuck: number
|
|
65
|
+
/** Non-`.json` names in the spool that are not ours; they count against the script's cap. */
|
|
66
|
+
foreign: number
|
|
67
|
+
applyErrors: number
|
|
68
|
+
backlog: number
|
|
69
|
+
lastSweepAt: number | null
|
|
70
|
+
lastEventAt: number | null
|
|
71
|
+
lastError: string | null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface SpoolIngester {
|
|
75
|
+
sweep(): number
|
|
76
|
+
stop(): void
|
|
77
|
+
stats(): SpoolStats
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function startSpoolIngester(options: SpoolIngesterOptions): SpoolIngester {
|
|
81
|
+
const { dir, ledger } = options
|
|
82
|
+
const now = options.now ?? (() => Date.now())
|
|
83
|
+
const sweepMs = options.sweepMs ?? SPOOL_SWEEP_MS
|
|
84
|
+
const batch = options.batch ?? SPOOL_BATCH
|
|
85
|
+
const seen = options.seenKeys ?? new Set<string>()
|
|
86
|
+
const rejectedDir = join(dir, 'rejected')
|
|
87
|
+
const stats: SpoolStats = { ingested: 0, rejected: 0, salvaged: 0, duplicates: 0, stuck: 0, foreign: 0, applyErrors: 0, backlog: 0, lastSweepAt: null, lastEventAt: null, lastError: null }
|
|
88
|
+
let ticking = false
|
|
89
|
+
let stopped = false
|
|
90
|
+
let watcher: FSWatcher | null = null
|
|
91
|
+
let pruneCounter = 0
|
|
92
|
+
let lastStampAt = 0
|
|
93
|
+
let loggedStuck = false
|
|
94
|
+
|
|
95
|
+
try { mkdirSync(rejectedDir, { recursive: true, mode: 0o700 }) } catch { /* the sweep reports it */ }
|
|
96
|
+
|
|
97
|
+
// The stamp is a metadata change inside the watched directory, so it would wake the
|
|
98
|
+
// watcher, which would sweep, which would stamp: touched at most once per sweep interval.
|
|
99
|
+
const stamp = () => {
|
|
100
|
+
const t = now()
|
|
101
|
+
if (t - lastStampAt < sweepMs) return
|
|
102
|
+
lastStampAt = t
|
|
103
|
+
const path = join(dir, LAST_DRAIN_STAMP)
|
|
104
|
+
try {
|
|
105
|
+
if (existsSync(path)) { const d = new Date(t); utimesSync(path, d, d) } else writeFileSync(path, '', { mode: 0o600 })
|
|
106
|
+
} catch { /* a missing stamp only makes the script cautious */ }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Spool order: the millisecond stamp, then the pid, both as numbers (a pid wrap must not reorder). */
|
|
110
|
+
const spoolOrder = (a: string, b: string): number => {
|
|
111
|
+
const ma = SPOOL_FILE_RE.exec(a)!, mb = SPOOL_FILE_RE.exec(b)!
|
|
112
|
+
return (Number(ma[1]) - Number(mb[1])) || (Number(ma[2]) - Number(mb[2])) || a.localeCompare(b)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const listSpool = (): string[] => {
|
|
116
|
+
let names: string[]
|
|
117
|
+
try { names = readdirSync(dir) } catch (error) {
|
|
118
|
+
stats.lastError = errorCode(error)
|
|
119
|
+
return []
|
|
120
|
+
}
|
|
121
|
+
// A `.json` whose name the script never writes (a partial rename) is moved aside so the
|
|
122
|
+
// count the script sees stays honest and `backlog` never hides it. Anything else that is
|
|
123
|
+
// not ours (a stray file someone dropped here) is left where it is and only counted.
|
|
124
|
+
let foreign = 0
|
|
125
|
+
for (const n of names) {
|
|
126
|
+
if (SPOOL_FILE_RE.test(n) || n === 'rejected' || n === LAST_DRAIN_STAMP) continue
|
|
127
|
+
if (n.startsWith('.tmp.')) {
|
|
128
|
+
try { if (now() - statSync(join(dir, n)).mtimeMs > TMP_MAX_AGE_MS) unlinkSync(join(dir, n)) } catch { /* fine */ }
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
if (n.startsWith('.')) continue
|
|
132
|
+
if (!n.endsWith('.json')) { foreign++; continue }
|
|
133
|
+
try { renameSync(join(dir, n), join(rejectedDir, `${n}.unrecognized`)) ; stats.rejected++ } catch { /* fine */ }
|
|
134
|
+
}
|
|
135
|
+
stats.foreign = foreign
|
|
136
|
+
return names.filter(n => SPOOL_FILE_RE.test(n)).sort(spoolOrder)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const reject = (name: string, reason: string) => {
|
|
140
|
+
stats.rejected++
|
|
141
|
+
try { renameSync(join(dir, name), join(rejectedDir, `${name}.${reason}`)) } catch {
|
|
142
|
+
try { unlinkSync(join(dir, name)) } catch { /* gone already */ }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** True when the file is gone afterwards (ingested, rejected or deduped); false when it stays. */
|
|
147
|
+
const ingestOne = (name: string): boolean => {
|
|
148
|
+
const path = join(dir, name)
|
|
149
|
+
let text: string
|
|
150
|
+
try { text = readFileSync(path, 'utf-8') } catch {
|
|
151
|
+
// Raced a rename, or unreadable: the next sweep sees it or not; it is not progress.
|
|
152
|
+
return !existsSync(path)
|
|
153
|
+
}
|
|
154
|
+
let parsed: ReturnType<typeof parseHookEnvelope>
|
|
155
|
+
try { parsed = parseHookEnvelope(text) } catch (error) {
|
|
156
|
+
stats.lastError = errorCode(error)
|
|
157
|
+
reject(name, 'parse_threw')
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
let env: HookEnvelope | null = null
|
|
161
|
+
if (parsed.ok) {
|
|
162
|
+
env = parsed.envelope
|
|
163
|
+
} else if (parsed.salvaged) {
|
|
164
|
+
// A cut Stop still turns the row idle; nothing else in the file is trusted.
|
|
165
|
+
env = { ts: parsed.salvaged.ts, ppid: null, event: parsed.salvaged.event, sessionId: parsed.salvaged.sessionId, payload: { session_id: parsed.salvaged.sessionId } }
|
|
166
|
+
stats.salvaged++
|
|
167
|
+
}
|
|
168
|
+
if (!env) { reject(name, parsed.ok ? 'unknown' : parsed.reason); return true }
|
|
169
|
+
if (seen.has(name)) {
|
|
170
|
+
stats.duplicates++
|
|
171
|
+
try { unlinkSync(path) } catch { /* fine */ }
|
|
172
|
+
return true
|
|
173
|
+
}
|
|
174
|
+
// Classified NOW, while the child pid is alive; the ledger row remembers the verdict so a
|
|
175
|
+
// replay after a restart (when the spawn ledger is empty) applies the same rule.
|
|
176
|
+
let child = false
|
|
177
|
+
try { child = options.isChild?.(env) ?? false } catch { child = false }
|
|
178
|
+
let appended = false
|
|
179
|
+
try { appended = ledger.append(name, env, child) } catch (error) { stats.lastError = errorCode(error) }
|
|
180
|
+
if (!appended) {
|
|
181
|
+
// Keep the file: the ledger is the durable record and it refused. Health shows why.
|
|
182
|
+
stats.lastError = ledger.stats().lastError ?? stats.lastError
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
seen.add(name)
|
|
186
|
+
try { options.apply?.(env, child) } catch (error) {
|
|
187
|
+
stats.applyErrors++
|
|
188
|
+
console.error(`[hook-spool] apply failed for ${name}: ${error instanceof Error ? error.message : error}`)
|
|
189
|
+
}
|
|
190
|
+
stats.ingested++
|
|
191
|
+
stats.lastEventAt = env.ts
|
|
192
|
+
try { unlinkSync(path) } catch { /* a re-read is deduped by `seen` */ }
|
|
193
|
+
return true
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const pruneRejected = () => {
|
|
197
|
+
let names: string[]
|
|
198
|
+
try { names = readdirSync(rejectedDir).sort() } catch { return }
|
|
199
|
+
const cutoff = now() - REJECTED_MAX_AGE_MS
|
|
200
|
+
const excess = Math.max(0, names.length - REJECTED_KEEP)
|
|
201
|
+
names.forEach((n, index) => {
|
|
202
|
+
const path = join(rejectedDir, n)
|
|
203
|
+
let old = false
|
|
204
|
+
try { old = statSync(path).mtimeMs < cutoff } catch { return }
|
|
205
|
+
if (index < excess || old) { try { unlinkSync(path) } catch { /* fine */ } }
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Returns the number of files REMOVED this pass; a stuck file is not progress. */
|
|
210
|
+
const sweep = (): number => {
|
|
211
|
+
if (ticking || stopped) return 0
|
|
212
|
+
ticking = true
|
|
213
|
+
let removed = 0
|
|
214
|
+
let stuck = 0
|
|
215
|
+
try {
|
|
216
|
+
const names = listSpool()
|
|
217
|
+
for (const name of names.slice(0, batch)) {
|
|
218
|
+
if (ingestOne(name)) removed++
|
|
219
|
+
else stuck++
|
|
220
|
+
}
|
|
221
|
+
stats.stuck = stuck
|
|
222
|
+
stats.backlog = Math.max(0, names.length - removed)
|
|
223
|
+
stats.lastSweepAt = now()
|
|
224
|
+
if (stuck > 0 && !loggedStuck) {
|
|
225
|
+
loggedStuck = true
|
|
226
|
+
console.error(`[hook-spool] ${stuck} file(s) could not be drained (${stats.lastError ?? 'unknown'}); they stay in ${dir}`)
|
|
227
|
+
}
|
|
228
|
+
if (stuck === 0) loggedStuck = false
|
|
229
|
+
stamp()
|
|
230
|
+
if (++pruneCounter % 30 === 0) pruneRejected()
|
|
231
|
+
} finally {
|
|
232
|
+
ticking = false
|
|
233
|
+
}
|
|
234
|
+
return removed
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Startup drain: the backlog, in bounded passes, before anyone asks. Bounded twice: a pass
|
|
238
|
+
// that removed nothing ends it (a refusing ledger must never spin the boot), and no
|
|
239
|
+
// backlog needs more passes than a full spool.
|
|
240
|
+
for (let pass = 0; pass < STARTUP_MAX_PASSES && !stopped; pass++) {
|
|
241
|
+
if (sweep() === 0) break
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const timer = setInterval(sweep, sweepMs)
|
|
245
|
+
timer.unref()
|
|
246
|
+
try {
|
|
247
|
+
// Only a spool file is a wake-up. The stamp and the rejected dir change metadata here
|
|
248
|
+
// too, and reacting to them would be a self-sustaining loop.
|
|
249
|
+
watcher = watch(dir, { persistent: false }, (_event, filename) => {
|
|
250
|
+
if (typeof filename === 'string' && SPOOL_FILE_RE.test(filename)) sweep()
|
|
251
|
+
})
|
|
252
|
+
watcher.on('error', () => { /* the interval is the guaranteed path */ })
|
|
253
|
+
} catch { watcher = null }
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
sweep,
|
|
257
|
+
stop() {
|
|
258
|
+
stopped = true
|
|
259
|
+
clearInterval(timer)
|
|
260
|
+
try { watcher?.close() } catch { /* fine */ }
|
|
261
|
+
},
|
|
262
|
+
stats: () => ({ ...stats }),
|
|
263
|
+
}
|
|
264
|
+
}
|