Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ standalone/src-tauri/binaries/
standalone/src-tauri/gen/
standalone/dist/
standalone/sidecar/dor-cli/
standalone/sidecar/alert-journal.cjs
standalone/sidecar/iframe-proxy.cjs
standalone/sidecar/agent-browser-host.cjs
standalone/sidecar/burrow.cjs
Expand Down
13 changes: 13 additions & 0 deletions docs/alert-diagnostics-removal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Removing temporary alert diagnostics

The feature gate and current logging contract are owned by `docs/specs/alert.md` → Local alert diagnostics.

Use this checklist when the usage sample and alert investigation are finished:

1. Remove `lib/src/lib/alert-diagnostics-config.ts`, `lib/src/lib/alert-diagnostics.ts`, and `lib/src/host/alert-journal.ts`, their dedicated tests, and `vscode-ext/src/alert-journal.ts`.
2. Remove diagnostic imports and calls from the alert manager, detector, ring watcher, speech, settings, activity store, and platform initialization. Remove diagnostic-only snapshots, counters, timer wrappers, and attempt metadata. Keep the existing ring/attention decisions, speech callbacks, and high-entropy redaction.
3. Remove `recordAlertDiagnostic` from the platform port and desktop adapters; remove `alert:diagnostic` / `alert_diagnostic` from the VS Code message types/router, Tauri command registration, and sidecar dispatch. Remove journal initialization/shutdown hooks, the sidecar bundle entry, and its `.gitignore` entry.
4. Remove `scripts/summarize-alert-log.mjs`, its test and root test-script entry. Remove diagnostic-only assertions from mixed test files, the owning spec section/rationale, this checklist, and pointers in the transport, desktop-host, and local-security specs. Ratchet changed spec budgets.
5. Search for leftovers with `rg -n 'alert-diagnostic|alert-journal|alertDiagnostic|AlertDiagnostic|recordLifecycle|diagnosticSnapshot|summarize-alert-log' lib standalone vscode-ext scripts docs .gitignore package.json`. Run `pnpm test` and build both desktop hosts.

