@gotcos/glasses-server 7.0.7 → 7.0.27
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/.env.example +19 -0
- package/CHANGELOG.md +305 -0
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +129 -23
- package/server/lib/agent-session-older.ts +218 -0
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +102 -20
- package/server/lib/api-auth.ts +37 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +54 -9
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/cos-started-sessions.ts +101 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +21 -1
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/list-model-memo.ts +85 -0
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/model-efforts.ts +40 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/secret-path-guard.ts +4 -1
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-organizer.ts +305 -0
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +39 -3
- package/server/lib/thread-turn-queue.ts +24 -0
- package/server/lib/today-brief.ts +522 -58
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/routes/agent-session-bindings.ts +16 -1
- package/server/routes/agent-session-stream.ts +7 -0
- package/server/routes/agent-sessions.ts +106 -2
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +14 -1
- package/server/routes/live-events.ts +6 -1
- package/server/routes/permission-broker.ts +26 -0
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-organizer.ts +149 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +50 -14
- package/server/routes/today-brief.ts +91 -36
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +6 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +45 -0
- package/server/web-control/app.js +1680 -108
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +54 -17
- package/server/web-control/panes/cos-chat.css +52 -6
- package/server/web-control/panes/cos-chat.js +364 -32
- package/server/web-control/panes/questions.js +135 -14
- package/server/web-control/panes/sessions.css +280 -0
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +121 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +185 -38
- package/server/web-control/theme.js +33 -0
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
|
@@ -188,9 +188,16 @@ function loadArchivedSessions(): SessionCacheEntry[] {
|
|
|
188
188
|
return entries
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// S1 (2026-11-09): said once per process. COS Control's generated .env has no COS_SCRIPTS_DIR, so
|
|
192
|
+
// on a new install this ran after every exchange (5 s debounce) and printed each time.
|
|
193
|
+
let scriptsDirMissingNoted = false
|
|
194
|
+
|
|
191
195
|
export function updateGlassesSessionCache(): void {
|
|
192
196
|
if (!SCRIPTS_DIR) {
|
|
193
|
-
|
|
197
|
+
if (!scriptsDirMissingNoted) {
|
|
198
|
+
scriptsDirMissingNoted = true
|
|
199
|
+
console.warn('[session-cache] COS_SCRIPTS_DIR not set — skipping cache writes (said once)')
|
|
200
|
+
}
|
|
194
201
|
return
|
|
195
202
|
}
|
|
196
203
|
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
// interactive-only events (Notification, StopFailure, sub-agents, compaction, model switch)
|
|
14
14
|
// whose shapes are still the documented ones until a Desktop recording lands.
|
|
15
15
|
|
|
16
|
+
import { questionRowText, waitingQuestionWordsEnabled } from './full-hold.js'
|
|
17
|
+
import { captureTurnAsk, turnEndAsksEnabled, WRAP_UP_TOOL } from './turn-end-ask.js'
|
|
16
18
|
import { createHash } from 'node:crypto'
|
|
17
19
|
import { profileIdForCodexPath } from './codex-homes.js'
|
|
18
20
|
import { isKeyProfileId } from '../../shared/key-preference.js'
|
|
@@ -208,12 +210,31 @@ function canonicalJson(value: unknown): string {
|
|
|
208
210
|
export function toolTarget(toolInput: unknown, max = 80): string {
|
|
209
211
|
if (!toolInput || typeof toolInput !== 'object') return ''
|
|
210
212
|
const i = toolInput as Record<string, unknown>
|
|
211
|
-
|
|
213
|
+
// X1: an AskUserQuestion carries only `questions`. With COS_WAITING_QUESTION_WORDS on (default ON, Miles
|
|
214
|
+
// 22:10) its target is its first question's text, control and format characters out, cut at `max` (80)
|
|
215
|
+
// below, so a row waiting on a question says what it asks. Never an option, a description or an answer.
|
|
216
|
+
// `0`: a question's target is '' exactly as in 7.0.13, and the words reach neither the ledger nor a row.
|
|
217
|
+
const firstQuestion = waitingQuestionWordsEnabled(process.env) && Array.isArray(i.questions) && i.questions.length > 0 && i.questions[0] && typeof i.questions[0] === 'object'
|
|
218
|
+
? questionRowText((i.questions[0] as Record<string, unknown>).question)
|
|
219
|
+
: undefined
|
|
220
|
+
const candidate = [i.command, i.file_path, i.path, i.url, i.pattern, i.query, i.prompt, i.description, firstQuestion]
|
|
212
221
|
.find(v => typeof v === 'string' && v.trim().length > 0)
|
|
213
222
|
const text = typeof candidate === 'string' ? candidate.replace(/\s+/g, ' ').trim() : ''
|
|
214
223
|
return text.length > max ? text.slice(0, max - 1) + '…' : text
|
|
215
224
|
}
|
|
216
225
|
|
|
226
|
+
/**
|
|
227
|
+
* 7.0.18 (X1): a turn-end ask (lib/turn-end-ask.ts) kept in the ledger so a restart replays it. Only on a
|
|
228
|
+
* PostToolUse of `mcp__ccd_turn__wrap_up`, only with COS_TURN_END_ASKS on, and only the bounded capture:
|
|
229
|
+
* the ask (80), at most 2 reply labels (40) and `blocked`. Never `recap`, never `tool_response`. An
|
|
230
|
+
* additive key, so 7.0.17 replays the row and ignores it. `tool_target` stays as 7.0.17 computes it.
|
|
231
|
+
*/
|
|
232
|
+
function turnAskProjection(event: HookEventName, toolName: string, toolInput: unknown): Record<string, unknown> {
|
|
233
|
+
if (event !== 'PostToolUse' || toolName !== WRAP_UP_TOOL || !turnEndAsksEnabled()) return {}
|
|
234
|
+
const capture = captureTurnAsk(toolInput, waitingQuestionWordsEnabled(process.env))
|
|
235
|
+
return capture ? { turn_ask: capture } : {}
|
|
236
|
+
}
|
|
237
|
+
|
|
217
238
|
/** Text fields kept from a payload. Everything else is dropped before the ledger sees it. */
|
|
218
239
|
export const HOOK_TEXT_MAX = 400
|
|
219
240
|
|
|
@@ -272,6 +293,7 @@ export function projectHookPayload(event: HookEventName, payload: Record<string,
|
|
|
272
293
|
...(event === 'PermissionRequest' && Array.isArray(payload.permission_suggestions)
|
|
273
294
|
? { permission_suggestions: payload.permission_suggestions.slice(0, 4) }
|
|
274
295
|
: {}),
|
|
296
|
+
...turnAskProjection(event, toolName, payload.tool_input),
|
|
275
297
|
}
|
|
276
298
|
}
|
|
277
299
|
case 'Notification':
|
|
@@ -21,6 +21,9 @@ import { HALT_MARKER_TTL_MS, HALT_REARM_UNVERIFIED_MS, clearHaltMarkerOnSessionE
|
|
|
21
21
|
import { cancelVoidsOpenTurn, threadCancel } from './session-cancel.js'
|
|
22
22
|
import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatus, type HookStatus } from './claude-hooks-installer.js'
|
|
23
23
|
import { codexHooksHealthField, cursorObserverHealthField, type CodexHooksHealth, type CursorObserverHealth } from './provider-observe.js'
|
|
24
|
+
import { readHidIdleSeconds } from './permission-broker.js'
|
|
25
|
+
import { waitingQuestionWordsEnabled } from './full-hold.js'
|
|
26
|
+
import { TurnEndAskTracker, turnEndAsksEnabled, type TurnEndAskCounters } from './turn-end-ask.js'
|
|
24
27
|
|
|
25
28
|
export function sessionHooksEnabled(): boolean {
|
|
26
29
|
const raw = process.env.COS_SESSION_HOOKS
|
|
@@ -256,6 +259,8 @@ export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: {
|
|
|
256
259
|
let ledger: SessionHookLedger | null = null
|
|
257
260
|
let ingester: SpoolIngester | null = null
|
|
258
261
|
let replayed: { rows: number; applied: number } | null = null
|
|
262
|
+
/** 7.0.18 (X1): the turn-end ask tracker, only while COS_TURN_END_ASKS is on (read once at startup). */
|
|
263
|
+
let turnEndAskTracker: TurnEndAskTracker | null = null
|
|
259
264
|
/** The two-scan memory: consecutive dead observations and when the first one was. */
|
|
260
265
|
const deadById = new Map<string, { scans: number; since: number | null; at: number }>()
|
|
261
266
|
const DEAD_MEMORY_MS = 60 * 60_000
|
|
@@ -271,6 +276,18 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
|
|
|
271
276
|
ledger = new SessionHookLedger(dataPath('session-hook-events.jsonl'))
|
|
272
277
|
const enabled = sessionHooksEnabled()
|
|
273
278
|
const since = Date.now() - LEDGER_REPLAY_WINDOW_MS
|
|
279
|
+
// 7.0.18 (X1): subscribed BEFORE the replay, so an ask asked before a restart is still known after it.
|
|
280
|
+
// Off (COS_TURN_END_ASKS=0): no tracker, no subscription, no desk reads, every row 7.0.17's.
|
|
281
|
+
turnEndAskTracker?.stop()
|
|
282
|
+
turnEndAskTracker = enabled && turnEndAsksEnabled() ? new TurnEndAskTracker({
|
|
283
|
+
now: () => Date.now(),
|
|
284
|
+
enabled: () => true,
|
|
285
|
+
words: () => waitingQuestionWordsEnabled(process.env),
|
|
286
|
+
readDeskIdleSeconds: readHidIdleSeconds,
|
|
287
|
+
deskIdleSeconds,
|
|
288
|
+
}) : null
|
|
289
|
+
const tracker = turnEndAskTracker
|
|
290
|
+
const unsubscribeTurnAsk = tracker ? sessionSignalStore.subscribe((signal, env, child) => tracker.observe(env, child, signal)) : () => {}
|
|
274
291
|
const replay = ledger.replay(since, (env, _key, child, entrypoint) => {
|
|
275
292
|
if (!enabled) return
|
|
276
293
|
sessionSignalStore.apply(env, child)
|
|
@@ -321,6 +338,8 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
|
|
|
321
338
|
stop() {
|
|
322
339
|
clearInterval(pruneTimer)
|
|
323
340
|
unsubscribeHalt()
|
|
341
|
+
unsubscribeTurnAsk()
|
|
342
|
+
tracker?.stop()
|
|
324
343
|
ingester?.stop()
|
|
325
344
|
},
|
|
326
345
|
}
|
|
@@ -472,8 +491,10 @@ export function deriveForRow(input: { sessionId: string; registry?: RegistryFact
|
|
|
472
491
|
prevDeadScans: prev?.scans ?? 0,
|
|
473
492
|
prevDeadSince: prev?.since ?? null,
|
|
474
493
|
attachedTurn: input.attachedTurn === true,
|
|
494
|
+
...(turnEndAskTracker ? { turnEndAsk: turnEndAskTracker.visibleAsk(key, now) } : {}),
|
|
475
495
|
})
|
|
476
496
|
if (derived.agent_state === 'ended') {
|
|
497
|
+
turnEndAskTracker?.sessionRowEnded(key)
|
|
477
498
|
for (const listener of rowEndedListeners) {
|
|
478
499
|
try { listener(key) } catch { /* a listener never breaks a row */ }
|
|
479
500
|
}
|
|
@@ -489,7 +510,20 @@ export function deriveForRow(input: { sessionId: string; registry?: RegistryFact
|
|
|
489
510
|
return derived
|
|
490
511
|
}
|
|
491
512
|
|
|
513
|
+
/** 7.0.18 (X1): the turn-end ask counters for the authenticated stats, or null with the switch off. */
|
|
514
|
+
export function turnEndAskStats(): (TurnEndAskCounters & { tracked: number }) | null {
|
|
515
|
+
return turnEndAskTracker ? turnEndAskTracker.stats() : null
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Tests only: the tracker the runtime would build, installed directly. */
|
|
519
|
+
export function __setTurnEndAskTrackerForTests(tracker: TurnEndAskTracker | null): void {
|
|
520
|
+
turnEndAskTracker?.stop()
|
|
521
|
+
turnEndAskTracker = tracker
|
|
522
|
+
}
|
|
523
|
+
|
|
492
524
|
export function __resetSessionHooksForTests(): void {
|
|
525
|
+
turnEndAskTracker?.stop()
|
|
526
|
+
turnEndAskTracker = null
|
|
493
527
|
rowEndedListeners.clear()
|
|
494
528
|
sessionSignalStore.__resetForTests()
|
|
495
529
|
deadById.clear()
|
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
// so COS can query Glasses sessions by original UUID, date, domain, or content.
|
|
3
3
|
// Fires on: TTL expiry, explicit /api/sessions/:id/end, server shutdown.
|
|
4
4
|
|
|
5
|
-
import { appendFileSync, chmodSync, mkdirSync } from 'node:fs'
|
|
5
|
+
import { appendFileSync, chmodSync, closeSync, constants as fsConstants, createReadStream, fstatSync, mkdirSync, openSync, statSync } from 'node:fs'
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
6
7
|
import { resolve, dirname } from 'node:path'
|
|
7
8
|
import type { Exchange } from './conversation.js'
|
|
9
|
+
import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
|
|
10
|
+
import { dataPath } from './data-dir.js'
|
|
8
11
|
|
|
9
12
|
// Resolve COS_SCRIPTS_DIR independently (same pattern as session-cache-writer)
|
|
10
13
|
let SCRIPTS_DIR: string | null = null
|
|
@@ -132,7 +135,13 @@ export function buildSessionLogEntry(input: EndSessionInput): SessionLogEntry {
|
|
|
132
135
|
}
|
|
133
136
|
}
|
|
134
137
|
|
|
135
|
-
/**
|
|
138
|
+
/** Where the ledger lives, or null when COS_SCRIPTS_DIR is not set. */
|
|
139
|
+
export function sessionLogPath(): string | null {
|
|
140
|
+
return SCRIPTS_DIR ? resolve(SCRIPTS_DIR, LOG_FILE_NAME) : null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Append a session log entry to .glasses_sessions.jsonl. Unconditional: callers that may
|
|
144
|
+
* repeat an unchanged session go through writeSessionLogIfChanged instead. */
|
|
136
145
|
export function writeSessionLog(entry: SessionLogEntry): boolean {
|
|
137
146
|
if (!SCRIPTS_DIR) {
|
|
138
147
|
console.warn('[session-log] COS_SCRIPTS_DIR not set — skipping session log')
|
|
@@ -153,11 +162,311 @@ export function writeSessionLog(entry: SessionLogEntry): boolean {
|
|
|
153
162
|
}
|
|
154
163
|
}
|
|
155
164
|
|
|
156
|
-
|
|
165
|
+
// ── Write-once-per-content ledger state (S1, 2026-11-09) ──────────────────────────────
|
|
166
|
+
// Before this, every server shutdown appended EVERY session with exchanges (about 300 on
|
|
167
|
+
// Miles's Mac, since sessions.json keeps all ages), and the first auto-snapshot after
|
|
168
|
+
// each boot appended them all again, because its "unchanged" check lived only in memory.
|
|
169
|
+
// 72 boots in 14 days grew the ledger to 1.94 GB: 118,221 entries for 588 sessions, one
|
|
170
|
+
// session copied 1,254 times. The state below survives restarts, so an unchanged session
|
|
171
|
+
// is never appended twice. It is keyed by the ledger path, so pointing COS_SCRIPTS_DIR
|
|
172
|
+
// elsewhere starts a fresh state instead of suppressing writes to the new file.
|
|
173
|
+
|
|
174
|
+
/** Why an entry is being written. A snapshot or a shutdown is a copy of a live session;
|
|
175
|
+
* an end (TTL, explicit end, clear) closes it. */
|
|
176
|
+
export type SessionLogKind = 'snapshot' | 'shutdown' | 'end'
|
|
177
|
+
|
|
178
|
+
interface LedgerStateRow { sig: string; kind: SessionLogKind; at: number }
|
|
179
|
+
/** `ino`: the ledger file the rows describe. A different or missing file (compaction, a restore, a
|
|
180
|
+
* deletion) means the rows no longer describe what is on disk, so they are dropped (Q1 review S5). */
|
|
181
|
+
interface LedgerState { version: 1; logPath: string; ino?: number; sessions: Record<string, LedgerStateRow> }
|
|
182
|
+
|
|
183
|
+
function ledgerInode(logPath: string): number | null {
|
|
184
|
+
try { return statSync(logPath).ino } catch { return null }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Most sessions the state remembers; the oldest by last write are dropped past this. */
|
|
188
|
+
export const SESSION_LOG_STATE_CAP = 5_000
|
|
189
|
+
const STATE_FILE_NAME = 'session-log-state.json'
|
|
190
|
+
|
|
191
|
+
let ledgerState: LedgerState | null = null
|
|
192
|
+
let ledgerStateDirty = false
|
|
193
|
+
let ledgerStateFlushTimer: ReturnType<typeof setTimeout> | null = null
|
|
194
|
+
|
|
195
|
+
function stateFilePath(): string {
|
|
196
|
+
return dataPath(STATE_FILE_NAME)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function loadLedgerState(logPath: string): LedgerState {
|
|
200
|
+
if (ledgerState && ledgerState.logPath === logPath) return ledgerState
|
|
201
|
+
const fresh: LedgerState = { version: 1, logPath, sessions: {} }
|
|
202
|
+
try {
|
|
203
|
+
const result = loadJsonOrQuarantine<LedgerState>(stateFilePath())
|
|
204
|
+
const loaded = result.status === 'ok' ? result.data : null
|
|
205
|
+
if (loaded && loaded.version === 1 && loaded.logPath === logPath
|
|
206
|
+
&& loaded.sessions && typeof loaded.sessions === 'object') {
|
|
207
|
+
ledgerState = loaded
|
|
208
|
+
return loaded
|
|
209
|
+
}
|
|
210
|
+
} catch { /* unreadable state: start fresh; the worst case is one more copy per session */ }
|
|
211
|
+
ledgerState = fresh
|
|
212
|
+
return fresh
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** What makes two entries for one session the same: its messages, counts and model.
|
|
216
|
+
* Timestamps the writer invents (a snapshot's ended_at is "now") are not part of it. */
|
|
217
|
+
export function sessionLogSignature(entry: SessionLogEntry): string {
|
|
218
|
+
return createHash('sha256').update(JSON.stringify([
|
|
219
|
+
entry.total_message_count,
|
|
220
|
+
entry.user_message_count,
|
|
221
|
+
entry.assistant_message_count,
|
|
222
|
+
entry.model_preference,
|
|
223
|
+
entry.created_at,
|
|
224
|
+
entry.messages,
|
|
225
|
+
])).digest('hex').slice(0, 32)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Persist the state now (atomic). Called at the end of a shutdown or snapshot pass. */
|
|
229
|
+
export function flushSessionLogState(): void {
|
|
230
|
+
if (ledgerStateFlushTimer) { clearTimeout(ledgerStateFlushTimer); ledgerStateFlushTimer = null }
|
|
231
|
+
if (!ledgerState || !ledgerStateDirty) return
|
|
232
|
+
const rows = Object.entries(ledgerState.sessions)
|
|
233
|
+
if (rows.length > SESSION_LOG_STATE_CAP) {
|
|
234
|
+
rows.sort((a, b) => b[1].at - a[1].at)
|
|
235
|
+
ledgerState.sessions = Object.fromEntries(rows.slice(0, SESSION_LOG_STATE_CAP))
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
mkdirSync(dirname(stateFilePath()), { recursive: true, mode: 0o700 })
|
|
239
|
+
atomicWriteFileSync(stateFilePath(), JSON.stringify(ledgerState), { mode: 0o600 })
|
|
240
|
+
ledgerStateDirty = false
|
|
241
|
+
} catch (err) {
|
|
242
|
+
console.error('[session-log] Failed to save ledger state:', err)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function scheduleStateFlush(): void {
|
|
247
|
+
if (ledgerStateFlushTimer) return
|
|
248
|
+
ledgerStateFlushTimer = setTimeout(() => { ledgerStateFlushTimer = null; flushSessionLogState() }, 1_000)
|
|
249
|
+
ledgerStateFlushTimer.unref?.()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 'unconfigured': COS_SCRIPTS_DIR is not set, so there is no ledger. That is the default for
|
|
253
|
+
* every new install (COS Control's generated .env does not set it), not a failure. */
|
|
254
|
+
export type SessionLogWriteResult = 'written' | 'unchanged' | 'unconfigured' | 'failed'
|
|
255
|
+
|
|
256
|
+
let unconfiguredNoted = false
|
|
257
|
+
/** Say once per process that there is no ledger (Q1 review M2: the auto-snapshot printed it per
|
|
258
|
+
* session every 5 minutes on a new install). */
|
|
259
|
+
function noteUnconfigured(): void {
|
|
260
|
+
if (unconfiguredNoted) return
|
|
261
|
+
unconfiguredNoted = true
|
|
262
|
+
console.log('[session-log] COS_SCRIPTS_DIR not set: no .glasses_sessions.jsonl ledger on this install (said once)')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Append the entry unless this session's content was already written.
|
|
266
|
+
* - snapshot / shutdown: skipped when the last write for the session (any kind) had the same content.
|
|
267
|
+
* - end: skipped only when the last write was itself an end with the same content, so a real end
|
|
268
|
+
* always lands once even after a snapshot of the same content (its slug and end_reason differ). */
|
|
269
|
+
export function writeSessionLogIfChanged(entry: SessionLogEntry, kind: SessionLogKind): SessionLogWriteResult {
|
|
270
|
+
const logPath = sessionLogPath()
|
|
271
|
+
if (!logPath) { noteUnconfigured(); return 'unconfigured' }
|
|
272
|
+
const state = loadLedgerState(logPath)
|
|
273
|
+
const ino = ledgerInode(logPath)
|
|
274
|
+
if (state.ino !== undefined && state.ino !== ino) {
|
|
275
|
+
state.sessions = {}
|
|
276
|
+
state.ino = undefined
|
|
277
|
+
ledgerStateDirty = true
|
|
278
|
+
}
|
|
279
|
+
const sig = sessionLogSignature(entry)
|
|
280
|
+
const prev = state.sessions[entry.session_id]
|
|
281
|
+
if (prev && prev.sig === sig && (kind !== 'end' || prev.kind === 'end')) return 'unchanged'
|
|
282
|
+
if (!writeSessionLog(entry)) return 'failed'
|
|
283
|
+
state.ino = ledgerInode(logPath) ?? undefined
|
|
284
|
+
state.sessions[entry.session_id] = { sig, kind, at: Date.now() }
|
|
285
|
+
ledgerStateDirty = true
|
|
286
|
+
scheduleStateFlush()
|
|
287
|
+
return 'written'
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function kindForReason(reason: EndSessionInput['endReason']): SessionLogKind {
|
|
291
|
+
return reason === 'server_shutdown' ? 'shutdown' : 'end'
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** End a session: build entry and write to JSONL (once per content; see writeSessionLogIfChanged). */
|
|
157
295
|
export function logSessionEnd(input: EndSessionInput): SessionLogEntry | null {
|
|
158
296
|
if (input.exchanges.length === 0) return null
|
|
159
297
|
|
|
160
298
|
const entry = buildSessionLogEntry(input)
|
|
161
|
-
|
|
299
|
+
writeSessionLogIfChanged(entry, kindForReason(input.endReason))
|
|
162
300
|
return entry
|
|
163
301
|
}
|
|
302
|
+
|
|
303
|
+
// ── Reader (S1, 2026-11-09) ───────────────────────────────────────────────────────────
|
|
304
|
+
// /api/sessions/lookup read the whole ledger with readFileSync(...).toString(). At 1.94 GB
|
|
305
|
+
// that throws (the string is longer than V8 allows), the catch treated it as "no log
|
|
306
|
+
// file", and every lookup silently answered from live sessions only, after blocking the
|
|
307
|
+
// event loop for the read. This reader streams and reads only the bytes appended since the
|
|
308
|
+
// last call. It keeps, per session, what the old loop kept: the FIRST line's range, plus the
|
|
309
|
+
// latest end any line reported. Which of the two a lookup uses is the route's old rule
|
|
310
|
+
// (lookupRangesFromHistory below), unchanged.
|
|
311
|
+
|
|
312
|
+
export interface SessionLogRange {
|
|
313
|
+
/** created_at of the session's first line in the file. Never widened (the old loop never moved it). */
|
|
314
|
+
start: number
|
|
315
|
+
/** ended_at of that first line. */
|
|
316
|
+
firstEnd: number
|
|
317
|
+
/** The latest ended_at of any line for the session. */
|
|
318
|
+
end: number
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
interface RangeIndex { logPath: string; ino: number; offset: number; ranges: Map<string, SessionLogRange> }
|
|
322
|
+
let rangeIndex: RangeIndex | null = null
|
|
323
|
+
let rangeIndexBuild: Promise<Map<string, SessionLogRange>> | null = null
|
|
324
|
+
|
|
325
|
+
const ENTRY_PREFIX = /^\{"session_id":"((?:[^"\\]|\\.)*)","device_id":"(?:[^"\\]|\\.)*","created_at":"([^"]*)","ended_at":"([^"]*)"/
|
|
326
|
+
|
|
327
|
+
/** The identity and time range of one ledger line, or null for a line that is not an entry. */
|
|
328
|
+
export function parseSessionLogLine(line: string): { sessionId: string; start: number; end: number } | null {
|
|
329
|
+
let sessionId: string | undefined
|
|
330
|
+
let createdAt: string | undefined
|
|
331
|
+
let endedAt: string | undefined
|
|
332
|
+
const prefix = ENTRY_PREFIX.exec(line.slice(0, 512))
|
|
333
|
+
if (prefix) {
|
|
334
|
+
try { sessionId = JSON.parse(`"${prefix[1]}"`) } catch { sessionId = undefined }
|
|
335
|
+
createdAt = prefix[2]
|
|
336
|
+
endedAt = prefix[3]
|
|
337
|
+
}
|
|
338
|
+
if (sessionId === undefined) {
|
|
339
|
+
try {
|
|
340
|
+
const d = JSON.parse(line)
|
|
341
|
+
if (d && typeof d.session_id === 'string') {
|
|
342
|
+
sessionId = d.session_id
|
|
343
|
+
createdAt = typeof d.created_at === 'string' ? d.created_at : undefined
|
|
344
|
+
endedAt = typeof d.ended_at === 'string' ? d.ended_at : undefined
|
|
345
|
+
}
|
|
346
|
+
} catch { return null }
|
|
347
|
+
}
|
|
348
|
+
if (!sessionId || !createdAt) return null
|
|
349
|
+
const start = new Date(createdAt).getTime()
|
|
350
|
+
if (!Number.isFinite(start)) return null
|
|
351
|
+
const endParsed = endedAt ? new Date(endedAt).getTime() : NaN
|
|
352
|
+
const end = Number.isFinite(endParsed) ? endParsed : start + 7_200_000
|
|
353
|
+
return { sessionId, start, end }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function mergeRange(ranges: Map<string, SessionLogRange>, id: string, start: number, end: number): void {
|
|
357
|
+
const existing = ranges.get(id)
|
|
358
|
+
if (!existing) { ranges.set(id, { start, firstEnd: end, end }); return }
|
|
359
|
+
if (end > existing.end) existing.end = end
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The ranges /api/sessions/lookup matches against, in first-seen order: the route's rule from
|
|
364
|
+
* before S1, unchanged (Q1 review M1, 2026-11-09). A session's range starts at its first line.
|
|
365
|
+
* Its end may be widened by later lines ONLY when the session is not in memory. A session still
|
|
366
|
+
* in memory (restored from sessions.json, however old) keeps its first line's end, because every
|
|
367
|
+
* boot snapshot of it is stamped ended_at = now: widening would let an October session claim
|
|
368
|
+
* November timestamps, and the glasses companion stores the answer for good.
|
|
369
|
+
* Sessions in `live` are skipped (the route lists them first, extended to now).
|
|
370
|
+
*/
|
|
371
|
+
export function lookupRangesFromHistory(
|
|
372
|
+
history: ReadonlyMap<string, SessionLogRange>,
|
|
373
|
+
inMemory: (sessionId: string) => boolean,
|
|
374
|
+
live: ReadonlySet<string> = new Set(),
|
|
375
|
+
): Array<{ sid: string; start: number; end: number }> {
|
|
376
|
+
const out: Array<{ sid: string; start: number; end: number }> = []
|
|
377
|
+
for (const [sid, range] of history) {
|
|
378
|
+
if (live.has(sid)) continue
|
|
379
|
+
out.push({ sid, start: range.start, end: inMemory(sid) ? range.firstEnd : Math.max(range.firstEnd, range.end) })
|
|
380
|
+
}
|
|
381
|
+
return out
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Index lines in [from, to) of the file into `ranges`. Returns the offset just past the
|
|
385
|
+
* last complete line, so a half-written last line is read again next time. */
|
|
386
|
+
async function indexBytes(fd: number, from: number, to: number, ranges: Map<string, SessionLogRange>): Promise<number> {
|
|
387
|
+
if (to <= from) return from
|
|
388
|
+
let offset = from
|
|
389
|
+
let carry: Buffer[] = []
|
|
390
|
+
let carryLen = 0
|
|
391
|
+
// Reads the descriptor readSessionLogRanges opened and checked (a regular file), never the path again.
|
|
392
|
+
const stream = createReadStream('', { fd, autoClose: false, start: from, end: to - 1, highWaterMark: 1 << 20 })
|
|
393
|
+
for await (const raw of stream) {
|
|
394
|
+
let chunk = raw as Buffer
|
|
395
|
+
let nl = chunk.indexOf(10)
|
|
396
|
+
while (nl !== -1) {
|
|
397
|
+
const piece = chunk.subarray(0, nl)
|
|
398
|
+
// Only the first 512 bytes are needed for the fast path; keep the whole line only
|
|
399
|
+
// while it is short or when the fast path fails (legacy key order).
|
|
400
|
+
const lineBuf = carryLen ? Buffer.concat([...carry, piece]) : piece
|
|
401
|
+
const lineLen = carryLen + piece.length
|
|
402
|
+
carry = []; carryLen = 0
|
|
403
|
+
const head = lineBuf.subarray(0, Math.min(lineBuf.length, 512)).toString('utf8')
|
|
404
|
+
let parsed = ENTRY_PREFIX.test(head) ? parseSessionLogLine(head) : null
|
|
405
|
+
if (!parsed && lineBuf.length <= 64 * 1024 * 1024) parsed = parseSessionLogLine(lineBuf.toString('utf8'))
|
|
406
|
+
if (parsed) mergeRange(ranges, parsed.sessionId, parsed.start, parsed.end)
|
|
407
|
+
offset += lineLen + 1
|
|
408
|
+
chunk = chunk.subarray(nl + 1)
|
|
409
|
+
nl = chunk.indexOf(10)
|
|
410
|
+
}
|
|
411
|
+
if (chunk.length) { carry.push(Buffer.from(chunk)); carryLen += chunk.length }
|
|
412
|
+
}
|
|
413
|
+
return offset
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** One time range per session from the ledger, read incrementally. A shrunk or replaced
|
|
417
|
+
* file (compaction) is re-read from the start. A missing file is an empty map. */
|
|
418
|
+
export async function readSessionLogRanges(path: string | null = sessionLogPath()): Promise<Map<string, SessionLogRange>> {
|
|
419
|
+
if (!path) return new Map()
|
|
420
|
+
if (rangeIndexBuild) return rangeIndexBuild
|
|
421
|
+
rangeIndexBuild = (async () => {
|
|
422
|
+
// O_NONBLOCK (hazard-invariants ratchet): openSync on a FIFO with no writer never returns, and it runs on
|
|
423
|
+
// the server's only JS thread. Not O_NOFOLLOW: the append writer follows a symlinked ledger, and the reader
|
|
424
|
+
// must read what the writer writes. fstat follows the link, so a link to a FIFO or folder is refused below.
|
|
425
|
+
const nonBlock = typeof fsConstants.O_NONBLOCK === 'number' ? fsConstants.O_NONBLOCK : 0
|
|
426
|
+
let fd: number
|
|
427
|
+
try {
|
|
428
|
+
fd = openSync(path, fsConstants.O_RDONLY | nonBlock)
|
|
429
|
+
} catch {
|
|
430
|
+
rangeIndex = null
|
|
431
|
+
return new Map<string, SessionLogRange>()
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
const st = fstatSync(fd)
|
|
435
|
+
if (!st.isFile()) {
|
|
436
|
+
rangeIndex = null
|
|
437
|
+
return new Map<string, SessionLogRange>()
|
|
438
|
+
}
|
|
439
|
+
if (!rangeIndex || rangeIndex.logPath !== path || rangeIndex.ino !== st.ino || rangeIndex.offset > st.size) {
|
|
440
|
+
rangeIndex = { logPath: path, ino: st.ino, offset: 0, ranges: new Map() }
|
|
441
|
+
}
|
|
442
|
+
const index = rangeIndex
|
|
443
|
+
index.offset = await indexBytes(fd, index.offset, st.size, index.ranges)
|
|
444
|
+
return index.ranges
|
|
445
|
+
} finally {
|
|
446
|
+
closeSync(fd)
|
|
447
|
+
}
|
|
448
|
+
})()
|
|
449
|
+
try {
|
|
450
|
+
return await rangeIndexBuild
|
|
451
|
+
} finally {
|
|
452
|
+
rangeIndexBuild = null
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Size of the ledger in bytes, or null when it does not exist (diagnostics). */
|
|
457
|
+
export function sessionLogSize(): number | null {
|
|
458
|
+
const path = sessionLogPath()
|
|
459
|
+
if (!path) return null
|
|
460
|
+
try { return statSync(path).size } catch { return null }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Tests only: forget the in-memory state and reader index. */
|
|
464
|
+
export function __resetSessionLogForTests(): void {
|
|
465
|
+
if (ledgerStateFlushTimer) clearTimeout(ledgerStateFlushTimer)
|
|
466
|
+
ledgerStateFlushTimer = null
|
|
467
|
+
ledgerState = null
|
|
468
|
+
ledgerStateDirty = false
|
|
469
|
+
unconfiguredNoted = false
|
|
470
|
+
rangeIndex = null
|
|
471
|
+
rangeIndexBuild = null
|
|
472
|
+
}
|