Skip to content

fix(cli): history lists its entries even when this run cannot write the history - #6061

Merged
miguel-heygen merged 3 commits into
mainfrom
fix/history-read-without-cache-write
Oct 22, 2026
Merged

miguel-heygen merged 3 commits into
mainfrom
fix/history-read-without-cache-write

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

What

hyperframes history (the list, with or without --json, plus show and peek) now lists the project's recorded changes even when this run cannot write the history folder, for example on a full disk or an unwritable cache folder. It prints the history as last recorded, plus one line on stderr naming the cause:

[hyperframes] Could not write this project's history (EACCES: permission denied, open '<history>/<id>/project.json.hfb9fac4.tmp'); showing it as last recorded.

Commands that change the project through the history (undo, restore, pin, begin, end) still fail as before. Their job is the write.

Why

Reading the history opens it as its owner. That writes the owner lock, the project record and copies of any changed files. If any of those writes failed, the read failed with it, and the user lost sight of every recorded change. Users on macOS hit this on 0.8.183:

  • ENOSPC: no space left on device, open '<history>/<id>/owner.pid-<pid>-<uuid>.tmp' (full disk)
  • EPERM opening <history>/<project>/<file>.tmp (cache folder it may not write), twice from one install

None of those writes is needed to read. readProjectHistory already reads the log without taking the lock or writing anything, and withReader already falls back to it when another process holds the history. This PR uses the same fallback when opening fails because a write failed (ENOSPC, EDQUOT, EACCES, EPERM or EROFS from a write). A read that fails while opening, such as a blobs folder it may not list or an owner file it may not read, still fails with its own error.

What the user gives up in that case: changes made since the last successful open are not filed into the history on this run. They couldn't be written anyway, and they get filed on the next open that can write.

Related work

None.

How

  • One place: withReader in packages/cli/src/utils/historyOwner.ts, which every read-only history subcommand goes through.
  • What counts as a write is decided by the failing call, not the error code alone: mkdir, write, rename, unlink, link, copyfile and the like, or a denied open of a file that is missing (creating it) or that this process can read (so the denial was for writing, for example appending to a read-only log). Node's error does not carry the open flags; the file's state answers which kind of open failed. Everything else propagates, including scandir and an ENOSPC from a file watcher.
  • It falls back only if opening failed, never once the read has started, so a read never runs twice. A write error raised after the read began is still the command's own error.

Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (if applicable)
  • Comments follow CONTRIBUTING.md "Comments": they say why, not what, and a bug fix says what the code must do and how to reproduce the bug

history.test.ts, seven new tests. They use real read-only folders, not mocks, and skip on Windows and as root, where permissions don't block the write:

