mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(webapp): tell agent runs from everything else in History (BEA-157)
The hub has classified every READ three ways for a while — human, share, agent — and shows it on every folder and file header. It has the same signal for every CHANGE (Op.Session, set only by `bdrive sync --hook`, already on the wire and already grouping run cards) and rendered it nowhere. Asking "what did the agents do here" got you a path box and a list of email addresses, neither of which answers it: the agent runs as the person, so the same account is on both. Server: ?by=agent|unattributed on handleHistory, one case in the existing filter switch, before the sort and the cursor skip — so it composes with q/user/since/until/path/prefix and pages with no new machinery. Anything else is a 400, like `invalid n` and `invalid since`. Frontend: "by" joins HISTORY_FILTER_KEYS, which is the whole wiring (URL parse, URL write, the query, Clear, Back). A three-state segment reuses the Dashboard's lens pills; the chip goes on bare rows and once on a run card's header, gated on run.session rather than on cardness — groupRuns keys a card on a session OR a note, and a note-keyed card claims nothing. The second class is "unattributed", never "human". The daemon scans every 3s and usually commits an agent's write before the hook's cycle runs, so an empty Session is absence of evidence — a "Human" button would confidently mislabel agent work as a person's. Only the positive class is ever badged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
899a840439
commit
f94150dff5
@@ -98,7 +98,7 @@ classDiagram
|
||||
Palette shell AccountBar ...
|
||||
}
|
||||
note for components "NewProjectDialog replaced ProjectNav's name-only modalPrompt: name + starting point, POSTing {name, template}. Its options come from useConfig()'s `templates`, never a hardcoded list, so a hub shipping another template needs no frontend change; the initial selection is options[0].value — the same array element the RECOMMENDED badge indexes, so the badged row and the checked row are one row by construction (on a template-less hub that row is 'I already have a folder', which still creates an empty project). modal.tsx keeps its one-field API — teaching it about choices would tax every other caller"
|
||||
note for components "HistoryFilters drives the SERVER (?q=/?user=/?since=/?until= on the history API), never the loaded page — filtering what is on screen would lie about everything below the fold and break next_cursor. Its state is Route.filters, so a narrowed feed is linkable, survives reload, and Back undoes it; the author list accumulates across fetches, because filtering by one author leaves only their rows loaded"
|
||||
note for components "HistoryFilters drives the SERVER (?q=/?user=/?since=/?until=/?by= on the history API), never the loaded page — filtering what is on screen would lie about everything below the fold and break next_cursor. Its state is Route.filters, so a narrowed feed is linkable, survives reload, and Back undoes it; the author list accumulates across fetches, because filtering by one author leaves only their rows loaded. ?by= is agent (op.Session set, which only the agent hook ever writes) vs unattributed — never 'human', since the daemon usually commits an agent's write before the hook runs, so only the positive class is ever badged"
|
||||
note for components "FileView's transformHTML resolves the server's `wiki:` marker against flatFiles into a real urlForPath() href (unresolvable ones lose the href and get .wiki-missing), so copy-link/middle-click/new-tab work and only a plain click reaches the delegated handler — resolution used to happen at click time, which left a dead `wiki:guide` string in the DOM (BEA-136). It also drops `data:image/svg` from any rendered img and any `data:` href from any rendered link — goldmark admits them, and an inline SVG is a document rather than a picture (the same property the server's sandboxInline walls off). Insights builds its per-device folder bag with Object.create(null), since folder names come off a peer's journal and one named __proto__ silently emptied the matrix. style.css sets unicode-bidi isolate-override on the peer-authored strings a reader is expected to CHECK (listing rows, breadcrumb, history path/note/device) — journal.SafeText refuses the bidi CONTROLS, but a single strong-RTL LETTER is legal and still reorders a row"
|
||||
note for components "HistoryView's RunGroup header carries the run-wide undo (POST undo-run, gated by the same write permission as the per-row restore/remove). It asks the SERVER for the file list first (preview: true) rather than deriving it from the loaded feed — that window is paged and filterable, so a client-computed list is wrong exactly when the run is old. modal.tsx's Confirm.message widened from string to ReactNode for it (the prompt's one-field API is untouched), so the dialog can show every path, its action, and the "changed after this run" warning inline"
|
||||
note for components "FileView's MarkdownView renders SecretBadge above the content when the render response carries findings — VersionBanner's shape (a strip, role=status, no actions), the red family rather than the accent because accent+glow already means 'you are looking at an old version' and the two strips stack on the ?sha= view. It phrases from lib/secrets so the badge and the share dialog name the rule and the line identically"
|
||||
|
||||
@@ -174,3 +174,50 @@ test("a filter change shows a loading row, never a premature 'no matches'", asyn
|
||||
await expect(page.locator(".history .empty")).toHaveCount(0);
|
||||
await page.unroute(stall);
|
||||
});
|
||||
|
||||
// BEA-157: the hub already knew which changes an agent run claimed
|
||||
// (Op.Session) and rendered it nowhere. The segment filters server-side like
|
||||
// every other filter, and the badge is a positive claim only — the second
|
||||
// button is "Unattributed", never "Human", because an empty session can also
|
||||
// mean the daemon committed an agent's write before the hook ran.
|
||||
test("the agent-run segment filters the feed, badges only agent work, and lands in the URL", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
await page.goto(`/${pid}/history`);
|
||||
await expect(page.locator(rows).first()).toBeVisible();
|
||||
const seg = page.locator(".hf-by");
|
||||
await expect(seg.getByRole("button")).toHaveText(["All", "Agent runs", "Unattributed"]);
|
||||
// The one thing this UI must never say.
|
||||
await expect(page.locator(".hfilters")).not.toContainText("Human");
|
||||
|
||||
// The seeded run card is the project's only agent-claimed work.
|
||||
await seg.getByRole("button", { name: "Agent runs" }).click();
|
||||
await page.waitForURL(`/${pid}/history?by=agent`);
|
||||
await expect(page.locator(".hrun-head")).toHaveCount(1);
|
||||
await expect(page.locator(".hrun-head .hagent")).toHaveText("agent");
|
||||
// Once on the card header, never repeated on the rows inside it.
|
||||
await expect(page.locator(".hrun-body .hagent")).toHaveCount(0);
|
||||
const agentPaths = await page.locator(`${rows} .hpath`).allTextContents();
|
||||
expect(agentPaths.length).toBeGreaterThan(0);
|
||||
|
||||
// Deep link + reload: the selection is URL state, not component state.
|
||||
await page.reload();
|
||||
await expect(seg.getByRole("button", { name: "Agent runs" })).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.locator(`${rows} .hpath`)).toHaveCount(agentPaths.length);
|
||||
|
||||
// The complement is disjoint — per OP, not per path: the suite rewrites
|
||||
// some of these same files unattributed later on, so the honest assertion
|
||||
// is that nothing agent-claimed survives, not that no path repeats.
|
||||
await seg.getByRole("button", { name: "Unattributed" }).click();
|
||||
await page.waitForURL(`/${pid}/history?by=unattributed`);
|
||||
await expect(page.locator(rows).first()).toBeVisible();
|
||||
await expect(page.locator(".hagent")).toHaveCount(0);
|
||||
await expect(page.locator(".hrun-head", { hasText: "8f21e4" })).toHaveCount(0);
|
||||
|
||||
// Back undoes it, and All clears it back to the bare URL.
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(`/${pid}/history?by=agent`);
|
||||
await seg.getByRole("button", { name: "All" }).click();
|
||||
await page.waitForURL(`/${pid}/history`);
|
||||
await expect(page.locator(".hrun-head .hagent")).toHaveCount(1);
|
||||
});
|
||||
|
||||
@@ -6,11 +6,17 @@ import { hasHistoryFilters, type HistoryFilters as Filters } from "../router";
|
||||
/* ---- history filters ----
|
||||
The whole feed is one flat scroll, and agents write far more than people
|
||||
do — so a month-old project is unreadable without a way to narrow it.
|
||||
Every filter is applied SERVER-side (?q=/?user=/?since=/?until=), never
|
||||
over the loaded page: filtering what happens to be on screen would lie
|
||||
about everything below the fold and break paging. State lives in the URL,
|
||||
so a narrowed feed is linkable and Back undoes a filter like any other
|
||||
navigation.
|
||||
Every filter is applied SERVER-side (?q=/?user=/?since=/?until=/?by=),
|
||||
never over the loaded page: filtering what happens to be on screen would
|
||||
lie about everything below the fold and break paging. State lives in the
|
||||
URL, so a narrowed feed is linkable and Back undoes a filter like any
|
||||
other navigation.
|
||||
|
||||
The by= segment is two classes, not three: "Agent runs" is every change an
|
||||
agent session claimed, and the other button is "Unattributed" — NOT
|
||||
"Human". The daemon usually commits an agent's write before the hook's
|
||||
cycle runs, so a change with no session may well be an agent's; the UI
|
||||
says only what it knows.
|
||||
|
||||
Dates are bare YYYY-MM-DD and the server reads them as UTC days — the
|
||||
label says so, because a native date input speaks the reader's local
|
||||
@@ -91,6 +97,23 @@ export function HistoryFilters(props: {
|
||||
onChange={(e) => set("until", e.target.value)}
|
||||
/>
|
||||
</span>
|
||||
<span className="hf-by" role="group" aria-label="Filter by who changed it">
|
||||
{[
|
||||
["", "All"],
|
||||
["agent", "Agent runs"],
|
||||
["unattributed", "Unattributed"],
|
||||
].map(([v, label]) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className={"in-lens-btn" + ((filters?.by ?? "") === v ? " active" : "")}
|
||||
aria-pressed={(filters?.by ?? "") === v}
|
||||
onClick={() => set("by", v)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
{active && (
|
||||
<button type="button" className="hf-clear" onClick={() => onChange({})}>
|
||||
Clear
|
||||
|
||||
@@ -144,6 +144,15 @@ export function HistoryRow({
|
||||
read
|
||||
</span>
|
||||
)}
|
||||
{/* An agent session claimed this change. Positive claim only — a row
|
||||
with no chip asserts nothing, because an absent session can also
|
||||
mean the daemon committed an agent's write before the hook ran.
|
||||
Inside a run card the card header says it once instead. */}
|
||||
{!inRun && e.session && (
|
||||
<span className="hagent" title="Changed during an agent run">
|
||||
agent
|
||||
</span>
|
||||
)}
|
||||
<span className="hpath">{e.path}</span>
|
||||
<span className="htime">{when}</span>
|
||||
</div>
|
||||
|
||||
@@ -288,6 +288,14 @@ function RunGroup({
|
||||
>
|
||||
<Icon name={open ? "chevd" : "chev"} />
|
||||
</button>
|
||||
{/* Gated on the session, never on "is a card": groupRuns keys a card
|
||||
on a session id OR on a note, and a note-keyed card carries no
|
||||
agent claim at all (see lib/runs.ts). */}
|
||||
{run.session && (
|
||||
<span className="hagent" title="Changed during an agent run">
|
||||
agent
|
||||
</span>
|
||||
)}
|
||||
{/* The note is a link when the agent left one — clicking it opens the
|
||||
session, so it can't live inside the collapse button. */}
|
||||
<span className="hrun-note">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Run with `npm test` (node's built-in runner; node ≥ 23 strips the types).
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseRoute, projectByName, urlForView, historyFilterQuery } from "./router.ts";
|
||||
import { parseRoute, projectByName, urlForView, historyFilterQuery, hasHistoryFilters } from "./router.ts";
|
||||
|
||||
// A trailing slash is what a browser hands you when you copy a folder URL,
|
||||
// so /notes/ has to be the same page as /notes.
|
||||
@@ -72,7 +72,10 @@ test("odd characters round-trip", () => {
|
||||
// History filters live in the URL, so a narrowed feed is linkable and comes
|
||||
// back on reload and Back. Round-trip: parse → build → same URL.
|
||||
test("history filters round-trip through the URL", () => {
|
||||
const r = parseRoute("/p-1/history/notes?q=runbook&user=mira@acme.io&since=2026-07-01&until=2026-07-31", "hub");
|
||||
const r = parseRoute(
|
||||
"/p-1/history/notes?q=runbook&user=mira@acme.io&since=2026-07-01&until=2026-07-31&by=agent",
|
||||
"hub",
|
||||
);
|
||||
assert.equal(r.view, "history");
|
||||
assert.equal(r.viewTarget, "notes");
|
||||
assert.deepEqual(r.filters, {
|
||||
@@ -80,13 +83,26 @@ test("history filters round-trip through the URL", () => {
|
||||
user: "mira@acme.io",
|
||||
since: "2026-07-01",
|
||||
until: "2026-07-31",
|
||||
by: "agent",
|
||||
});
|
||||
assert.equal(
|
||||
urlForView("history", "p-1", "notes", r.filters),
|
||||
"/p-1/history/notes?q=runbook&user=mira%40acme.io&since=2026-07-01&until=2026-07-31",
|
||||
"/p-1/history/notes?q=runbook&user=mira%40acme.io&since=2026-07-01&until=2026-07-31&by=agent",
|
||||
);
|
||||
});
|
||||
|
||||
// BEA-157: ?by= is a filter like any other — it alone is enough to make a
|
||||
// feed "filtered", so a bare ?by=agent link round-trips and shows Clear.
|
||||
test("?by= rides the URL on its own", () => {
|
||||
const r = parseRoute("/p-1/history?by=agent", "hub");
|
||||
assert.deepEqual(r.filters, { by: "agent" });
|
||||
assert.equal(hasHistoryFilters(r.filters), true);
|
||||
assert.equal(historyFilterQuery({ by: "unattributed" }), "?by=unattributed");
|
||||
assert.equal(urlForView("history", "p-1", "", r.filters), "/p-1/history?by=agent");
|
||||
// and it is not invented where it wasn't asked for
|
||||
assert.equal(parseRoute("/p-1/history", "hub").filters, undefined);
|
||||
});
|
||||
|
||||
// An unfiltered feed keeps the bare URL it has always had — no empty ?q=.
|
||||
test("no filters means no query string", () => {
|
||||
assert.equal(parseRoute("/p-1/history", "hub").filters, undefined);
|
||||
|
||||
@@ -68,8 +68,14 @@ export interface HistoryFilters {
|
||||
user?: string; // exact account
|
||||
since?: string; // YYYY-MM-DD (UTC), inclusive
|
||||
until?: string; // YYYY-MM-DD (UTC), inclusive
|
||||
// "agent" (the op carries an agent session) or "unattributed" (it doesn't —
|
||||
// which is NOT the same as "a human did it"). Typed `string`, not that
|
||||
// union, for the same reason `since` is not typed YYYY-MM-DD: the URL is
|
||||
// user input, the parse loop below writes a bare string, and the server is
|
||||
// the one validator (it 400s anything else).
|
||||
by?: string;
|
||||
}
|
||||
export const HISTORY_FILTER_KEYS = ["q", "user", "since", "until"] as const;
|
||||
export const HISTORY_FILTER_KEYS = ["q", "user", "since", "until", "by"] as const;
|
||||
|
||||
export function hasHistoryFilters(f?: HistoryFilters): boolean {
|
||||
return !!f && HISTORY_FILTER_KEYS.some((k) => !!f[k]);
|
||||
@@ -115,7 +121,7 @@ export interface Route {
|
||||
// not a property of the project — a teammate connecting next week has their
|
||||
// own answer, and would be told the wrong thing by a persisted flag.
|
||||
connect?: string;
|
||||
// History feed filters (?q=&user=&since=&until=). Only ever set on the
|
||||
// History feed filters (?q=&user=&since=&until=&by=). Only ever set on the
|
||||
// history view; absent when nothing is filtered.
|
||||
filters?: HistoryFilters;
|
||||
// The history target arrived as ?path=/?prefix= rather than as a path
|
||||
|
||||
@@ -770,6 +770,11 @@ a.ai-main:hover { color: var(--accent); }
|
||||
.hf-search input { height: 30px; padding-left: 29px; font-size: 12.5px; border-radius: var(--r-ctl); background: var(--surface); }
|
||||
.hf-search input::-webkit-search-cancel-button { filter: invert(.6); }
|
||||
.hf-user { height: 30px; max-width: 190px; padding: 0 8px; border-radius: var(--r-ctl); border: 1px solid var(--border); background: var(--surface); color: var(--text); font: inherit; font-size: 12.5px; cursor: pointer; }
|
||||
/* Who changed it: All / Agent runs / Unattributed. Reuses the Dashboard's
|
||||
lens pills — same visual language, already themed — but not its .in-lens
|
||||
wrapper, whose bottom margin is wrong inside this flex row. */
|
||||
.hf-by { display: flex; align-items: center; gap: 6px; }
|
||||
.hf-by .in-lens-btn { height: 30px; padding: 0 12px; }
|
||||
.hf-dates { display: flex; align-items: center; gap: 6px; }
|
||||
.hf-lbl { font-size: 10px; letter-spacing: .06em; text-transform: uppercase; color: var(--text-ghost); }
|
||||
.hf-date { width: 140px; height: 30px; font-size: 12.5px; border-radius: var(--r-ctl); background: var(--surface); }
|
||||
@@ -845,6 +850,9 @@ a.ai-main:hover { color: var(--accent); }
|
||||
/* "this run read it too" — a quieter badge than the kind pill it follows,
|
||||
because the change is still the headline of the row. */
|
||||
.hread { flex: none; padding: 2px 6px; border-radius: 4px; font-size: 10px; text-transform: uppercase; letter-spacing: .06em; font-weight: 600; color: var(--text-dim); background: var(--hover); }
|
||||
/* "an agent run claimed this change" — the accent, because unlike .hread it
|
||||
is a claim about who, and it is the only class the feed will assert. */
|
||||
.hagent { flex: none; padding: 2px 6px; border-radius: 4px; font-size: 10px; text-transform: uppercase; letter-spacing: .06em; font-weight: 600; color: var(--accent); background: var(--hover); }
|
||||
/* What the run read and did NOT change: same two columns as a change row so
|
||||
the eye reads one list, dimmer because nothing moved. */
|
||||
.hrun-reads { border-top: 1px solid var(--border); padding: 4px 0 6px; }
|
||||
@@ -1013,6 +1021,8 @@ a.ai-main:hover { color: var(--accent); }
|
||||
them costs nothing but a second row. */
|
||||
.hf-search input, .hf-user, .hf-date, .hf-clear { height: 44px; }
|
||||
.hf-dates { flex: 1 1 100%; }
|
||||
.hf-by { flex: 1 1 100%; }
|
||||
.hf-by .in-lens-btn { height: auto; }
|
||||
.hf-date { flex: 1; width: auto; min-width: 0; }
|
||||
/* The desktop run header ellipsises the note at 46% and the meta at
|
||||
whatever is left, which on a phone clips to `claude-…` / `Alice <ali…` —
|
||||
|
||||
@@ -137,11 +137,17 @@ func parseHistTime(s string, end bool) (time.Time, bool) {
|
||||
// newest first by wall-clock time, at most ?n= entries (default 100).
|
||||
//
|
||||
// Reader filters — ?q= (case-insensitive substring of the path), ?user=
|
||||
// (exact account), ?since=/?until= (UTC bounds, inclusive at both ends) —
|
||||
// compose with each other and with path/prefix. They are applied in the same
|
||||
// walk as path/prefix, i.e. BEFORE the sort and the cursor skip, so
|
||||
// next_cursor keeps meaning "the next matching entry" and paging under a
|
||||
// filter needs no new machinery.
|
||||
// (exact account), ?since=/?until= (UTC bounds, inclusive at both ends),
|
||||
// ?by= (agent|unattributed) — compose with each other and with path/prefix.
|
||||
// They are applied in the same walk as path/prefix, i.e. BEFORE the sort and
|
||||
// the cursor skip, so next_cursor keeps meaning "the next matching entry" and
|
||||
// paging under a filter needs no new machinery.
|
||||
//
|
||||
// ?by= FILTERS, it does not group — note the wart that /heat's ?by=device
|
||||
// means "group by". agent is Session != "", which only `bdrive sync --hook`
|
||||
// ever sets, so it is a positive claim that is always true. The complement is
|
||||
// "unattributed", NOT "human": the daemon usually commits an agent's write
|
||||
// before the hook's cycle runs, so an empty Session is absence of evidence.
|
||||
//
|
||||
// Paging: the response carries next_cursor when more entries exist, and
|
||||
// ?cursor= resumes just past the entry it was minted from — so history older
|
||||
@@ -174,6 +180,11 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
needle := strings.ToLower(q.Get("q")) // lowered once, not per op
|
||||
user := q.Get("user")
|
||||
by := q.Get("by")
|
||||
if by != "" && by != "agent" && by != "unattributed" {
|
||||
http.Error(w, "invalid by", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var since, until time.Time
|
||||
// since > until is not an error: it means "nothing", which is what it returns.
|
||||
for _, b := range []struct {
|
||||
@@ -247,6 +258,10 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
|
||||
continue
|
||||
case user != "" && op.User != user:
|
||||
continue
|
||||
case by == "agent" && op.Session == "":
|
||||
continue
|
||||
case by == "unattributed" && op.Session != "":
|
||||
continue
|
||||
case !since.IsZero() && op.Time.Before(since):
|
||||
continue
|
||||
case !until.IsZero() && !op.Time.Before(until):
|
||||
|
||||
@@ -72,6 +72,24 @@ func (f *fakeRemote) putFull(dev, user, path, content string, at time.Time) {
|
||||
writeFileT(f.t, p, data)
|
||||
}
|
||||
|
||||
// sessionize stamps the last op on dev's journal with an agent session id —
|
||||
// which, in production, only `bdrive sync --hook` ever sets. It is the whole
|
||||
// signal behind ?by=agent, so a filter test needs a way to plant it.
|
||||
func (f *fakeRemote) sessionize(dev, session string) {
|
||||
f.t.Helper()
|
||||
p := filepath.Join(f.dir, "journal", dev+".jsonl")
|
||||
ops, err := journal.ReadFile(p)
|
||||
if err != nil || len(ops) == 0 {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
ops[len(ops)-1].Session = session
|
||||
data, err := journal.Marshal(ops)
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
writeFileT(f.t, p, data)
|
||||
}
|
||||
|
||||
func writeFileT(t *testing.T, path string, data []byte) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
@@ -428,7 +446,9 @@ func TestHistoryPagingAcrossLamportAndTime(t *testing.T) {
|
||||
|
||||
// seedFiltered builds a feed with two authors, three days and paths that
|
||||
// differ in case, so every filter has something to include and something to
|
||||
// leave out.
|
||||
// leave out. Two of the four ops carry an agent session — one per author —
|
||||
// so ?by=agent and ?by=unattributed are both non-empty and neither maps onto
|
||||
// a single author.
|
||||
func seedFiltered(t *testing.T) (http.Handler, string) {
|
||||
t.Helper()
|
||||
srv, p, root := newHub(t, false, nil)
|
||||
@@ -436,7 +456,9 @@ func seedFiltered(t *testing.T) (http.Handler, string) {
|
||||
day := func(d, h int) time.Time { return time.Date(2026, 7, d, h, 30, 0, 0, time.UTC) }
|
||||
f.putFull("dev1", "mira@acme.io", "docs/Runbook.md", "v1", day(1, 9))
|
||||
f.putFull("dev1", "mira@acme.io", "docs/runbook-old.md", "v1", day(15, 12))
|
||||
f.sessionize("dev1", "sess-mira")
|
||||
f.putFull("dev2", "ken@acme.io", "docs/plan.md", "p1", day(31, 23))
|
||||
f.sessionize("dev2", "sess-ken")
|
||||
f.putFull("dev2", "ken@acme.io", "notes/runbook.md", "n1", day(15, 0))
|
||||
return srv.Handler(), "/api/p/" + p.ID + "/"
|
||||
}
|
||||
@@ -491,8 +513,18 @@ func TestHistoryFilters(t *testing.T) {
|
||||
{"until=2026-07-01T09:30:00Z", []string{"docs/Runbook.md"}},
|
||||
// since > until means nothing, not an error
|
||||
{"since=2026-07-31&until=2026-07-01", []string{}},
|
||||
// agent runs vs unattributed: disjoint, and their union is the feed.
|
||||
// The complement is never called "human" — an empty Session means the
|
||||
// daemon may simply have beaten the hook.
|
||||
{"by=agent", []string{"docs/plan.md", "docs/runbook-old.md"}},
|
||||
{"by=unattributed", []string{"notes/runbook.md", "docs/Runbook.md"}},
|
||||
{"by=", []string{"docs/plan.md", "docs/runbook-old.md", "notes/runbook.md", "docs/Runbook.md"}},
|
||||
// composed with each other…
|
||||
{"q=runbook&user=mira@acme.io", []string{"docs/runbook-old.md", "docs/Runbook.md"}},
|
||||
{"by=agent&user=mira@acme.io", []string{"docs/runbook-old.md"}},
|
||||
{"by=agent&q=runbook", []string{"docs/runbook-old.md"}},
|
||||
{"by=unattributed&user=ken@acme.io", []string{"notes/runbook.md"}},
|
||||
{"by=agent&prefix=notes/", []string{}},
|
||||
{"q=runbook&user=mira@acme.io&since=2026-07-10&until=2026-07-20", []string{"docs/runbook-old.md"}},
|
||||
// …and with the existing prefix/path scoping
|
||||
{"prefix=docs/&q=runbook", []string{"docs/runbook-old.md", "docs/Runbook.md"}},
|
||||
@@ -511,36 +543,46 @@ func TestHistoryFilters(t *testing.T) {
|
||||
// after the skip and this test loses entries.
|
||||
func TestHistoryFilterPaging(t *testing.T) {
|
||||
h, base := seedFiltered(t)
|
||||
filter := "q=runbook"
|
||||
want, next := histPaths(t, h, base+"history?"+filter)
|
||||
if len(want) != 3 || next != "" {
|
||||
t.Fatalf("unpaged filtered feed = %v (next %q)", want, next)
|
||||
}
|
||||
var got []string
|
||||
cursor := ""
|
||||
for pages := 0; ; pages++ {
|
||||
if pages > len(want) {
|
||||
t.Fatalf("paging did not terminate: %v", got)
|
||||
for _, c := range []struct {
|
||||
filter string
|
||||
n int
|
||||
count int
|
||||
}{
|
||||
{"q=runbook", 2, 3},
|
||||
{"by=agent", 1, 2}, // one entry per page walks the whole agent feed
|
||||
} {
|
||||
want, next := histPaths(t, h, base+"history?"+c.filter)
|
||||
if len(want) != c.count || next != "" {
|
||||
t.Fatalf("?%s unpaged = %v (next %q)", c.filter, want, next)
|
||||
}
|
||||
page, next := histPaths(t, h, base+"history?"+filter+"&n=2"+cursorArg(cursor))
|
||||
if len(page) == 0 {
|
||||
t.Fatalf("empty page %d", pages)
|
||||
var got []string
|
||||
cursor := ""
|
||||
for pages := 0; ; pages++ {
|
||||
if pages > len(want) {
|
||||
t.Fatalf("?%s paging did not terminate: %v", c.filter, got)
|
||||
}
|
||||
page, next := histPaths(t, h, fmt.Sprintf("%shistory?%s&n=%d%s", base, c.filter, c.n, cursorArg(cursor)))
|
||||
if len(page) == 0 {
|
||||
t.Fatalf("?%s empty page %d", c.filter, pages)
|
||||
}
|
||||
got = append(got, page...)
|
||||
if next == "" {
|
||||
break
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
got = append(got, page...)
|
||||
if next == "" {
|
||||
break
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("?%s paged = %v, want %v", c.filter, got, want)
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("paged filtered = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A date we can't parse is a 400, not a silently unfiltered feed.
|
||||
// A filter value we can't parse is a 400, not a silently unfiltered feed.
|
||||
// `by=human` is in here on purpose: there is no such class, because an empty
|
||||
// Session cannot tell a person's edit from the daemon beating the hook.
|
||||
func TestHistoryBadDateRange(t *testing.T) {
|
||||
h, base := seedFiltered(t)
|
||||
for _, q := range []string{"since=yesterday", "until=2026-13-45", "since=2026-07-01&until=soon", "since="} {
|
||||
for _, q := range []string{"since=yesterday", "until=2026-13-45", "since=2026-07-01&until=soon", "since=", "by=garbage", "by=human"} {
|
||||
rec := do(t, h, "GET", base+"history?"+q, nil)
|
||||
if q == "since=" { // an empty value is "no filter", like every other param
|
||||
if rec.Code != 200 {
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
@@ -5,10 +5,10 @@
|
||||
<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-DB7i1Dhi.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CjbtsWS5.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-L-I4D1mx.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cf7xD_Vj.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -118,10 +118,16 @@ versions in between stay in the history, the restore itself shows up in
|
||||
teammate like any other edit — so you can restore away from a restore. The hub
|
||||
has the same button on every history row.
|
||||
|
||||
The hub's History view narrows the feed by path substring, author and date
|
||||
range (dates are UTC days, inclusive at both ends). The filters live in the
|
||||
URL — `<project>/history?q=runbook&user=mira@acme.io&since=2026-07-01&until=2026-07-31`
|
||||
The hub's History view narrows the feed by path substring, author, date range
|
||||
(dates are UTC days, inclusive at both ends), and whether an agent run claimed
|
||||
the change (`by=agent`, or `by=unattributed` for everything else). The filters
|
||||
live in the URL —
|
||||
`<project>/history?q=runbook&user=mira@acme.io&since=2026-07-01&until=2026-07-31&by=agent`
|
||||
— so a narrowed feed is a link you can send, and it survives reload and Back.
|
||||
`by=agent` is a positive claim and nothing else is: a change with no agent
|
||||
session may still be an agent's, because the sync daemon often commits the
|
||||
write before the agent's hook runs — which is why the other class is called
|
||||
*unattributed* rather than *human*.
|
||||
Filtering happens on the server, so paging through a filtered feed shows every
|
||||
match, not just the ones on the first page.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user