Disabling or removing the feature leaves existing `alert-logs` directories intact. Delete those saved samples separately if they are no longer needed.
15 changes: 14 additions & 1 deletion docs/specs/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,10 +289,23 @@ Source of truth: `AlertSettings` in `lib/src/lib/alert-settings.ts` (renderer mi
- Web Speech has no per-utterance stop, so `cancel()` empties the whole queue. **Re-dispatch every still-ringing Session whose current-ring utterance was accepted but never started**, because attending one Pane must not silence another's alarm, and hold each re-dispatch to the same gates as the first (attended meanwhile, or the setting switched off, drops out). **Prune a queued entry as soon as its ring resolves**, so a later unrelated `cancel()` cannot re-dispatch a stale one, bypass the new ring's delay, and speak twice. **Never cut a Session that is only queued** — cutting it would take the Pane that *is* talking with it.
- **Teardown must `cancel()` the engine, not just detach the callbacks** — a webview that unmounts mid-alarm would otherwise keep reading Pane names aloud with no UI left to stop it.
- **In-flight tracking is bounded.** The utterance set and queued index evict their oldest entry past a shared cap. Delivery identities retain one token per ringing Session until the ring resolves. An evicted utterance that still fires settles normally; it is no longer eligible for collateral re-dispatch.
- `speaking` / `spoken` remains only while the originating Session is still `ALERT_RINGING`: any action that resolves the ring (Clearing And TODO) clears it, killing the Session included, while visibility, hover, and command-mode selection do not. **Never persist it or send it to the host**, so restore/reconnect cannot recreate it.
- `speaking` / `spoken` remains only while the originating Session is still `ALERT_RINGING`: any action that resolves the ring (Clearing And TODO) clears it, killing the Session included, while visibility, hover, and command-mode selection do not. **Never persist it as restorable state or send it as host alert state**, so restore/reconnect cannot recreate it. Diagnostic events follow Local alert diagnostics.

Source of truth: `toSpokenText` in `lib/src/lib/alert-speech.ts`, armed by `lib/src/components/wall/use-alert-speech.ts`; `redactHighEntropyTokens` in `lib/src/lib/redact-high-entropy.ts`; label derivation in `lib/src/lib/session-label.ts`; `AlertSpeechState` in `lib/src/lib/alert-speech-state.ts`.

### Local alert diagnostics

- **Must default temporary diagnostics off**, through `alertDiagnosticsConfig.enabled` in `lib/src/lib/alert-diagnostics-config.ts`. Disabled builds emit no diagnostic IPC, install no diagnostic focus listeners, and write or prune no journal files, including direct host/lifecycle records. Set the flag to `true`, rebuild the desktop frontend and host/sidecar bundles, and restart to collect a sample; this is a source switch, not a persisted setting. Existing logs remain untouched. Removal checklist: `docs/alert-diagnostics-removal.md`. Pinned by `lib/src/lib/alert-diagnostics-gate.test.ts`.
- **Must journal desktop alert decisions and local speech attempts without changing delivery behavior when diagnostics are enabled.** Logging failure never blocks an alert; diagnostics never hydrate alert state. Pocket and the website demo have no journal port (rationale).
- **Must record only the final `toSpokenText` payload as speech content**, with code-point and UTF-16 counts, a distinct attempt id, Session id and ring sequence, and a reason separating fresh rings, queue redispatch, and settings tests. Record unavailable/refused dispatch, queue admission, engine start/end/error, and global cancellation. A callback includes the current ring state and time since request; absence of callbacks is unknown delivery, not silence.
- **Must correlate decisions with source-instance ids, source sequence numbers, wall time, and process-local monotonic time.** Record decision inputs before ring mutations, manager publications, suppression reasons, semantic event kinds, dismissal/attention actions, detector changes, deferred delivery, timer deadlines/lateness, renderer snapshots, focus/visibility transitions, and host shutdown start/completion. Sample output through cumulative received/resize-ignored chunk counts and last-output timestamps at those events; never log PTY bytes, raw commands, or notification bodies.
- **Must bound logging before IPC and disk.** The emitter admits 100 events per monotonic second, with speech allowed through a 200-event total; the writer accepts at most 512 queued records of 8 KiB each. Loss is reported on subsequent successful writes. IPC loss, abrupt shutdown, and retention can leave incomplete traces. Never infer exact provider billing from them.
- **Must rotate JSONL files at 4 MiB or 24 hours of writing**, pruning on rotation/startup by a 30-day age and a 64 MiB directory budget with one file's headroom. Concurrent writers make the size budget approximate. Files are local to the host's app storage; access controls are described in `docs/specs/security-local.md` → Persisted state.

Run `node scripts/summarize-alert-log.mjs <alert-logs-directory>` for UTC daily character totals separated into fresh rings, redispatch, and tests, plus observed outcomes and loss markers. The source count excludes journal-writer identities, including standalone's writer-owned shutdown markers; standalone counts its renderer, while VS Code counts renderer and extension-host emitters. Files and first/last record times describe the available sample, not continuous uptime. Multiply the relevant character volume by the eventual provider/model rate; no ElevenLabs client or pricing is built here.

Source of truth: `alertDiagnostic` in `lib/src/lib/alert-diagnostics.ts`; `trace` in `lib/src/lib/alert-manager.ts`; `speak` in `lib/src/lib/alert-speech.ts`; `watchUnattendedRings` in `lib/src/lib/alert-ring-watch.ts`; `createAlertJournal` in `lib/src/host/alert-journal.ts`; `summarizeAlertLogs` in `scripts/summarize-alert-log.mjs`. Pinned by `lib/src/lib/alert-diagnostics.test.ts`, `lib/src/lib/alert-speech.test.ts`, `lib/src/host/alert-journal.test.ts`, and `scripts/summarize-alert-log.test.mjs`.

### Push notifications

**The two halves run in different processes.** Ring *detection* is webview state, so `watchPushRings` stays in the webview and fires one `push { sessionId, title }` command at the Burrow service. *Delivery* needs the enrollment and the ACL, which only the Burrow holds, so `sendPush` runs in the service's process and touches no DOM or store. **A webview cannot choose recipients:** it names the Session and what to call it; the service reads its own active ACL at send time. **Arm watching only while the service reports an enrollment** (`enrolled-gate.ts`), so an un-enrolled machine pays no activity-store subscription; a `push` arriving with no Burrow running is not sent. **Keep both halves under `remote/burrow/`**, inside the lazily-imported `RemotePairingModalHost` chunk, so a host without `enableBurrow` never fetches it (rationale).
Expand Down
8 changes: 8 additions & 0 deletions docs/specs/alert.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@

Guarding only completion leaves a stale `start` free to replace the active utterance's token. Queue-admission identity also covers old rings, collateral redispatch, and evicted callbacks after teardown; it retains one token per ringing Session without retaining each engine utterance.

## Local alert diagnostics

A source switch keeps this temporary investigation out of persisted alarm settings. Gating both the emitter and journal covers renderer IPC and the sidecar’s direct lifecycle path; either gate alone would leave part of the feature active.

A spoken alert after focus returns can be a newly latched ring, a replayed host snapshot, a throttled renderer timer, or an engine queue entry that starts late. Logging only speech text cannot distinguish these. The VS Code manager survives in the extension host while its renderer is hidden; standalone's manager lives in the renderer. Recording both layers, their separate source lifetimes, and the last accepted output connects a dismissal to the later decision without storing terminal content. Monotonic clocks are comparable only within one source; wall-clock deadlines expose delayed timers but can also move when the system clock changes.

Post-sanitization counts measure the text a future speech provider would receive. Redispatch is separate because the future remote queue and browser fallback need not retry identically. Local request volume is a planning sample, not a billing ledger. Best-effort bounded writing keeps observability off the alert's critical path.

## Push notifications

**Why both halves live under `remote/burrow/`.** The sink rides the lazily-imported `RemotePairingModalHost` chunk; the shared ring machine and the device store stay in the common bundle instead, since speech and the settings dialog need them everywhere.
Expand Down
4 changes: 4 additions & 0 deletions docs/specs/security-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ invocation per Surface, no buffer, unlinked as it is read
with `wx`, its socket directory re-checked on every contention round. **Neither
control does anything on Windows** (rationale).

**Must keep alert journals in host app storage, separate from the standalone debug log.** `<app_data_dir>/alert-logs/` (standalone) and `context.globalStorageUri/alert-logs/` (VS Code) contain post-redaction spoken text and diagnostic metadata. Redaction is a heuristic; spoken text can still contain sensitive words. The writer creates/tightens its directory to `0700` and exclusively creates files at `0600` on Unix; Windows uses inherited host-storage ACLs, including standalone's `burrow_state_dir` restriction. No upload or Settings Sync is involved. Content and retention: `docs/specs/alert.md` → Local alert diagnostics.

Source of truth: `createAlertJournal` in `lib/src/host/alert-journal.ts`, pinned by `lib/src/host/alert-journal.test.ts`.

**The standalone log is unprotected and names the control socket.**
`$DORMOUSE_LOG_FILE`, else `%LOCALAPPDATA%\Dormouse Terminal\dormouse.log`, else
`<tmpdir>/dormouse.log`, created and appended with no mode and no ACL, so it
Expand Down
2 changes: 2 additions & 0 deletions docs/specs/standalone.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,8 @@ native handler never fires. Behavior and status:

## Logging

Local alert diagnostics (`docs/specs/alert.md` → Local alert diagnostics) live in `<app_data_dir>/alert-logs/`, written by the Node sidecar through `alert_diagnostic`. The sidecar bundles `createAlertJournal` from `lib/src/host/alert-journal.ts` and gives its close at most 250ms during shutdown. The browser-dev harness has no persistent journal.

Windows release builds use the GUI subsystem, so nothing streams to a launching
terminal. The Rust backend appends sidecar stderr, malformed stdout diagnostics,
and its own diagnostics to a log file: `%LOCALAPPDATA%\Dormouse Terminal\dormouse.log` on
Expand Down
1 change: 1 addition & 0 deletions docs/specs/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ Transport constraints:

| Direction | Message | Contract |
| --- | --- | --- |
| Webview → host | `alert:diagnostic` (VS Code) / `alert_diagnostic` → sidecar `alert:diagnostic` (Tauri) | One local journal record through the optional `recordAlertDiagnostic` adapter port. No reply, broadcast, or Relay forwarding. **Must validate the bounded primitive record before retaining it**; `isAlertDiagnostic` in `lib/src/host/alert-journal.ts`. Behavior: `docs/specs/alert.md` → Local alert diagnostics. |
| Webview → host | `dormouse:openExternal` | Open a user-confirmed external URI from an OSC 8 hyperlink. **Hosts must revalidate**, rejecting malformed, control-character-bearing, or blocked pseudo-scheme targets (`javascript:`, `data:`, `blob:`, `about:` — `lib/src/lib/external-links.ts`). |
| Webview → host | `pty:getOpenPorts` | TCP listening ports of a PTY's shell **and all of its descendant subprocesses**, resolved from the root pid, answered with `pty:openPorts`. `getOpenPortsForPid()` in `standalone/sidecar/pty-core.js` (VS Code loads it through the `lib/pty-core.cjs` shim). |
| Host → webview | `pty:openPorts` | `ports: OpenPort[]` (`{ protocol, family, address, port, pid, processName }`), de-duplicated by `(family, address, port)`, sorted by port then address. Empty when the PTY is gone or enumeration fails. |
Expand Down
6 changes: 6 additions & 0 deletions docs/specs/vscode.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
>
> Defers to `docs/specs/transport.md` — PTY lifecycle, buffering, reconnection, the message protocol, persisted-session types, and every adapter-agnostic invariant — for all sections below.

## Local alert diagnostics

**Must append host decisions and renderer diagnostic messages to the same local journal**, under `context.globalStorageUri/alert-logs/`. The Dormouse output channel reports the directory. **Must keep the journal open through PTY teardown**, then give closing writes at most 250ms. Never put these records in Settings Sync or restorable Session state. Behavior: `docs/specs/alert.md` → Local alert diagnostics.

Source of truth: `initAlertJournal` in `vscode-ext/src/alert-journal.ts`; `attachRouter` in `vscode-ext/src/message-router.ts`.

## Code Map

Start on the side of the webview boundary involved, then follow imports:
Expand Down
109 changes: 109 additions & 0 deletions lib/src/host/alert-journal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { alertDiagnosticsConfig } from '../lib/alert-diagnostics-config';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { mkdtemp, mkdir, readdir, readFile, rm, stat, writeFile, utimes } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createAlertJournal, isAlertDiagnostic } from './alert-journal';
import type { AlertDiagnostic } from '../lib/alert-diagnostics';

const dirs: string[] = [];
const journals: ReturnType<typeof createAlertJournal>[] = [];
beforeEach(() => { alertDiagnosticsConfig.enabled = true; });
afterEach(async () => {
await Promise.all(journals.splice(0).map((j) => j.close()));
alertDiagnosticsConfig.enabled = false;
await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
});
async function setup() {
const dir = await mkdtemp(join(tmpdir(), 'alert-journal-'));
dirs.push(dir);
const journal = createAlertJournal(dir);
journals.push(journal);
return { dir, journal };
}
const record = (seq: number): AlertDiagnostic => ({ version: 1, source: 'test', seq, at: Date.now(), monotonicMs: seq, event: 'speech.request', fields: { text: 'hello', characters: 5, attempt: String(seq), reason: 'ring' } });
async function records(directory: string) {
return (await Promise.all((await readdir(directory)).map((f) => readFile(join(directory, f), 'utf8'))))
.join('').trim().split('\n').map((s) => JSON.parse(s));
}
it('writes private JSONL, strips extra envelope fields, and rejects oversized data', async () => {
const { journal } = await setup();
const first = record(1);
journal.append({ ...first, unexpected: 'do not persist' });
journal.append({ ...record(2), fields: { text: 'x'.repeat(513) } });
await journal.flush();
expect(await records(journal.directory)).toEqual([first]);
if (process.platform !== 'win32') {
expect((await stat(journal.directory)).mode & 0o777).toBe(0o700);
const [name] = await readdir(journal.directory);
expect((await stat(join(journal.directory, name))).mode & 0o777).toBe(0o600);
}
});
it('bounds an overloaded queue and writes a loss marker', async () => {
const { journal } = await setup();
for (let seq = 1; seq <= 1000; seq++) journal.append(record(seq));
await journal.flush();
const all = await records(journal.directory);
expect(all.filter((r) => r.event === 'speech.request')).toHaveLength(512);
expect(all.find((r) => r.event === 'journal.dropped')?.fields.count).toBe(488);
expect(all.every(isAlertDiagnostic)).toBe(true);
});
it('prunes only its own expired files', async () => {
const { journal } = await setup();
await mkdir(journal.directory);
const old = join(journal.directory, 'alerts-1-00000000-0000-0000-0000-000000000000.jsonl');
await writeFile(old, 'old');
await utimes(old, new Date(0), new Date(0));
await writeFile(join(journal.directory, 'unrelated.txt'), 'keep');
journal.append(record(1));
await journal.flush();
expect(await readdir(journal.directory)).toContain('unrelated.txt');
await expect(stat(old)).rejects.toThrow();
});
it('absorbs filesystem failure without leaking record contents in warnings', async () => {
const { dir } = await setup();
const warn = vi.fn();
const file = join(dir, 'not-a-directory');
await writeFile(file, 'x');
const journal = createAlertJournal(file, warn);
journals.push(journal);
journal.append(record(1));
await expect(journal.flush()).resolves.toBeUndefined();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls.flat().join('')).not.toContain('hello');
});

it('rotates before exceeding the file size budget', async () => {
const { journal } = await setup();
const fields = Object.fromEntries(Array.from({ length: 14 }, (_, i) => [`value${i}`, 'x'.repeat(500)]));
for (let batch = 0; batch < 2; batch++) {
for (let i = 0; i < 350; i++) journal.append({ ...record(batch * 350 + i + 1), fields });
await journal.flush();
}
const files = await readdir(journal.directory);
expect(files).toHaveLength(2);
for (const name of files) expect((await stat(join(journal.directory, name))).size).toBeLessThanOrEqual(4 * 1024 * 1024);
expect(await records(journal.directory)).toHaveLength(700);
});

it('reopens a file pruned by another host writer', async () => {
const { journal } = await setup();
journal.append(record(1));
await journal.flush();
const [file] = await readdir(journal.directory);
await rm(join(journal.directory, file));
journal.append(record(2));
await journal.flush();
expect((await records(journal.directory)).map((r) => r.seq)).toEqual([2]);
});

it('keeps accepting teardown records between shutdown lifecycle markers', async () => {
const { journal } = await setup();
journal.recordLifecycle('host.stopping');
journal.append({ ...record(1), event: 'manager.onExit', fields: { sessionId: 's' } });
journal.recordLifecycle('host.stopped');
await journal.close();
const all = await records(journal.directory);
expect(all.map((r) => r.event)).toEqual(['host.stopping', 'manager.onExit', 'host.stopped']);
expect(all.every(isAlertDiagnostic)).toBe(true);
});
Loading