diff --git a/CLAUDE.md b/CLAUDE.md index 21ed5b1f..79203a83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -434,6 +434,9 @@ explaining why it was safe. - `docs/sidecar-topology.md` — where the sidecar architecture is going, and what was considered and dropped - `docs/working-on-officer.md` — how to run, restart and check your work on this machine - `docs/wallet-key-custody.md` — what the platform can and cannot see of the wallet +- `docs/editor-buffer-corruption.md` — an open bug with a trap set for it: background-job output appearing + inside a file open in the editor. Read it BEFORE investigating a report of that, and before re-testing + anything it already rules out - `TODO.md` — current direction and deferred work; **takes precedence over this file where they disagree** - `src/apps/CLAUDE.md` — shared frontend patterns - `src/databases/CLAUDE.md` — database patterns diff --git a/TODO.md b/TODO.md index a4b6d73e..d080aca3 100644 --- a/TODO.md +++ b/TODO.md @@ -428,10 +428,12 @@ outside the example's new README: no synthetic keyboard events, no clipboard writes near the editor. That leaves Monaco's own change event, which means it looks like a paste or a programmatic `executeEdits`. - `reportSuspiciousEdit` in CodeEditor.tsx logs any insertion over 200 characters that arrives while - the editor is NOT focused, with a stack trace taken at that moment. That is the missing evidence: - whoever calls it will be named in the trace. Delete it once the cause is known — it is a trap, not a - feature. + **`docs/editor-buffer-corruption.md` is the procedure — read that when someone reports this.** A + trap is set: `reportSuspiciousEdit` in CodeEditor.tsx records any insertion over 200 characters + arriving while the editor is NOT focused, with the stack at that moment, into + `localStorage['officer:editor-buffer-trap']` (last five). It persists rather than only logging + because this arrives as a report hours later, when the console is long gone — the question becomes + "paste this key" instead of "what did you see?". Delete all of it once the cause is known. One thing to rule out first, because it is mine and it is recent: `2b70a570` gave `` a real `path`, so models are now keyed by file URI globally rather than being per-mount `inmemory://`. Two diff --git a/docs/editor-buffer-corruption.md b/docs/editor-buffer-corruption.md new file mode 100644 index 00000000..441f2291 --- /dev/null +++ b/docs/editor-buffer-corruption.md @@ -0,0 +1,78 @@ +# Background-job output appears inside an open file + +An open bug, not reproducible on demand, with a trap already set for it. **If someone has just reported +this, skip to "When it is reported".** + +Status as of 2026-08-16: cause unknown, two candidates eliminated, instrumentation shipped. + +## What it looks like + +A file is open in the code editor while a background job is producing output — a dev server, a watcher. +The output appears **inside the file's buffer**, spliced into the middle of it. Observed once with a +`bun dev` job: the JSX of `SigninScreen.tsx` and eight lines of dotenv/POSTGRES_URL output shared the +buffer, with Monaco's line numbers continuing straight through (…180, the log lines, then 189 ``). + +Two details from that report do most of the diagnostic work: + +- **The tab was marked dirty.** So the model really was mutated; this is not a rendering artefact. +- **The file on disk was untouched.** The corruption is in the browser and was never saved. Reopening the + tab fixes it. + +## What has been ruled out, and why + +Do not re-investigate these without new evidence. + +| Candidate | Why it is out | +| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| The end-of-turn refresh re-reading the file | `reloadFile` sets `originalContent` and `isDirty: false`. It cannot produce a dirty tab. | +| A shared component between the task panel and the editor | `CodeSurface` (the task log) renders with shiki. It is not Monaco and holds no editor model. | +| Two Monaco editors sharing a model | The app's only other mount, `FileViewer/renderers/EditorRenderer.tsx`, passes no `path`, so its model URI is `inmemory://model/N` and cannot collide with the editor's `file:///…`. | +| Something typing into the page | No `execCommand('insertText')`, no synthesised `KeyboardEvent`, no clipboard writes anywhere near the editor. | + +That leaves **Monaco's own `onChange`** — the model was edited the way a paste or a programmatic +`executeEdits` edits it. Which is why this needs to be caught in the act rather than reasoned about. + +One thing to check first, because it is recent and it is ours: `2b70a570` gave `` a real `path`, +so models are keyed by file URI globally rather than per mount. Two panels open on the same file now share +one model. That should be an improvement, and it is a change in exactly the layer that turns out to be +involved. + +## When it is reported + +Ask for one thing. In the browser's console, on the machine where it happened: + +```js +JSON.parse(localStorage.getItem('officer:editor-buffer-trap')); +``` + +That returns up to the last five occurrences, each with: + +| Field | What it tells you | +| ------------------ | ----------------------------------------------------------------------------------------------------- | +| `stack` | **The answer.** The call stack at the moment of the edit — whatever inserted the text is named in it. | +| `inserted` | The first 200 characters that arrived. Confirms it is job output rather than a real paste. | +| `charactersAdded` | How much arrived at once. | +| `path` | Which file was open. | +| `at` | Lines it up against the job's own logs. | +| `editorWasFocused` | Always `false` — the trap only fires when it is. | + +It is written by `reportSuspiciousEdit` in `apps/CodeEditor/CodeEditor.tsx`, which fires on any insertion +over 200 characters arriving while the editor does **not** have focus. Nobody types 200 characters into an +editor they are not focused on. + +If the key is empty but the corruption happened, that is itself informative: either the editor **was** +focused (so it plausibly is a paste, and the question becomes what pasted it), or the insertion arrived in +chunks under 200 characters — in which case lower `SUSPICIOUS_INSERT` and wait for it again. + +## Why a trap rather than a fix + +Because every fix available without knowing the cause is a guess that would look like a solution. A guard +that refuses suspicious writes would hide the symptom on the one screen where it is visible, and the same +mechanism would go on doing whatever else it does. The corruption is never saved and reopening the tab +clears it, so the cost of waiting is low and the cost of a wrong fix is a bug that stops being reportable. + +## When it is solved + +Delete all of it: `reportSuspiciousEdit`, `EDITOR_TRAP_KEY`, its call in the `onChange` handler, the entry +in `TODO.md` → Known bugs, and this file. It is instrumentation for one bug, and instrumentation nobody +remembers the reason for is worse than none. diff --git a/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx b/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx index b13e955f..053244c0 100644 --- a/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx +++ b/src/workspaces/officerdev/src/apps/CodeEditor/CodeEditor.tsx @@ -29,22 +29,36 @@ type CodeEditorViewProps = { }; /** - * Shout when the buffer changes in a way a person could not have typed. + * Catch the buffer changing in a way a person could not have typed, and LEAVE THE EVIDENCE ON DISK. * - * Background-job output has twice appeared spliced into an open file — the model mutated, the tab marked - * dirty, the file on disk untouched. Two explanations are already ruled out: the end-of-turn refresh - * cannot do it (`reloadFile` clears `isDirty`), and nothing in the app types into the page. That leaves - * Monaco's own change event, which means it looks like a paste or a programmatic edit — and neither can - * be identified from a screenshot after the fact. + * Background-job output has appeared spliced into an open file — the model mutated, the tab marked dirty, + * the file itself untouched. Two explanations are ruled out by reading: the end-of-turn refresh cannot do + * it (`reloadFile` clears `isDirty`), and nothing in the app types into the page. That leaves Monaco's own + * change event, so it looks like a paste or a programmatic edit, and neither is identifiable from a + * screenshot taken afterwards. * - * So this does not prevent anything. It records the one thing missing: what arrived, how big it was, and - * whether the editor even had focus at the time. A large insertion into an UNFOCUSED editor is not a - * user, and the stack trace attached to it names whatever called it. + * ── Why it writes to localStorage and not just the console ── * - * Delete this once the cause is known. It is a trap, not a feature. + * This is rare, nobody can reproduce it on demand, and it will arrive as somebody's report hours later. + * A `console.warn` is only seen by a person who happened to have devtools open at that second — which is + * nobody. Persisting the last few reports means the question "what did you see?" becomes "paste this + * key", and the answer is complete: the stack, the size, the path, the first 200 characters and whether + * the editor was even focused. + * + * The stack is the point. A person cannot type 200 characters into an editor they are not focused on, so + * whatever did will be named in it. + * + * Full procedure, and what is already ruled out: `docs/editor-buffer-corruption.md`. Delete all of this + * once the cause is known — it is a trap, not a feature. */ const SUSPICIOUS_INSERT = 200; +/** Where the evidence waits to be asked for. Documented in `docs/editor-buffer-corruption.md`. */ +export const EDITOR_TRAP_KEY = 'officer:editor-buffer-trap'; + +/** Enough to see a pattern across occurrences, few enough to never be the reason storage is full. */ +const TRAP_KEPT = 5; + function reportSuspiciousEdit( editor: MonacoEditor.IStandaloneCodeEditor | null, file: { path: string; content: string }, @@ -54,13 +68,31 @@ function reportSuspiciousEdit( if (grew < SUSPICIOUS_INSERT) return; if (editor?.hasTextFocus()) return; + const report = { + at: new Date().toISOString(), + path: file.path, + charactersAdded: grew, + editorWasFocused: false, + inserted: next.slice(0, 200), + stack: new Error('editor buffer changed while unfocused').stack ?? 'no stack', + }; + // eslint-disable-next-line no-console console.warn( - `[editor] ${grew} characters appeared in ${file.path} while the editor was NOT focused. ` + - `This is the background-job-output bug (TODO.md → Known bugs). First 120 chars:\n` + - next.slice(0, 120), - new Error('stack at the moment of the edit'), + `[editor] ${grew} characters appeared in ${file.path} while the editor was NOT focused — ` + + `see docs/editor-buffer-corruption.md. Saved to localStorage["${EDITOR_TRAP_KEY}"].`, + report, ); + + // Never let recording a bug become one. Private mode, a full quota and a disabled storage API all throw + // here, and none of them are worth breaking an edit over. + try { + const previous: unknown = JSON.parse(window.localStorage.getItem(EDITOR_TRAP_KEY) ?? '[]'); + const list = Array.isArray(previous) ? previous : []; + window.localStorage.setItem(EDITOR_TRAP_KEY, JSON.stringify([...list, report].slice(-TRAP_KEPT))); + } catch { + /* the console line above is still there */ + } } export const CodeEditorView = ({