fix(webapp): a run card keeps the reads of files the project deleted (BEA-152) (#186)

The History run card carried a footnote saying reads are shown only for
files the project still has. Nothing implements that: SessionPaths does a
ListBySession and consults no tree, so a read outlives the file it read —
which is why the Dashboard's hot path listed `scratch.md · 4` while the run
card showed nothing. Two surfaces, one ledger, and the only explanation on
offer was wrong about the mechanism.

The card is narrower than the project totals because it is one session on
one device, not because deleted files are filtered. Say that instead, and
label a vanished path the way the Dashboard already labels it — same words,
same `.in-hp-gone` class. Reads of deleted files count, on both surfaces.

The seeded run now reads `scratch.md` too (the fixture's deleted-but-read
file), so the label has something to render against; the Go test pins the
policy the copy now states out loud.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-19 12:43:12 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent ae14d11ac3
commit b5bd8dddff
7 changed files with 126 additions and 59 deletions
+5
View File
@@ -112,6 +112,11 @@ func TestE2EServe(t *testing.T) {
"notes/readme.md", // read AND rewritten by the run
"index.md", // read, never changed
"archive/retired-spec.md", // read, never changed — the hot+stale one
// Read by the run, then deleted by the seed: the run card has to keep
// it and label it "no longer in the project", the way the Dashboard
// already does with its heat row (BEA-152). Drop this and the label
// has nothing to render against.
"scratch.md",
} {
srv.Reads.RecordSession(p.ID, e2eSession, "seed", path)
}
@@ -3,8 +3,8 @@ import { login, wikiId, expectToast, READER } from "./helpers";
/* One agent run, both halves (BEA-98). History used to show only what a run
CHANGED; the reads lived in a daily aggregate with no session dimension and
could not be joined to it. The seeded run reads three files and rewrites
one of them. */
could not be joined to it. The seeded run reads four files and rewrites
one of them — the fourth is a file the seed later deletes (BEA-152). */
test("a run card shows what the session read as well as what it changed", async ({ page }) => {
await login(page);
@@ -14,7 +14,7 @@ test("a run card shows what the session read as well as what it changed", async
const card = page.locator(".hrun").first();
await expect(card).toBeVisible();
// The header counts both halves now.
await expect(card.locator(".hrun-meta")).toContainText("read 3");
await expect(card.locator(".hrun-meta")).toContainText("read 4");
await expect(card.locator(".hrun-meta")).toContainText("changed 2");
// The file the run read AND rewrote carries the read marker on its own row.
@@ -25,14 +25,19 @@ test("a run card shows what the session read as well as what it changed", async
// What it read and did not touch is its own list.
const readOnly = card.locator(".hrun-read");
await expect(readOnly).toHaveCount(2);
await expect(readOnly).toHaveCount(3);
await expect(readOnly.first()).toContainText("archive/retired-spec.md");
await expect(readOnly.last()).toContainText("index.md");
await expect(readOnly.last()).toContainText("scratch.md");
// Landmine 3 is on screen, not folded into a comment: a file the run read
// and then deleted shows a write with no read, and the card says why.
// BEA-152: a file the run read and the project no longer has stays on the
// card, labelled the way the Dashboard labels the same file — the two
// surfaces read one ledger, so they must not answer differently.
const gone = card.locator(".hrun-read", { hasText: "scratch.md" });
await expect(gone.locator(".in-hp-gone")).toHaveText("· no longer in the project");
// ...and the footnote says what the card is actually scoped to, instead of
// asserting a deleted-file filter that no code implements.
await expect(card.locator(".hrun-foot")).toHaveText(
"Reads shown only for files the project still has.",
"Reads shown are what this device reported for this session — a narrower set than the project's read totals.",
);
});
@@ -515,6 +515,7 @@ export default function Browser(props: {
apiBase={apiBase}
target={route.viewTarget || ""}
isFolder={isFolderFn}
flatFiles={flatFiles}
onOpen={openPath}
onMeta={setMeta}
onRendered={onRendered}
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { HistoryEntry } from "../api/types";
import type { HistoryEntry, Node } from "../api/types";
import { HistoryRow, NoteText, type RemoveAction, type RestoreAction } from "./HistoryRow";
import { Icon } from "./shell";
import { whoChanged } from "../util";
@@ -36,6 +36,9 @@ export function HistoryView(props: {
apiBase: string;
target: string; // "" = whole project
isFolder: (p: string) => boolean;
// Every file the project still has, so a run card can mark a path it read
// that has since been deleted — the same label the Dashboard uses.
flatFiles: Node[];
onOpen: (path: string, version?: string) => void;
onMeta: (meta: string) => void;
onRendered?: () => void;
@@ -48,6 +51,9 @@ export function HistoryView(props: {
onFilters?: (f: Filters) => void;
}) {
const { apiBase, target, isFolder, onMeta, onRendered, restore, remove, undoRun, filters } = props;
// One set for the whole feed, not one per card: every run card asks the
// same question of the same tree.
const known = useMemo(() => new Set(props.flatFiles.map((f) => f.path)), [props.flatFiles]);
const q = !target
? { prefix: "" }
: isFolder(target)
@@ -170,6 +176,7 @@ export function HistoryView(props: {
<RunGroup
key={"g" + n}
run={item.run}
known={known}
onOpen={props.onOpen}
apiBase={apiBase}
prevBlob={prevBlob}
@@ -218,6 +225,7 @@ export function HistoryView(props: {
function RunGroup({
run,
known,
onOpen,
apiBase,
prevBlob,
@@ -228,6 +236,7 @@ function RunGroup({
undoRun,
}: {
run: Run;
known: Set<string>;
onOpen: (path: string, version?: string) => void;
apiBase: string;
prevBlob: (i: number) => string | undefined;
@@ -334,15 +343,24 @@ function RunGroup({
<button key={p} type="button" className="hrun-read" onClick={() => onOpen(p)}>
<span className="hkind">read</span>
<span className="hpath">{p}</span>
{/* Word for word what the Dashboard says about the same
file (Insights.tsx) — two surfaces reading one ledger
must not each invent their own vocabulary for it. */}
{!known.has(p) && <span className="in-hp-gone">· no longer in the project</span>}
</button>
))}
</div>
)}
{/* Not decoration: reads are recorded only for paths the project
still has, so a file this run read and then deleted shows its
write with no read. Saying so beats reading as a bug. */}
{/* Not decoration: this card is one session on one device, so its
read count is smaller than the project's totals for the same
files — and that gap read as a bug. Reads of a deleted file are
kept and labelled, never dropped: the ledger records what the
agent did, and an audit surface reports it. */}
{sid && (
<div className="hrun-foot">Reads shown only for files the project still has.</div>
<div className="hrun-foot">
Reads shown are what this device reported for this session a narrower set than the
project's read totals.
</div>
)}
</div>
)}
+38
View File
@@ -222,3 +222,41 @@ func TestSessionReadsOffByDefault(t *testing.T) {
t.Fatalf("nil ledger = %v", got)
}
}
// BEA-152: a read outlives the file it read. The History run card used to
// carry a footnote claiming reads are kept only for paths the project still
// has — nothing implemented that, and the Dashboard had been showing those
// reads (labelled) all along. This is the regression pin for the policy the
// card now states out loud: the ledger records what the agent did, so the
// audit surface reports it. It passes today; that is the point.
func TestSessionPathsSurviveDeletion(t *testing.T) {
h, _, c, p, root := sessionHub(t)
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
f.putAs("dev1", "alice@x.io", "Alice", "scratch.md", "# scratch")
if rec := secfixSync(t, h, p.ID, c["alice"], "dev1", "laptop", "mac"); rec.Code != 200 {
t.Fatalf("alice sync: %d %s", rec.Code, rec.Body)
}
if rec := reportRead(t, h, p, c["alice"], "dev1", []map[string]string{
{"path": "scratch.md", "session": "8f21e4"},
}); rec.Code != 200 {
t.Fatalf("report: %d %s", rec.Code, rec.Body)
}
if got := sessionPaths(t, h, p, c["alice"], "8f21e4", "dev1"); len(got) != 1 {
t.Fatalf("before the delete = %v, want scratch.md", got)
}
// The agent (or anyone) deletes the file it read. A fresh report of the
// same path is now refused (ingest only records paths the project has),
// which is what proves the delete actually landed in the replayed state.
f.delAt("dev1", "scratch.md", time.Now())
rec := reportRead(t, h, p, c["alice"], "dev1", []map[string]string{
{"path": "scratch.md", "session": "later"},
})
if !strings.Contains(rec.Body.String(), `"accepted":0`) {
t.Fatalf("the file is still in the project, so this test proves nothing: %s", rec.Body)
}
if got := sessionPaths(t, h, p, c["alice"], "8f21e4", "dev1"); len(got) != 1 || got[0] != "scratch.md" {
t.Fatalf("after the delete = %v, want scratch.md — a read is not undone by deleting the file", got)
}
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-DvCVMCS-.js"></script>
<script type="module" crossorigin src="/assets/index-Gbsbki_z.js"></script>
<link rel="modulepreload" crossorigin href="/assets/_commonjsHelpers-CqkleIqs.js">
<link rel="modulepreload" crossorigin href="/assets/mermaid-DQuCJ8Gi.js">
<link rel="stylesheet" crossorigin href="/assets/index-DISTZ6FW.css">