Test On main With this PR
lists the history when its folder cannot be written, and says so fails: EACCES ... open '<root>/<id>/project.json.<x>.tmp' passes
lists the history when its log can be read but not appended to (chmod 0444 log.jsonl) fails: EACCES ... open '<root>/<id>/log.jsonl' passes
lists the history when only its stored file copies cannot be written fails: EACCES ... open '<root>/<id>/blobs/incoming-<uuid>' passes
a folder it cannot read (chmod 0111 blobs) fails with EACCES from scandir, not "could not write" passes (it has no fallback) passes
an owner file it cannot read fails with EACCES from open, not "could not write" passes (it has no fallback) passes
never runs a read twice when a write error comes after it started passes passes
still fails an undo it cannot record, and leaves the files as they are passes passes
  • Whole file: 48 passed, exit 0, 3 runs in a row. tsc --noEmit in packages/cli exit 0; oxlint, oxfmt, fallow, comment ratchet and citations pass.
  • Mutations, each turning only the named tests red:
    • classify by error code alone (this PR's first head): both "cannot read" tests fail;
    • count any open as a write: the owner-file test fails;
    • count an open of an existing file as never a write: the read-only-log test fails;
    • count no open as a write: the two "lists" tests fail;
    • fall back on busy only: the two "lists" tests fail;
    • drop the "has the read started" guard: the run-twice test fails;
    • fall back silently: the "says so" test fails.
  • Built CLI on a small project whose history folder was made read-only:
    • 0.8.185: exit 1, empty stdout, EACCES: permission denied, open '<history>/<id>/project.json.<x>.tmp';
    • this branch: exit 0, the recorded entries on stdout, and the stderr line quoted under What;
    • this branch with the blobs folder at chmod 0111: exit 1, EACCES: permission denied, scandir '<history>/<id>/blobs', no fallback.

Not exercised: a real full disk (ENOSPC is the same fallback, keyed on the error code), and Windows, where EPERM/EBUSY while another process holds the lock is still retried as busy first.

…he history

history --json, show and the other read-only subcommands open the history as
its owner, which writes a lock and files pending changes. On a full disk or an
unwritable cache folder that write failed the whole read. A read now falls back
to the history as last recorded and says why on stderr; undo, restore and other
writes still fail.

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes at aba07bf.

P2: withReader classifies the error by errno alone (historyOwner.ts:277-278). EACCES/EPERM are in CANNOT_WRITE, but opening the owner can raise them from a read as well as a write. Repro on a real filesystem:

  1. chmod 0111 the existing blobs directory, leaving the home and log readable and writable.
  2. withOwner rejects with EACCES, syscall: 'scandir'. That is a read failure.
  3. withReader treats it as unwritable and falls back to the read view. It returns successfully and prints "Could not write this project's history", which is the wrong diagnosis.

Fix: take write provenance into account, either the syscall (open with write flags, mkdir, write, rename, ...) or a marker set by the write stage, rather than the code alone. Add a test where an opening-stage read fails, so a read EACCES propagates (or at least isn't reported as a write failure).

Checked and fine: write commands still go through withOwner, and the ran guard is sound. Once the task has started, its errors are rethrown rather than retried on the read view.

Review by Rames (hf-review)

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 162 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

Unstable (1)

  • textselect-none-px-r0-root-z100: tracking 0, pressJump -, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0, pressJump -, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0, pressJump -, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Fixed at 1ae5f8e. The fallback now keys on which call failed, not the error code alone.

  • A write is a failed mkdir, write, rename, unlink, link, copyfile (and the like), or an open of a file that does not exist yet. Node's error has no open flags, and a denied open of a missing file can only be creating it.
  • Everything else propagates with its own error: your chmod 0111 blobs case (EACCES, scandir), an existing file it may not read (open), and an ENOSPC from a file watcher.
  • New tests: the 0111 blobs folder, and an unreadable leftover owner.pid. Each asserts the original EACCES propagates with no "could not write" line. With the errno-only classifier from aba07bf, both fail.
  • Real CLI with blobs at 0111: exit 1, EACCES: permission denied, scandir '<history>/<id>/blobs'. With a read-only history folder, it still lists entries with the one stderr line.

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 1ae5f8e. The earlier read-side finding is fixed: real scandir and read-open failures now propagate instead of being reported as cache-write failures. The ran guard is still sound, and the write commands still use withOwner unchanged.

P2: a failed write-open on an existing log still blocks reading saved history (packages/cli/src/utils/historyOwner.ts:292). The new check treats an open as a write only when the file doesn't exist yet. Repro on a real filesystem:

  1. Create some history.
  2. chmod 0444 its log.jsonl (still readable), leaving the directories writable.
  3. Change index.html.
  4. Run history list.

On startup the CLI records the external change through saveRecord, which opens the existing log with O_WRONLY | O_APPEND (packages/studio-server/src/history/historyLog.ts:100). That open raises EACCES. Because the log exists, absent(log) is false, so withReader rejects instead of showing the readable saved entries. Fix: track that an open was for writing for existing files too, not only when creating one. Keep the new tests for real read errors.

Seven focused filesystem checks ran against the current source, as uid 1000 with real permissions. Only project resolution and preview discovery were stubbed, and dependencies were borrowed from another checkout.

  • Read denials on directories, the owner file and sources propagate.
  • Write denials on history records and blobs fall back to the read view.
  • A task error runs the task only once.
  • The existing-log write-open failure above reproduces.

The full history suite was not re-run after the earlier dependency collection failure. No full-disk or Windows case was tested.

Review by Rames (hf-review)

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Fixed at 501ad61. A denied open now counts as a write when the file is missing (a create) or this process can read it. A readable file whose open was denied was being opened for writing, like your O_WRONLY | O_APPEND on a 0444 log. A file it can't read still propagates as a read error, so the earlier read-error tests keep holding.

  • New test: your repro (history, chmod 0444 log.jsonl, an outside edit, list). It fails without the change with EACCES ... open '<root>/<id>/log.jsonl', and passes with it. 48 passed, 3 runs.
  • Mutations: treating an existing file's open as never a write fails the new test; treating any open as a write fails the unreadable owner.pid test.
  • Real CLI with the log at 0444 after an edit: 0.8.185 exits 1 with that EACCES. This branch exits 0, lists the saved entry, and prints the one stderr line.

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 501ad61. Both earlier blockers are fixed:

  • Directory and read-open permission errors still surface as errors.
  • When appending to an existing, readable log is denied, the read now shows the recorded history instead of failing.

packages/cli/src/utils/historyOwner.ts:287-309 now decides whether an open failure was a write by checking whether the file is readable or missing, and still filters by syscall. The guard on the task having started is still there. undo, restore, pin, begin and end still use the unchanged withOwner write path.

Seven focused filesystem checks pass on Linux against the current withReader and history-engine source, with real permissions as uid 1000:

  • An unreadable directory, owner file or source still fails with an error.
  • A denied write to a history record or blob falls back to the read-only view.
  • A task that throws runs only once.
  • With log.jsonl read-only and the project changed afterwards, the previously recorded entries are still shown.

Only project resolution and preview discovery were stubbed, and dependencies were borrowed from another checkout. The full history suite was not re-run: it failed to load earlier because of a parser missing from the borrowed dependencies. There was no Windows, native macOS or real full-disk run.

Review by Rames (hf-review)

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit 934c88b Oct 22, 2026
108 of 117 checks passed
@miguel-heygen
miguel-heygen deleted the fix/history-read-without-cache-write branch October 22, 2026 15:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants