diff --git a/DESIGN.md b/DESIGN.md
index 12013048..69dd8f8e 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -240,8 +240,10 @@ such detail into a token. New layout spacing must use the scale.
ghost “Browse all reviews” icon action that opens the review picker, and a
close action in the modal drawer only. Below it: a “Search projects” field
styled like the file filter that filters current streams in place (Escape
- clears it; matching repositories expand), then repositories ordered by latest
- arrival.
+ clears it; matching repositories expand), then repositories ordered by their
+ most active visible stream. Streams sort by estimated code activity, with
+ captured collection time as the fallback and stable identity ties. Repository
+ and stream ages describe this activity; snapshot history retains its chronology.
Repository rows match tree-row height with a chevron, repository icon, medium
name and muted monospace age. Stream rows indent under them: branch icon, name,
trailing monospace changed-file count, and a muted metadata line (worktree,
@@ -253,6 +255,12 @@ such detail into a token. New layout spacing must use the scale.
Collapse state persists per browser.
- A newer head for the viewed stream shows a one-line `--accent-bg` notice below
the header with an outline-muted “View latest” action. Never swap the open review.
+- A compact header action counts unopened changed heads across projects and
+ opens the most recently active one. Share the drawer's per-browser seen state,
+ baseline existing heads on first visit, and clear a head's mark when opened.
+ Keep the same-stream notice available. Unchanged submissions must not create
+ unread prompts. Use existing ghost-button typography, icons and accessible
+ labels; preserve the header's review identity and compact-layout controls.
- Status bar: `--statusbar-height` (28), with shortcuts shed before server metrics.
- The diff display toolbar uses `--panel`, matching the header and status bar.
- Desktop sidebar: `--sidebar-width` defaults to 330px. `use-sidebar.ts` owns
diff --git a/apps/web/src/Header.tsx b/apps/web/src/Header.tsx
index 6a7e288f..2f8b3546 100644
--- a/apps/web/src/Header.tsx
+++ b/apps/web/src/Header.tsx
@@ -2,7 +2,7 @@ import { FolderSyncIcon, ListTreeIcon, RefreshCwIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ShellActions } from "./ActivityBar.tsx";
import { DeleteContextButton } from "./DeleteContextButton.tsx";
-import { ProjectsToggle } from "./ProjectsNav.tsx";
+import { NewChangesButton, ProjectsToggle } from "./ProjectsNav.tsx";
import { useDiffSource } from "./app-state.tsx";
import { useSidebarState } from "./sidebar-context.tsx";
import { CurrentContext, useCatalog, useSession } from "./session-context.tsx";
@@ -58,6 +58,7 @@ export function Header() {
+
{collect ? (
(() =>
- readSeen(saved("projects-seen")),
- );
- useEffect(() => {
- if (seen === null && loaded) setSeen(baselineSeen(heads));
- }, [seen, loaded, heads]);
- const currentHead =
- current && heads.some((context) => context.id === current.id)
- ? current
- : undefined;
- useEffect(() => {
- if (!currentHead) return;
- const stream = streamKey(currentHead);
- setSeen((value) =>
- !value || value[stream] === currentHead.id
- ? value
- : { ...value, [stream]: currentHead.id },
- );
- }, [currentHead]);
- useEffect(() => {
- if (seen) save("projects-seen", JSON.stringify(seen));
- }, [seen]);
- return seen;
-}
-
export function ProjectsNav() {
const projects = useProjectsNavState();
const sidebar = useSidebarState();
- const { heads, current, openStream, openPicker } = useCatalog();
- const seen = useSeenStreams(heads.contexts, heads.loaded);
+ const { heads, current, openStream, openPicker, seen } = useCatalog();
const [closed, setClosed] = useState(readCollapsedGroups);
const [query, setQuery] = useState("");
const now = useNow();
@@ -362,6 +330,31 @@ function ProjectsEmpty({
);
}
+/** Opens the most active unread head without interrupting the open review. */
+export function NewChangesButton() {
+ const { unreadHeads, openStream } = useCatalog();
+ const latest = unreadHeads[0];
+ if (!latest) return null;
+ const count = unreadHeads.length;
+ return (
+ openStream(latest)}
+ >
+
+
+ {count}
+ new
+
+
+ );
+}
+
/** Announces a newer head for the viewed stream without replacing the review. */
export function NewerSnapshotNotice() {
const session = useSession();
@@ -375,7 +368,7 @@ export function NewerSnapshotNotice() {
Newer snapshot of {streamTitle(newer)} · {contextChangeLabel(newer)}{" "}
- · {relativeAge(newer.lastSubmittedAt, now)}
+ · {relativeAge(contextActivity(newer), now)}
0
+ ? context.lastChangedAt
+ : (context.observation?.collectedAt ?? context.createdAt);
+}
+
+export function compareContextActivity(left: ApiContext, right: ApiContext) {
+ return (
+ contextActivity(right) - contextActivity(left) ||
+ (left.streamId ?? left.id).localeCompare(right.streamId ?? right.id) ||
+ left.id.localeCompare(right.id)
+ );
+}
+
+export function latestChangedContext(contexts: ApiContext[]) {
+ return contexts
+ .filter((context) => !context.stale && contextHasChanges(context))
+ .sort(compareContextActivity)[0];
+}
diff --git a/apps/web/src/project-nav.ts b/apps/web/src/project-nav.ts
index ebb35454..900b8a60 100644
--- a/apps/web/src/project-nav.ts
+++ b/apps/web/src/project-nav.ts
@@ -1,8 +1,10 @@
import type { ApiContext } from "@diffx/api";
+import { compareContextActivity, contextActivity } from "./context-activity.ts";
import {
contextHasChanges,
contextIsPiped,
contextMatches,
+ contextTimestamp,
searchWords,
} from "./project-picker.ts";
@@ -59,15 +61,16 @@ export function projectGroups(heads: ApiContext[], seen: SeenStreams | null) {
context,
unseen: seen !== null && seen[stream] !== context.id,
});
- group.lastActivity = Math.max(group.lastActivity, context.lastSubmittedAt);
+ group.lastActivity = Math.max(group.lastActivity, contextActivity(context));
groups.set(id, group);
}
const result = [...groups.values()];
for (const group of result) {
- group.streams.sort(
- (left, right) =>
- right.context.lastSubmittedAt - left.context.lastSubmittedAt ||
- left.id.localeCompare(right.id),
+ group.streams.sort((left, right) =>
+ group.kind === "piped"
+ ? contextTimestamp(right.context) - contextTimestamp(left.context) ||
+ left.id.localeCompare(right.id)
+ : compareContextActivity(left.context, right.context),
);
if (group.kind === "piped")
group.streams = group.streams.slice(0, pipedLimit);
@@ -75,12 +78,17 @@ export function projectGroups(heads: ApiContext[], seen: SeenStreams | null) {
new Set(group.streams.map((stream) => streamHost(stream.context))).size >
1;
}
- return result.sort(
- (left, right) =>
+ return result.sort((left, right) => {
+ const leftHead = left.streams[0]?.context;
+ const rightHead = right.streams[0]?.context;
+ return (
Number(left.kind === "piped") - Number(right.kind === "piped") ||
- right.lastActivity - left.lastActivity ||
- left.name.localeCompare(right.name),
- );
+ (leftHead && rightHead
+ ? compareContextActivity(leftHead, rightHead)
+ : 0) ||
+ left.id.localeCompare(right.id)
+ );
+ });
}
/** Keeps streams whose metadata matches every search word, preserving order. */
@@ -124,7 +132,7 @@ export function streamMeta(
worktree,
harness,
multipleHosts ? streamHost(context) : "",
- relativeAge(context.lastSubmittedAt, now),
+ relativeAge(contextActivity(context), now),
]
.filter(Boolean)
.join(" · ");
@@ -146,6 +154,22 @@ export function baselineSeen(heads: ApiContext[]): SeenStreams {
);
}
+/** Unread content heads, independent of ingestion order and submission touches. */
+export function unseenChangedHeads(
+ heads: ApiContext[],
+ seen: SeenStreams | null,
+) {
+ if (seen === null) return [];
+ return heads
+ .filter(
+ (context) =>
+ !context.stale &&
+ contextHasChanges(context) &&
+ seen[streamKey(context)] !== context.id,
+ )
+ .sort(compareContextActivity);
+}
+
export function readSeen(raw: string | null): SeenStreams | null {
if (raw === null) return null;
try {
diff --git a/apps/web/src/session-context.tsx b/apps/web/src/session-context.tsx
index c4a55800..946f157a 100644
--- a/apps/web/src/session-context.tsx
+++ b/apps/web/src/session-context.tsx
@@ -19,8 +19,14 @@ import { useIdentity, navigate } from "./identity-context.tsx";
import { Button } from "@/components/ui/button";
import { ContextIdentity } from "./ContextIdentity.tsx";
import { ProjectPicker } from "./ProjectPicker.tsx";
-import { ProjectsNav, ProjectsToggle } from "./ProjectsNav.tsx";
-import { contextHasChanges } from "./project-picker.ts";
+import {
+ NewChangesButton,
+ ProjectsNav,
+ ProjectsToggle,
+} from "./ProjectsNav.tsx";
+import { latestChangedContext } from "./context-activity.ts";
+import { unseenChangedHeads, type SeenStreams } from "./project-nav.ts";
+import { useSeenStreams } from "./use-seen-streams.ts";
import { useCompactChrome } from "./use-compact-chrome.ts";
import {
useContextCatalog,
@@ -37,6 +43,8 @@ interface Catalog {
heads: ContextCatalog;
/** The selected review once loaded, otherwise its catalog entry if known. */
current: ApiContext | undefined;
+ seen: SeenStreams | null;
+ unreadHeads: ApiContext[];
selectedId: string;
/** Pins one snapshot. */
select: (id: string, options?: { restoreFocus?: boolean }) => void;
@@ -235,8 +243,8 @@ export function SessionProvider({ children }: { children: ReactNode }) {
removeHead(id);
removeHistory(id);
if (selectedRef.current === id) {
- const next = heads.contexts.find(
- (context) => context.id !== id && contextHasChanges(context),
+ const next = latestChangedContext(
+ heads.contexts.filter((context) => context.id !== id),
);
if (next) openStream(next, { replace: true });
else {
@@ -280,7 +288,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const head =
route.kind === "live"
? contexts.find((context) => context.streamId === route.stream)
- : contexts.findLast(contextHasChanges);
+ : latestChangedContext(contexts);
if (head) openStream(head, { replace: true, restoreFocus: false });
else setUncollected(route);
});
@@ -291,7 +299,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const head =
route.kind === "live"
? heads.contexts.find((context) => context.streamId === route.stream)
- : heads.contexts.find(contextHasChanges);
+ : latestChangedContext(heads.contexts);
if (head) openStream(head, { replace: true, restoreFocus: false });
else if (route.kind === "live") setMissing(true);
}, [
@@ -334,10 +342,21 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const current = ready
? state.session
: heads.contexts.find((context) => context.id === selectedId);
+ const seen = useSeenStreams(
+ heads.contexts,
+ heads.loaded,
+ ready ? current : undefined,
+ );
+ const unreadHeads = useMemo(
+ () => unseenChangedHeads(heads.contexts, seen),
+ [heads.contexts, seen],
+ );
const catalog = useMemo(
() => ({
heads,
current,
+ seen,
+ unreadHeads,
selectedId,
select,
openStream,
@@ -351,6 +370,8 @@ export function SessionProvider({ children }: { children: ReactNode }) {
[
heads,
current,
+ seen,
+ unreadHeads,
selectedId,
select,
openStream,
@@ -370,10 +391,15 @@ export function SessionProvider({ children }: { children: ReactNode }) {
return;
restorePickerFocus.current = false;
if (empty) statusRef.current?.focus();
- else
- document
- .querySelector('#projects-nav [aria-current="page"]')
- ?.focus();
+ else {
+ const selected = document.querySelector(
+ '#projects-nav [aria-current="page"]',
+ );
+ const target = selected?.getClientRects().length
+ ? selected
+ : document.querySelector("#projects-toggle");
+ target?.focus();
+ }
}, [state, empty]);
return (
@@ -388,6 +414,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
+
{compact && }
diff --git a/apps/web/src/use-seen-streams.ts b/apps/web/src/use-seen-streams.ts
new file mode 100644
index 00000000..7dfb3df7
--- /dev/null
+++ b/apps/web/src/use-seen-streams.ts
@@ -0,0 +1,40 @@
+import type { ApiContext } from "@diffx/api";
+import { useEffect, useState } from "react";
+import { save, saved } from "./preferences.ts";
+import {
+ baselineSeen,
+ readSeen,
+ streamKey,
+ type SeenStreams,
+} from "./project-nav.ts";
+
+// One browser-local owner supplies both drawer dots and global navigation.
+export function useSeenStreams(
+ heads: ApiContext[],
+ loaded: boolean,
+ current: ApiContext | undefined,
+) {
+ const [seen, setSeen] = useState(() =>
+ readSeen(saved("projects-seen")),
+ );
+ useEffect(() => {
+ if (seen === null && loaded) setSeen(baselineSeen(heads));
+ }, [seen, loaded, heads]);
+ const currentHead =
+ current && heads.some((context) => context.id === current.id)
+ ? current
+ : undefined;
+ useEffect(() => {
+ if (!currentHead) return;
+ const stream = streamKey(currentHead);
+ setSeen((value) =>
+ !value || value[stream] === currentHead.id
+ ? value
+ : { ...value, [stream]: currentHead.id },
+ );
+ }, [currentHead]);
+ useEffect(() => {
+ if (seen) save("projects-seen", JSON.stringify(seen));
+ }, [seen]);
+ return seen;
+}
diff --git a/apps/web/test/browser/local-workspace.spec.ts b/apps/web/test/browser/local-workspace.spec.ts
index c9c7787e..13b35132 100644
--- a/apps/web/test/browser/local-workspace.spec.ts
+++ b/apps/web/test/browser/local-workspace.spec.ts
@@ -1102,16 +1102,25 @@ for (const kind of ["observation"] satisfies ApiContext["kind"][]) {
});
}
-test("landing redirects to the first usable diff across catalog pages", async ({
+test("landing selects the most active usable diff across catalog pages", async ({
page,
}) => {
const state = await localWorkspace(page);
+ state.context.lastChangedAt = 30;
await page.route("**/api/v2/contexts?*", (route) => {
const cursor = new URL(route.request().url()).searchParams.get("cursor");
return route.fulfill({
json: cursor
? {
- contexts: [state.context, { ...state.context, id: "later" }],
+ contexts: [
+ {
+ ...state.context,
+ id: "later",
+ lastChangedAt: 20,
+ lastSubmittedAt: 900,
+ },
+ state.context,
+ ],
nextCursor: null,
}
: {
@@ -1338,8 +1347,9 @@ test("observation picker drills into repositories and searches independent sourc
await expect(page.locator("#repo-path")).not.toContainText("Collected");
await expect(page.locator("#repo-path")).toHaveAttribute(
"title",
- /Context: first/,
+ /Context: third/,
);
+ await expect(page).toHaveURL(/\/contexts\/third$/);
await expect(page.locator(".context-identity-worktree")).toHaveCount(0);
const requests = state.scopes.length;
const catalogReads = catalogRequests;
@@ -1582,6 +1592,7 @@ test("projects drawer lists current changed streams and announces newer snapshot
streamId: `stream-${branch}`,
latestContextId: id,
lastSubmittedAt,
+ lastChangedAt: lastSubmittedAt,
changedFileCount,
});
const viewed = stream("rate-1", "acme-api", "feat/rate-limit", 10);
@@ -1739,6 +1750,152 @@ test("live stream URLs open the latest head on load and keep it until navigation
).toBeVisible();
});
+for (const mode of ["local", "remote"]) {
+ test(`${mode} global new changes opens the most active unread stream explicitly`, async ({
+ page,
+ }) => {
+ await page.clock.install();
+ await page.addInitScript(() => {
+ class IngestionEvents extends EventTarget {
+ notify = () =>
+ this.dispatchEvent(new MessageEvent("ingestion", { data: "{}" }));
+ constructor() {
+ super();
+ window.addEventListener("test-ingestion", this.notify);
+ }
+ close() {
+ window.removeEventListener("test-ingestion", this.notify);
+ }
+ }
+ Object.defineProperty(window, "EventSource", { value: IngestionEvents });
+ });
+ const state = await localWorkspace(page);
+ await page.route("**/api/v2/bootstrap", (route) =>
+ route.fulfill({
+ json: {
+ mode,
+ signupEnabled: false,
+ user:
+ mode === "remote" ? { id: "account-a", username: "alice" } : null,
+ csrfToken: mode === "remote" ? "csrf" : null,
+ collect: mode === "local",
+ },
+ }),
+ );
+ let collections = 0;
+ await page.route("**/api/v2/collections", (route) => {
+ collections++;
+ return route.fulfill({ json: { contexts: heads, failures: [] } });
+ });
+ const snapshot = (
+ id: string,
+ repository: string,
+ activity: number,
+ arrival: number,
+ ): ApiContext => ({
+ ...state.context,
+ id,
+ repositoryId: repository,
+ name: repository,
+ streamId: `stream-${repository}`,
+ latestContextId: id,
+ lastChangedAt: activity,
+ lastSubmittedAt: arrival,
+ });
+ const viewed = snapshot("viewed", "api", 100, 1);
+ const olderWeb = snapshot("web-old", "web", 50, 2);
+ const contexts = new Map(
+ [viewed, olderWeb].map((context) => [context.id, context]),
+ );
+ let heads = [olderWeb, viewed];
+ await page.route("**/api/v2/contexts?*", (route) =>
+ route.fulfill({ json: { contexts: heads, nextCursor: null } }),
+ );
+ await page.route("**/api/v2/contexts/*", (route) =>
+ route.fulfill({
+ json: contexts.get(
+ new URL(route.request().url()).pathname.split("/").at(-1) ?? "",
+ ),
+ }),
+ );
+ await page.goto("/contexts/viewed");
+ await ready(page);
+ const global = page.getByRole("button", {
+ name: /^Open latest new changes/,
+ });
+ await expect(global).toHaveCount(0);
+ const nav = page.getByRole("navigation", { name: "Projects" });
+ await expect(nav.locator(".project-name")).toHaveText(["api", "web"]);
+
+ const newerApi = snapshot("api-new", "api", 200, 900);
+ const newerWeb = snapshot("web-new", "web", 400, 10);
+ contexts.set(newerApi.id, newerApi);
+ contexts.set(newerWeb.id, newerWeb);
+ heads = [newerApi, newerWeb];
+ await page.evaluate(() =>
+ window.dispatchEvent(new Event("test-ingestion")),
+ );
+ await expect(global).toHaveAccessibleName(
+ "Open latest new changes (2 streams)",
+ );
+ await expect(global).toHaveText("2 new");
+ await expect(nav.locator(".project-name")).toHaveText(["web", "api"]);
+ await expect(page.locator(".snapshot-notice")).toContainText(
+ "Newer snapshot",
+ );
+ await expect(page).toHaveURL(/\/contexts\/viewed$/);
+ await page.clock.fastForward(5 * 60_000);
+ expect(collections).toBe(0);
+
+ await global.focus();
+ await page.keyboard.press("Enter");
+ await ready(page);
+ await expect(page).toHaveURL(/\/streams\/stream-web$/);
+ await expect(global).toHaveAccessibleName(
+ "Open latest new changes (1 stream)",
+ );
+ await expect(page.locator(".snapshot-notice")).toBeHidden();
+ await expect(nav.locator('[aria-current="page"]')).toBeFocused();
+
+ // Compact chrome preserves the action and restores focus to the closed drawer's toggle.
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.emulateMedia({ colorScheme: "dark" });
+ await expect(global).toBeVisible();
+ const bounds = await global.boundingBox();
+ expect(bounds && bounds.x >= 0 && bounds.x + bounds.width <= 390).toBe(
+ true,
+ );
+ await global.focus();
+ await page.keyboard.press("Enter");
+ await ready(page);
+ await expect(page).toHaveURL(/\/streams\/stream-api$/);
+ await expect(global).toHaveCount(0);
+ await expect(
+ page.getByRole("button", { name: "Show projects" }),
+ ).toBeFocused();
+ // A new submission of the same head may reorder projects, but adds no unread content.
+ heads = [
+ { ...newerApi, lastSubmittedAt: 1000, lastChangedAt: 500 },
+ newerWeb,
+ ];
+ await page.evaluate(() =>
+ window.dispatchEvent(new Event("test-ingestion")),
+ );
+ await page.setViewportSize({ width: 1440, height: 900 });
+ await page.emulateMedia({ colorScheme: "light" });
+ await expect(nav.locator(".project-name")).toHaveText(["api", "web"]);
+ await expect(global).toHaveCount(0);
+ expect(collections).toBe(0);
+
+ // Root selection shares activity ordering for stored and collected heads.
+ heads = [newerWeb, { ...newerApi, lastChangedAt: 500 }];
+ await page.goto("/");
+ await ready(page);
+ await expect(page).toHaveURL(/\/streams\/stream-api$/);
+ expect(collections).toBe(mode === "local" ? 1 : 0);
+ });
+}
+
test("local live URLs sync the checkouts on every load", async ({ page }) => {
const state = await localWorkspace(page);
await page.route("**/api/v2/bootstrap", (route) =>
@@ -1782,10 +1939,18 @@ test("local live URLs sync the checkouts on every load", async ({ page }) => {
json: { detail: "Unable to collect checkouts: git failed" },
});
// The root opens the newest changed head; live URLs open their own stream.
- const other = { ...snapshot(`other-${collected}`), streamId: "other" };
+ const other = {
+ ...snapshot(`other-${collected}`),
+ streamId: "other",
+ lastChangedAt: 100,
+ lastSubmittedAt: 900,
+ };
return route.fulfill({
json: {
- contexts: [other, snapshot(`collected-${collected}`)],
+ contexts: [
+ { ...snapshot(`collected-${collected}`), lastChangedAt: 200 },
+ other,
+ ],
failures: [],
},
});
diff --git a/apps/web/test/project-nav.test.ts b/apps/web/test/project-nav.test.ts
index 4183518c..556fdf72 100644
--- a/apps/web/test/project-nav.test.ts
+++ b/apps/web/test/project-nav.test.ts
@@ -9,7 +9,13 @@ import {
relativeAge,
searchProjectGroups,
streamMeta,
+ unseenChangedHeads,
} from "../src/project-nav.ts";
+import {
+ compareContextActivity,
+ contextActivity,
+ latestChangedContext,
+} from "../src/context-activity.ts";
function head(
id: string,
@@ -105,6 +111,160 @@ test("project search keeps matching streams in order and drops empty projects",
assert.deepEqual(ids("missing"), []);
});
+test("activity orders streams and repositories independently of submission time", () => {
+ const active = head("active", "api", 10, { lastChangedAt: 300 });
+ const submitted = head("submitted", "web", 500, { lastChangedAt: 100 });
+ const groups = projectGroups(
+ [submitted, head("old", "api", 900, { lastChangedAt: 50 }), active],
+ null,
+ );
+ assert.deepEqual(
+ groups.map((group) => [
+ group.name,
+ group.streams.map((stream) => stream.context.id),
+ ]),
+ [
+ ["api", ["active", "old"]],
+ ["web", ["submitted"]],
+ ],
+ );
+ assert.equal(latestChangedContext([submitted, active]), active);
+ assert.equal(latestChangedContext([active, submitted]), active);
+ assert.equal(
+ latestChangedContext([
+ { ...active, stale: true },
+ { ...submitted, changedFileCount: 0 },
+ ]),
+ undefined,
+ );
+ assert.equal(
+ streamMeta({ ...active, lastChangedAt: 60_000 }, false, 360_000),
+ "5m",
+ );
+});
+
+test("missing activity uses captured time, never submission arrival", () => {
+ const context = head("saved", "api", 900, {
+ lastChangedAt: 0,
+ createdAt: 20,
+ });
+ assert.equal(contextActivity(context), 20);
+ const observed = head("observed", "api", 1, {
+ worktreeName: "wt",
+ observation: {
+ sourceId: "source",
+ hostname: "host",
+ runId: "",
+ agent: "",
+ remoteUrl: "",
+ head: null,
+ repositoryKey: "repo",
+ repositoryName: "api",
+ checkoutKey: "checkout",
+ linkedWorktree: false,
+ root: "/repo",
+ worktreeName: "wt",
+ branch: "main",
+ collectedAt: 40,
+ collectorVersion: "test",
+ trigger: "manual",
+ },
+ lastChangedAt: 0,
+ });
+ assert.equal(contextActivity(observed), 40);
+ assert.equal(latestChangedContext([context, observed]), observed);
+});
+
+test("activity ties use stream then context identity, including same-name projects", () => {
+ const contexts = [
+ head("b", "repo-b", 10, { name: "same", streamId: "stream-a" }),
+ head("a", "repo-b", 20, {
+ name: "same",
+ lastChangedAt: 10,
+ streamId: "stream-a",
+ }),
+ head("c", "repo-a", 10, { name: "same", streamId: "stream-z" }),
+ ];
+ assert.deepEqual(
+ [...contexts].sort(compareContextActivity).map((context) => context.id),
+ ["a", "b", "c"],
+ );
+ for (const heads of [contexts, [...contexts].reverse()]) {
+ assert.equal(latestChangedContext(heads)?.id, "a");
+ assert.deepEqual(
+ projectGroups(heads, null).map((group) => group.id),
+ ["repo-b", "repo-a"],
+ );
+ }
+});
+
+test("equal-activity repository order aligns initial and unread targets over names", () => {
+ const contexts = [
+ head("first", "repo-z", 50, { name: "Zebra", streamId: "stream-a" }),
+ head("last", "repo-a", 50, { name: "Alpha", streamId: "stream-z" }),
+ ];
+ for (const heads of [contexts, [...contexts].reverse()]) {
+ const top = projectGroups(heads, {}).at(0)?.streams.at(0)?.context;
+ assert.equal(top?.id, "first");
+ assert.equal(top, latestChangedContext(heads));
+ assert.equal(top, unseenChangedHeads(heads, {}).at(0));
+ }
+});
+
+test("Piped stays last and preserves captured chronology despite activity or arrival", () => {
+ const contexts = [
+ head("older", "", 900, {
+ source: "stdin",
+ streamId: null,
+ createdAt: 10,
+ lastChangedAt: 500,
+ }),
+ head("newer", "", 100, {
+ source: "stdin",
+ streamId: null,
+ createdAt: 20,
+ lastChangedAt: 50,
+ }),
+ head("repository", "api", 1),
+ ];
+ for (const heads of [contexts, [...contexts].reverse()]) {
+ const groups = projectGroups(heads, null);
+ assert.equal(groups.at(0)?.id, "api");
+ assert.deepEqual(
+ groups.at(-1)?.streams.map((stream) => stream.context.id),
+ ["newer", "older"],
+ );
+ }
+});
+
+test("global unread heads count changed streams and open by activity", () => {
+ const viewed = head("viewed", "api", 1);
+ const seen = baselineSeen([viewed]);
+ const unchanged = { ...viewed, lastSubmittedAt: 999, lastChangedAt: 500 };
+ const recent = head("recent", "web", 10, { lastChangedAt: 300 });
+ const arrival = head("arrival", "api", 200, { lastChangedAt: 100 });
+ const heads = [
+ unchanged,
+ arrival,
+ recent,
+ head("clean", "api", 400, { changedFileCount: 0 }),
+ head("stale", "api", 500, { stale: true }),
+ ];
+ assert.deepEqual(unseenChangedHeads(heads, null), []);
+ assert.deepEqual(
+ unseenChangedHeads(heads, seen).map((context) => context.id),
+ ["recent", "arrival"],
+ );
+ assert.deepEqual(unseenChangedHeads(heads, baselineSeen(heads)), []);
+ assert.deepEqual(
+ unseenChangedHeads(heads, {
+ ...seen,
+ [recent.streamId ?? recent.id]: recent.id,
+ }).map((context) => context.id),
+ ["arrival"],
+ );
+});
+
test("unseen marks follow the newest opened snapshot per stream", () => {
const first = head("first", "api", 1);
const seen = baselineSeen([first]);
diff --git a/docs/architecture.md b/docs/architecture.md
index 3d585740..e8514c99 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -36,6 +36,12 @@ scope and widens it before ingesting a newly discovered checkout; `contextservic
applies it to listing, counting, resolution and deletion, and out-of-scope
contexts are not found. A scoped selection without checkouts matches nothing.
+Activity metadata is collected by the shared producer pipeline, never by catalog
+reads. Both compositions project activity from the winning stream submission and
+use it for current-work navigation; collection time and acceptance sequence still
+choose the stream head. Workspace discovery and web-triggered collection remain
+local-only. There is no background sync, watcher or periodic collection.
+
Private local control supports authenticated status/shutdown only. Replacement
uses lifecycle locking, a validated descriptor and graceful shutdown; a PID
alone is never authority. `diffx dev --fixture FILE` is isolated from
@@ -168,6 +174,9 @@ State errors identify the database and backup directory, name the compatible
schema, and show the `--state` override for explicitly selecting fresh state.
Fresh state has no saved reviews or server accounts.
Upgrade server and collectors together.
+Install the server first when adding optional protocol 4 metadata: older servers
+reject unknown request fields. Activity metadata is optional, so existing saved
+submissions remain replayable and schema 11 state needs no reset.
Versioned producer directories ignore old manual/hook pending payloads.
Stop old binaries before upgrading.
diff --git a/docs/development.md b/docs/development.md
index f1842fad..9326a17c 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -266,10 +266,18 @@ picker. The selected context scopes every request. The session owner exposes
local checkout sync when bootstrap reports `collect`; loading `/` or a live
`/streams/{streamId}` URL, the header's “Sync all checkouts” icon and Alt+R call
`POST /api/v2/collections`. Load-time sync opens the stream's synced head, or the
-newest changed one for `/`, and shows “Collecting latest changes” until it
+most recently active changed one for `/`, and shows “Collecting latest changes” until it
resolves. An explicit sync never replaces the open review: it reloads the heads,
-so the newer-head notice and drawer dots announce new snapshots, and feedback
-reports synced, updated and failed checkouts. The “Switch review” picker first selects a repository
+so the newer-head notice, drawer dots and global unread action announce new
+snapshots, and feedback reports synced, updated and failed checkouts. The global
+action opens the most recently active unopened changed head. The drawer and
+header share one per-browser seen-state owner; first visits baseline existing
+heads and unchanged submissions do not create unread prompts. Activity ordering
+uses catalog `lastChangedAt`, falling back to captured collection time, with
+stable stream/context identity ties. Repositories use their most active visible
+stream. Startup and root selection follow the same ordering. Collection triggers
+are unchanged; no background sync or Git watcher is scheduled.
+The “Switch review” picker first selects a repository
or Piped, then an immutable observation. Repository observations retain branch,
worktree, source/run, comparison, and collection-time metadata. Submission/session
associations remain separate from original snapshot metadata; catalog API filters
@@ -293,7 +301,7 @@ ties. Freshness uses a durable stream head, including deduplicated collections a
observations outside the current search or page. Expiry and pruning leave that head
intact, so older retained snapshots stay stale. A newer collection of previous
content makes its existing review latest again; its original snapshot metadata stays fixed.
-Changed observations come first, ordered by last submission time; counts
+In the historical picker, changed observations come first, ordered by last submission time; counts
describe their fixed snapshots. Host, Branch, and Worktree filters apply before
repository grouping. Unavailable entries are disabled and skipped during keyboard
navigation. Filter selections persist while navigating and reopening the picker.
diff --git a/docs/release.md b/docs/release.md
index 64d651e0..bfcf697d 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -43,7 +43,10 @@ Stop running binaries before upgrading. Current schema 11 state survives
subsequent starts; older, future or unrecognized schemas are refused without
resetting data. Back up the state directory and explicitly choose a fresh
database path if discarding incompatible state. Upgrade server and collectors
-together for ingestion protocol 4. In-memory state lasts only for one process.
+together for ingestion protocol 4, deploying the server first: new producers send
+optional activity metadata that older servers reject. Existing protocol 4
+submissions without that metadata still replay unchanged; schema 11 needs no
+reset for activity ordering. In-memory state lasts only for one process.
The tap branch is deterministic per version, such as `diffx-v0.2.0`.
Rerunning a release whose tag's first parent is still current `main` reuses the existing
diff --git a/docs/system.md b/docs/system.md
index 39cb7b1d..d7270278 100644
--- a/docs/system.md
+++ b/docs/system.md
@@ -100,8 +100,11 @@ Navigation is project-driven. The Projects drawer queries
heads by repository. A stream is one owner, source, repository, checkout, branch
and comparison policy; every context carries its opaque `streamId` and the
stream's current `latestContextId`. The drawer hides clean, unavailable and
-unknown-status heads, orders repositories and streams by latest arrival, and
-marks streams whose head this browser has not opened. Ingestion events reload
+unknown-status heads, orders streams by recent code activity and repositories by
+their most active visible stream, and marks heads this browser has not opened.
+Equal activity uses stable stream and context identities. A global header action
+counts unopened changed heads and opens the most active one. First visits mark
+existing heads as seen; opening a head clears its mark. Ingestion events reload
the heads; when the viewed context's stream has a newer head, a notice offers it
without replacing the open review. URLs are live or pinned: `/streams/{streamId}`
opens the stream's latest head when loaded or navigated to, while
@@ -110,12 +113,35 @@ live URLs; the picker opens pinned ones; piped snapshots have no stream and stay
pinned. A live URL resolves once, so later arrivals raise the notice instead of
swapping the open review. With local collection enabled, loading `/` or a live
URL first syncs the checkouts, then opens the stream's synced head or, for `/`,
-the newest changed one; a failed sync opens the stored head and reports the
-error. Otherwise `/` opens the newest changed head's live URL.
+the most recently active changed one; a failed sync opens the stored head and
+reports the error. Otherwise `/` opens the most recently active changed head's
+live URL. Startup selection and the local default MCP context use the same
+activity ordering.
The drawer's search filters those loaded heads by the picker's metadata terms;
the header only names the open review. An explicit local sync, like an arrival,
raises the notice instead of replacing the open review.
+Activity is a producer estimate, separate from collection freshness and unread
+state. Checkout collection takes the maximum of HEAD's committer time, changed
+paths' modification times and the index modification time when staged changes
+exist. Deleted paths use the nearest surviving directory's modification time.
+Collection Git reads disable optional locks and diff's automatic index refresh
+so they do not manufacture activity by updating the index timestamp during Sync.
+Merge-base checkout comparisons include these local-change signals; objects-only
+branch collection uses the captured target commit's committer time and ignores
+the checkout. Missing activity falls back to captured collection time. Touches,
+index rewrites and producer clocks can affect this heuristic. Activity failures
+do not discard an otherwise valid diff; cancellation still stops collection.
+
+The optional submission `activityAt` timestamp is projected as catalog
+`lastChangedAt`. Current heads use their winning submission, including a fresh
+submission that reuses an existing observation. Historical snapshots retain
+their original activity; captured observation metadata never changes. Activity
+does not participate in content identity, unchanged-upload suppression or stream
+head selection. Unchanged submissions can update ordering without producing an
+unread-content indicator. Piped history and the full catalog retain their
+existing collection/submission chronology.
+
The review picker, opened by Cmd/Ctrl+K or the drawer's “Browse all reviews”
action, loads the full catalog only while open. It selects a
repository, then an observation identified by worktree, branch, source, run and time. Distinct
diff --git a/internal/collector/activity_test.go b/internal/collector/activity_test.go
new file mode 100644
index 00000000..30567334
--- /dev/null
+++ b/internal/collector/activity_test.go
@@ -0,0 +1,125 @@
+package collector
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "testing"
+ "time"
+
+ "github.com/flexdinesh/diffx/internal/diffsource"
+)
+
+func activityTime(t *testing.T, path string, timestamp time.Time) {
+ t.Helper()
+ if err := os.Chtimes(path, timestamp, timestamp); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestCollectionActivityIncludesCommitAndCheckoutChanges(t *testing.T) {
+ committed := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
+ t.Setenv("GIT_AUTHOR_DATE", committed.Format(time.RFC3339))
+ t.Setenv("GIT_COMMITTER_DATE", committed.Format(time.RFC3339))
+ root := repository(t)
+ write(t, root, "tracked", "base\n")
+ git(t, root, "add", ".")
+ git(t, root, "commit", "-m", "initial")
+ git(t, root, "switch", "-c", "feature")
+ assertActivity := func(want time.Time) {
+ t.Helper()
+ request := collect(t, root)
+ if request.Metadata.ActivityAt != want.UnixMilli() {
+ t.Fatalf("activity %d, want %d", request.Metadata.ActivityAt, want.UnixMilli())
+ }
+ }
+ assertActivity(committed)
+ write(t, root, "tracked", "edited\n")
+ edited := committed.Add(time.Hour)
+ activityTime(t, filepath.Join(root, "tracked"), edited)
+ assertActivity(edited)
+ git(t, root, "add", ".")
+ _, gitDir, err := diffsource.RepositoryIdentity(t.Context(), root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ staged := edited.Add(time.Hour)
+ activityTime(t, filepath.Join(gitDir, "index"), staged)
+ assertActivity(staged)
+ assertActivity(staged) // Collection must not refresh staged index activity.
+ git(t, root, "mv", "tracked", "renamed")
+ renamed := staged.Add(time.Hour)
+ activityTime(t, filepath.Join(root, "renamed"), renamed)
+ activityTime(t, root, renamed)
+ activityTime(t, filepath.Join(gitDir, "index"), staged)
+ assertActivity(renamed)
+ if err := os.Remove(filepath.Join(root, "renamed")); err != nil {
+ t.Fatal(err)
+ }
+ deleted := renamed.Add(time.Hour)
+ activityTime(t, root, deleted)
+ assertActivity(deleted)
+}
+
+func TestObjectCollectionActivityUsesCapturedTargetCommit(t *testing.T) {
+ target := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
+ t.Setenv("GIT_AUTHOR_DATE", target.Format(time.RFC3339))
+ t.Setenv("GIT_COMMITTER_DATE", target.Format(time.RFC3339))
+ root := branchRepository(t)
+ git(t, root, "switch", "main")
+ other := target.Add(24 * time.Hour)
+ t.Setenv("GIT_AUTHOR_DATE", other.Format(time.RFC3339))
+ t.Setenv("GIT_COMMITTER_DATE", other.Format(time.RFC3339))
+ git(t, root, "commit", "--allow-empty", "-m", "new unrelated HEAD")
+ write(t, root, "tracked", "unrelated staged\n")
+ git(t, root, "add", ".")
+ write(t, root, "tracked", "unrelated unstaged\n")
+ write(t, root, "untracked", "unrelated\n")
+ options := branchOptions()
+ options.Branch = "feature"
+ request, err := Collect(t.Context(), root, options)
+ if err != nil || request.Metadata.ActivityAt != target.UnixMilli() {
+ t.Fatalf("target activity %d, want %d: %v", request.Metadata.ActivityAt, target.UnixMilli(), err)
+ }
+}
+
+func TestUnbornCollectionActivityUsesUntrackedFiles(t *testing.T) {
+ root := repository(t)
+ if got := collect(t, root).Metadata.ActivityAt; got != 0 {
+ t.Fatalf("empty unborn checkout activity: %d", got)
+ }
+ write(t, root, "untracked", "new\n")
+ edited := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
+ activityTime(t, filepath.Join(root, "untracked"), edited)
+ if got := collect(t, root).Metadata.ActivityAt; got != edited.UnixMilli() {
+ t.Fatalf("untracked unborn activity: %d, want %d", got, edited.UnixMilli())
+ }
+}
+
+func TestActivityFailureDoesNotDiscardValidDiff(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("Git wrapper requires POSIX shell")
+ }
+ root := branchRepository(t)
+ realGit, err := exec.LookPath("git")
+ if err != nil {
+ t.Fatal(err)
+ }
+ bin := t.TempDir()
+ script := `#!/bin/sh
+case " $* " in
+ *" --format=%ct "*) exit 9 ;;
+esac
+exec "$DIFFX_TEST_REAL_GIT" "$@"
+`
+ if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("DIFFX_TEST_REAL_GIT", realGit)
+ t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
+ request := collect(t, root)
+ if request.Metadata.ActivityAt != 0 || len(request.Scopes[0].Snapshot.Files) != 1 {
+ t.Fatalf("advisory metadata failure lost diff: %+v", request)
+ }
+}
diff --git a/internal/collector/changed_test.go b/internal/collector/changed_test.go
index 3f9a0599..28b4c29d 100644
--- a/internal/collector/changed_test.go
+++ b/internal/collector/changed_test.go
@@ -137,6 +137,106 @@ func TestCollectChangedTracksUntrackedAndDeletedFiles(t *testing.T) {
unchanged(t, root, removed)
}
+func TestCollectChangedRestoredContentsAreCleanWithoutRefreshingIndex(t *testing.T) {
+ root := repository(t)
+ write(t, root, "tracked", "original\n")
+ git(t, root, "add", ".")
+ git(t, root, "commit", "-m", "initial")
+ _, initial := changed(t, root, "")
+ write(t, root, "tracked", "changed\n")
+ _, dirty := changed(t, root, initial)
+ write(t, root, "tracked", "original\n")
+ // A non-racy mtime makes raw Git diff report a changed stat even though the
+ // bytes match the index. Collection must neither publish that false change
+ // nor refresh the index to hide it.
+ stamp := time.Now().Add(time.Hour)
+ if err := os.Chtimes(filepath.Join(root, "tracked"), stamp, stamp); err != nil {
+ t.Fatal(err)
+ }
+ index := filepath.Join(root, ".git", "index")
+ before, err := os.Stat(index)
+ if err != nil {
+ t.Fatal(err)
+ }
+ restored, fingerprint := changed(t, root, dirty)
+ if fingerprint != initial {
+ t.Fatal("restoring original bytes did not restore the clean content identity")
+ }
+ for _, scope := range restored.Scopes {
+ if len(scope.Snapshot.Files) != 0 || len(scope.Patches) != 0 {
+ t.Fatalf("stat-only changes published in %s: %+v", scope.Snapshot.Mode, scope)
+ }
+ }
+ unchanged(t, root, fingerprint)
+ after, err := os.Stat(index)
+ if err != nil || !after.ModTime().Equal(before.ModTime()) {
+ t.Fatalf("clean collection refreshed index activity: %v", err)
+ }
+}
+
+func TestMergeBaseContentIdentitySurvivesWorkingEditsAndRestoration(t *testing.T) {
+ for _, staged := range []bool{false, true} {
+ t.Run(map[bool]string{false: "committed", true: "staged"}[staged], func(t *testing.T) {
+ root := branchRepository(t)
+ contents := "committed\n"
+ if staged {
+ contents = "staged\n"
+ write(t, root, "tracked", contents)
+ git(t, root, "add", "tracked")
+ }
+ original, originalFingerprint := changed(t, root, "")
+ if len(original.Scopes[0].Snapshot.Files) != 1 {
+ t.Fatal("merge-base collection must retain committed branch changes")
+ }
+ write(t, root, "tracked", "working\n")
+ _, dirty := changed(t, root, originalFingerprint)
+ write(t, root, "tracked", contents)
+ stamp := time.Now().Add(time.Hour)
+ if err := os.Chtimes(filepath.Join(root, "tracked"), stamp, stamp); err != nil {
+ t.Fatal(err)
+ }
+ index := filepath.Join(root, ".git", "index")
+ before, err := os.Stat(index)
+ if err != nil {
+ t.Fatal(err)
+ }
+ restored, fingerprint := changed(t, root, dirty)
+ if restored.ContentHash != original.ContentHash || fingerprint != originalFingerprint {
+ t.Fatalf("restored merge-base content changed identity: hash %s/%s, fingerprint %s/%s", restored.ContentHash, original.ContentHash, fingerprint, originalFingerprint)
+ }
+ unchanged(t, root, fingerprint)
+ after, err := os.Stat(index)
+ if err != nil || !after.ModTime().Equal(before.ModTime()) {
+ t.Fatalf("restored merge-base collection refreshed index activity: %v", err)
+ }
+ })
+ }
+}
+
+func TestModeOnlyContentIdentityIgnoresFileTimes(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("executable mode changes require POSIX file modes")
+ }
+ root := repository(t)
+ write(t, root, "tracked", "original\n")
+ git(t, root, "add", ".")
+ git(t, root, "commit", "-m", "initial")
+ _, clean := changed(t, root, "")
+ path := filepath.Join(root, "tracked")
+ if err := os.Chmod(path, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ request, mode := changed(t, root, clean)
+ if len(request.Scopes[0].Snapshot.Files) != 1 || request.Scopes[0].Snapshot.Files[0].Additions != 0 || request.Scopes[0].Snapshot.Files[0].Deletions != 0 {
+ t.Fatal("mode-only change must remain reviewable without line changes")
+ }
+ stamp := time.Now().Add(time.Hour)
+ if err := os.Chtimes(path, stamp, stamp); err != nil {
+ t.Fatal(err)
+ }
+ unchanged(t, root, mode)
+}
+
func TestCollectChangedNeverSkipsUnknownIdentity(t *testing.T) {
root := repository(t)
write(t, root, "tracked", "original\n")
@@ -222,6 +322,7 @@ func TestFingerprintUsesStableProvenance(t *testing.T) {
request.Metadata.RunID, request.Metadata.Agent, request.Metadata.Trigger = "another", "another", "another"
request.Metadata.CollectorVersion = "another"
request.Metadata.CollectedAt++
+ request.Metadata.ActivityAt++
request.Metadata.Hostname, request.Metadata.Root, request.Metadata.WorktreeName = "another", "another", "another"
request.Metadata.RepositoryName, request.Metadata.RemoteURL = "another", "another"
request.Metadata.Branch = "another"
diff --git a/internal/collector/collector.go b/internal/collector/collector.go
index 05154b7c..45f6c238 100644
--- a/internal/collector/collector.go
+++ b/internal/collector/collector.go
@@ -118,6 +118,13 @@ func collectRepositoryChanged(ctx context.Context, source diffsource.Source, opt
metadata.WorktreeName = "branch: " + metadata.Branch
metadata.LinkedWorktree = nil
}
+ activity, err := diffsource.ActivityTime(ctx, source, metadata.Head, facts.GitDir)
+ if err != nil && (ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) {
+ return ingestion.Request{}, "", err
+ }
+ if err == nil {
+ metadata.ActivityAt = activity
+ }
if options.OnSource != nil {
if !compared {
comparison.BaseRef = "HEAD"
@@ -191,7 +198,7 @@ func Fingerprint(request ingestion.Request) string {
metadata := request.Metadata
metadata.RunID, metadata.Agent, metadata.Trigger, metadata.CollectorVersion = "", "", "", ""
metadata.AgentSession, metadata.TriggerRoot = nil, ""
- metadata.CollectedAt = 0
+ metadata.CollectedAt, metadata.ActivityAt = 0, 0
if metadata.SourceID != "" {
metadata.Hostname = ""
}
diff --git a/internal/contextservice/activity.go b/internal/contextservice/activity.go
new file mode 100644
index 00000000..b7124575
--- /dev/null
+++ b/internal/contextservice/activity.go
@@ -0,0 +1,34 @@
+package contextservice
+
+import "cmp"
+
+// ActivityTime returns advisory code activity, falling back to captured collection
+// time for observations from producers without activity metadata.
+func ActivityTime(item Context) int64 {
+ if item.LastChangedAt > 0 {
+ return item.LastChangedAt
+ }
+ if item.Observation != nil && item.Observation.CollectedAt > 0 {
+ return item.Observation.CollectedAt
+ }
+ return item.CreatedAt
+}
+
+// CompareActivity orders most active first, with stable stream and context ties.
+// It never changes stream freshness or catalog pagination.
+func CompareActivity(left, right Context) int {
+ if order := cmp.Compare(ActivityTime(right), ActivityTime(left)); order != 0 {
+ return order
+ }
+ leftStream, rightStream := left.ID, right.ID
+ if left.StreamID != nil {
+ leftStream = *left.StreamID
+ }
+ if right.StreamID != nil {
+ rightStream = *right.StreamID
+ }
+ if order := cmp.Compare(leftStream, rightStream); order != 0 {
+ return order
+ }
+ return cmp.Compare(left.ID, right.ID)
+}
diff --git a/internal/contextservice/activity_test.go b/internal/contextservice/activity_test.go
new file mode 100644
index 00000000..27e6b44e
--- /dev/null
+++ b/internal/contextservice/activity_test.go
@@ -0,0 +1,23 @@
+package contextservice
+
+import (
+ "testing"
+
+ "github.com/flexdinesh/diffx/internal/ingestion"
+)
+
+func TestActivityTimeFallsBackToCapturedTime(t *testing.T) {
+ for _, item := range []Context{
+ {LastChangedAt: 123, CreatedAt: 999, LastSubmittedAt: 1000},
+ {Observation: &ingestion.Metadata{CollectedAt: 123}, CreatedAt: 999, LastSubmittedAt: 1000},
+ {CreatedAt: 123, LastSubmittedAt: 1000},
+ } {
+ if got := ActivityTime(item); got != 123 {
+ t.Fatalf("activity %d, want 123: %+v", got, item)
+ }
+ }
+ left, right := Context{ID: "a", CreatedAt: 123}, Context{ID: "b", CreatedAt: 123}
+ if CompareActivity(left, right) >= 0 || CompareActivity(right, left) <= 0 || CompareActivity(left, left) != 0 {
+ t.Fatal("context identity must break activity ties deterministically")
+ }
+}
diff --git a/internal/contextservice/catalog_test.go b/internal/contextservice/catalog_test.go
index 678bf942..f88fbdd7 100644
--- a/internal/contextservice/catalog_test.go
+++ b/internal/contextservice/catalog_test.go
@@ -182,7 +182,7 @@ func TestCatalogCountsAndBranchRemainCapturedUntilNewIngestion(t *testing.T) {
restarted := New(service.store, service.user)
t.Cleanup(func() { _ = restarted.Close() })
old, err := restarted.Get(t.Context(), before.Context.ID)
- if err != nil || old.Stale || old.Branch == nil || *old.Branch != input.Metadata.Branch || old.ChangedFileCount == nil || *old.ChangedFileCount != 1 || old.LastChangedAt != input.Metadata.CollectedAt {
+ if err != nil || old.Stale || old.Branch == nil || *old.Branch != input.Metadata.Branch || old.ChangedFileCount == nil || *old.ChangedFileCount != 1 || old.LastChangedAt != input.Metadata.ActivityAt {
t.Fatalf("old observation mutated: %#v, %v", old, err)
}
latest, err := restarted.Get(t.Context(), after.Context.ID)
@@ -266,7 +266,7 @@ func TestCatalogStaleStatusSurvivesRestartAndFilteredPages(t *testing.T) {
fresh.SubmissionID = "before-fresh"
fresh.Metadata.CollectedAt = afterInput.Metadata.CollectedAt + 1
reused, err := restarted.Ingest(t.Context(), fresh)
- if err != nil || reused.Context.ID != before.Context.ID || reused.Context.Stale || reused.Context.LastChangedAt != input.Metadata.CollectedAt {
+ if err != nil || reused.Context.ID != before.Context.ID || reused.Context.Stale || reused.Context.LastChangedAt != input.Metadata.ActivityAt {
t.Fatalf("deduplicated latest observation: %#v, %v", reused, err)
}
late := afterInput
diff --git a/internal/contextservice/service.go b/internal/contextservice/service.go
index 65baa74e..bb2006b5 100644
--- a/internal/contextservice/service.go
+++ b/internal/contextservice/service.go
@@ -290,7 +290,7 @@ func (service *Service) present(item reviewdata.ContextInfo) (Context, error) {
if name == "" {
name = "Piped"
}
- value := Context{Source: item.Source, Stale: item.Stale, ID: item.ID, Kind: "observation", Name: name, Root: &m.Root, RepositoryID: item.RepositoryID, CreatedAt: item.CreatedAt, LastSubmittedAt: item.LastSubmittedAt, LastChangedAt: m.CollectedAt, ExpiresAt: item.ExpiresAt, ChangedFileCount: item.ChangedFileCount, SubmittedFrom: item.SubmittedFrom, Observation: m, Sessions: item.Sessions, Branch: &m.Branch, WorktreeName: &m.WorktreeName, StreamID: item.StreamID, LatestContextID: item.LatestContextID, Availability: "available", Capabilities: storedCapabilities(scopes, item.Source == "local")}
+ value := Context{Source: item.Source, Stale: item.Stale, ID: item.ID, Kind: "observation", Name: name, Root: &m.Root, RepositoryID: item.RepositoryID, CreatedAt: item.CreatedAt, LastSubmittedAt: item.LastSubmittedAt, LastChangedAt: item.ActivityAt, ExpiresAt: item.ExpiresAt, ChangedFileCount: item.ChangedFileCount, SubmittedFrom: item.SubmittedFrom, Observation: m, Sessions: item.Sessions, Branch: &m.Branch, WorktreeName: &m.WorktreeName, StreamID: item.StreamID, LatestContextID: item.LatestContextID, Availability: "available", Capabilities: storedCapabilities(scopes, item.Source == "local")}
if item.Source == "stdin" {
value.Name, value.Stale = "Piped", false
value.Root, value.RepositoryID, value.Branch, value.WorktreeName = nil, nil, nil, nil
diff --git a/internal/contextservice/service_test.go b/internal/contextservice/service_test.go
index d8c5dc25..a826e3cc 100644
--- a/internal/contextservice/service_test.go
+++ b/internal/contextservice/service_test.go
@@ -95,7 +95,7 @@ func TestObservationStoredOnlyAfterCheckoutChangesAndRemoval(t *testing.T) {
t.Fatalf("stored preview: %#v, %v", preview, err)
}
item, err := service.Get(t.Context(), submitted.Context.ID)
- if err != nil || item.Availability != "available" || item.LastChangedAt != collected.Metadata.CollectedAt {
+ if err != nil || item.Availability != "available" || item.LastChangedAt != collected.Metadata.ActivityAt {
t.Fatalf("stored metadata: %#v, %v", item, err)
}
if !reflect.DeepEqual(item.Capabilities, active.Capabilities) {
diff --git a/internal/diffsource/git.go b/internal/diffsource/git.go
index ecc0f22e..d9a4144e 100644
--- a/internal/diffsource/git.go
+++ b/internal/diffsource/git.go
@@ -122,7 +122,7 @@ func isGitExitFailure(err error) bool {
}
func gitDiffArgs(mode review.DiffMode, base string) []string {
- args := []string{"diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--find-renames"}
+ args := []string{"-c", "diff.autoRefreshIndex=false", "diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--find-renames"}
args = append(args, patchFormat...)
if mode == review.DiffStaged {
args = append(args, "--cached", base)
@@ -176,6 +176,7 @@ func parseRaw(raw string) (map[string]*review.ChangedFile, error) {
func applyStats(files map[string]*review.ChangedFile, output string) {
entries := strings.Split(output, "\x00")
+ changed := make(map[string]bool)
for index := 0; index < len(entries); index++ {
record := entries[index]
if record == "" {
@@ -200,10 +201,19 @@ func applyStats(files map[string]*review.ChangedFile, output string) {
if file == nil {
continue
}
+ changed[path] = true
file.Binary = additions == "-"
file.Additions, _ = strconv.Atoi(additions)
file.Deletions, _ = strconv.Atoi(deletions)
}
+ // Raw records can describe stat-only changes when index refresh is disabled.
+ // Numstat compares contents and includes real changes, including zero-line
+ // mode changes, empty files, renames, binaries and dirty submodules.
+ for path, file := range files {
+ if !changed[path] && file.Status != "U" {
+ delete(files, path)
+ }
+ }
}
type statusPair struct {
diff --git a/internal/diffsource/git_test.go b/internal/diffsource/git_test.go
index e1be92af..47793b22 100644
--- a/internal/diffsource/git_test.go
+++ b/internal/diffsource/git_test.go
@@ -22,6 +22,32 @@ func testGit(t *testing.T, root string, arguments ...string) string {
return string(output)
}
+func TestDiffStatsExcludeStatOnlyRecordsAndKeepMeaningfulZeroLineChanges(t *testing.T) {
+ files, err := parseRaw(":100644 100644 aaaa 0000 M\x00stat-only\x00" +
+ ":100644 100755 aaaa 0000 M\x00mode\x00" +
+ ":000000 100644 0000 aaaa A\x00empty\x00" +
+ ":100644 100644 aaaa bbbb R100\x00old\x00renamed\x00" +
+ ":100644 100644 aaaa bbbb C100\x00original\x00copied\x00" +
+ ":100644 100644 aaaa 0000 M\x00binary\x00" +
+ ":160000 160000 aaaa aaaa M\x00module\x00" +
+ ":000000 100644 0000 0000 U\x00conflict\x00")
+ if err != nil {
+ t.Fatal(err)
+ }
+ applyStats(files, "0\t0\tmode\x000\t0\tempty\x000\t0\t\x00old\x00renamed\x000\t0\t\x00original\x00copied\x00-\t-\tbinary\x000\t0\tmodule\x00")
+ if len(files) != 7 || files["stat-only"] != nil {
+ t.Fatalf("stat-only path remained reviewable: %+v", files)
+ }
+ for _, path := range []string{"mode", "empty", "renamed", "copied", "binary", "module", "conflict"} {
+ if files[path] == nil {
+ t.Fatalf("meaningful change %q was discarded", path)
+ }
+ }
+ if !files["binary"].Binary || files["renamed"].Status != "R" || files["copied"].Status != "C" || files["conflict"].Status != "U" {
+ t.Fatalf("change semantics lost: %+v", files)
+ }
+}
+
func TestRepositoryIdentityGroupsWorktrees(t *testing.T) {
root := t.TempDir()
testGit(t, root, "init", "-q")
diff --git a/internal/diffsource/identity.go b/internal/diffsource/identity.go
index 5a6bd747..a1a7eb85 100644
--- a/internal/diffsource/identity.go
+++ b/internal/diffsource/identity.go
@@ -38,6 +38,7 @@ func ContentHash(ctx context.Context, source Source, snapshots []review.Reposito
return "", err
}
}
+ indexed := stageZeroObjects(index)
hash := sha256.New()
encoder := json.NewEncoder(hash)
if err := encoder.Encode([]string{"git-content-v1", index}); err != nil {
@@ -73,6 +74,13 @@ func ContentHash(ctx context.Context, source Source, snapshots []review.Reposito
if len(fields) < 5 || !git.objectsOnly() && (fields[0] == ":160000" || fields[1] == "160000") {
return "", nil
}
+ // Without index refresh, a stat-dirty working path can have an
+ // unknown raw target OID even when status proved it matches the
+ // index. Restore the known OID to preserve existing content hashes.
+ if target, ok := indexed[file.Path]; ok && snapshot.Mode != review.DiffStaged && file.WorktreeStatus == " " && !file.Recreated && fields[1] == target.mode && strings.Trim(fields[3], "0") == "" {
+ fields[3] = target.oid
+ header = strings.Join(fields, " ")
+ }
}
var content []string
if !git.objectsOnly() && snapshot.Mode != review.DiffStaged && (file.Status != "D" || file.Recreated) {
@@ -103,6 +111,20 @@ func ContentHash(ctx context.Context, source Source, snapshots []review.Reposito
return hex.EncodeToString(hash.Sum(nil)), nil
}
+type indexObject struct{ mode, oid string }
+
+func stageZeroObjects(index string) map[string]indexObject {
+ objects := make(map[string]indexObject)
+ for _, record := range strings.Split(index, "\x00") {
+ header, path, ok := strings.Cut(record, "\t")
+ fields := strings.Fields(header)
+ if ok && len(fields) == 3 && fields[2] == "0" {
+ objects[path] = indexObject{mode: fields[0], oid: fields[1]}
+ }
+ }
+ return objects
+}
+
func worktreeContent(ctx context.Context, path string) ([]string, error) {
stat, err := os.Lstat(path)
if err != nil {
diff --git a/internal/diffsource/worktrees.go b/internal/diffsource/worktrees.go
index 6fba2418..be5bc892 100644
--- a/internal/diffsource/worktrees.go
+++ b/internal/diffsource/worktrees.go
@@ -39,26 +39,54 @@ type WorktreeChangeSummary struct {
ChangedFileCount int
}
-// WorktreeChanges reads local change counts and times without loading diffs.
-func WorktreeChanges(ctx context.Context, root, gitDir string) (WorktreeChangeSummary, error) {
- status, err := runGit(ctx, root, 16*1024*1024, "--no-optional-locks", "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none", "--renames")
- if err != nil {
- return WorktreeChangeSummary{}, err
+// ActivityTime estimates code activity for the captured target. Objects-only
+// comparisons never consult the unrelated checkout's files or index.
+func ActivityTime(ctx context.Context, source Source, head *string, gitDir string) (int64, error) {
+ git, ok := source.(*gitSource)
+ if !ok {
+ return 0, nil
}
var latest int64
- commit, err := runGit(ctx, root, 1024, "log", "-1", "--format=%ct")
- if err == nil {
- seconds, parseErr := strconv.ParseInt(strings.TrimSpace(commit), 10, 64)
- if parseErr != nil {
- return WorktreeChangeSummary{}, parseErr
+ if head != nil {
+ var err error
+ latest, err = commitTime(ctx, git.root, *head)
+ if err != nil {
+ return 0, err
}
- latest = seconds * 1000
- } else {
+ }
+ if git.objectsOnly() {
+ return latest, ctx.Err()
+ }
+ changes, err := worktreeChanges(ctx, git.root, gitDir, latest)
+ return changes.LastChangedAt, err
+}
+
+func commitTime(ctx context.Context, root, revision string) (int64, error) {
+ raw, err := runGit(ctx, root, 1024, "show", "-s", "--format=%ct", revision, "--")
+ if err != nil {
+ return 0, err
+ }
+ seconds, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
+ return max(0, seconds*1000), err
+}
+
+// WorktreeChanges reads local change counts and times without loading diffs.
+func WorktreeChanges(ctx context.Context, root, gitDir string) (WorktreeChangeSummary, error) {
+ latest, err := commitTime(ctx, root, "HEAD")
+ if err != nil {
var failure *gitFailure
if !errors.As(err, &failure) {
return WorktreeChangeSummary{}, err
}
}
+ return worktreeChanges(ctx, root, gitDir, latest)
+}
+
+func worktreeChanges(ctx context.Context, root, gitDir string, latest int64) (WorktreeChangeSummary, error) {
+ status, err := runGit(ctx, root, 16*1024*1024, "--no-optional-locks", "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none", "--renames")
+ if err != nil {
+ return WorktreeChangeSummary{}, err
+ }
staged := false
for name, state := range parseStatus(status) {
if err := ctx.Err(); err != nil {
diff --git a/internal/ingestion/model.go b/internal/ingestion/model.go
index ea1863a6..f619841c 100644
--- a/internal/ingestion/model.go
+++ b/internal/ingestion/model.go
@@ -42,6 +42,7 @@ type Metadata struct {
BranchID string `json:"branchId,omitempty"`
Head *string `json:"head"`
CollectedAt int64 `json:"collectedAt"`
+ ActivityAt int64 `json:"activityAt,omitempty"`
CollectorVersion string `json:"collectorVersion"`
Comparison *Comparison `json:"comparison,omitempty"`
AgentSession *AgentSession `json:"agentSession,omitempty"`
diff --git a/internal/ingestion/validate.go b/internal/ingestion/validate.go
index 4c21cfc8..260e65dd 100644
--- a/internal/ingestion/validate.go
+++ b/internal/ingestion/validate.go
@@ -26,6 +26,9 @@ func Validate(input Request) error {
if strings.TrimSpace(input.Metadata.SourceID) == "" || len(input.Metadata.SourceID) > 256 || input.Metadata.CollectedAt <= 0 {
return fmt.Errorf("sourceId and positive collectedAt required")
}
+ if input.Metadata.ActivityAt < 0 {
+ return fmt.Errorf("activityAt must be nonnegative")
+ }
if input.Metadata.Trigger != "manual" && input.Metadata.Trigger != "agent-hook" && input.Metadata.Trigger != "pipe" {
return fmt.Errorf("trigger must be manual, agent-hook, or pipe")
}
diff --git a/internal/ingestion/validate_test.go b/internal/ingestion/validate_test.go
index db97555c..60dd6510 100644
--- a/internal/ingestion/validate_test.go
+++ b/internal/ingestion/validate_test.go
@@ -24,6 +24,7 @@ func TestValidateObservationContracts(t *testing.T) {
{"uppercase content hash", func(r *Request) { r.ContentHash = strings.Repeat("A", 64) }},
{"blank source", func(r *Request) { r.Metadata.SourceID = " " }},
{"oversized source", func(r *Request) { r.Metadata.SourceID = strings.Repeat("x", 257) }},
+ {"negative activity time", func(r *Request) { r.Metadata.ActivityAt = -1 }},
{"missing collection time", func(r *Request) { r.Metadata.CollectedAt = 0 }},
{"unknown trigger", func(r *Request) { r.Metadata.Trigger = "watcher" }},
{"working-tree non-HEAD baseline", func(r *Request) {
diff --git a/internal/reviewdata/model.go b/internal/reviewdata/model.go
index 2674968c..d011cba8 100644
--- a/internal/reviewdata/model.go
+++ b/internal/reviewdata/model.go
@@ -51,6 +51,9 @@ type ContextInfo struct {
LastSubmittedAt int64
ExpiresAt *int64
ChangedFileCount *int
+
+ // ActivityAt is the winning submission activity for heads; captured activity otherwise.
+ ActivityAt int64
// StreamID names the source/repository/checkout/branch/comparison stream;
// LatestContextID is that stream's current head.
StreamID *string
diff --git a/internal/reviewstore/activity_test.go b/internal/reviewstore/activity_test.go
new file mode 100644
index 00000000..854aa40a
--- /dev/null
+++ b/internal/reviewstore/activity_test.go
@@ -0,0 +1,143 @@
+package reviewstore
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/flexdinesh/diffx/internal/ingestion"
+)
+
+func TestActivityProjectionPreservesHeadOrderAndObservationProvenance(t *testing.T) {
+ for _, queued := range []bool{false, true} {
+ t.Run(map[bool]string{false: "direct", true: "queued"}[queued], func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state.db")
+ store, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = store.Close() })
+ owner := testUser(t, store, "activity")
+ other := testUser(t, store, "other")
+ publish := func(request ingestion.Request) Binding {
+ t.Helper()
+ if queued {
+ if _, err := store.Queue().Accept(t.Context(), owner.ID, request); err != nil {
+ t.Fatal(err)
+ }
+ delivery, err := store.Queue().Claim(t.Context(), time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ binding, err := store.Ingest(owner.ID, delivery.Request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := store.Queue().Complete(t.Context(), delivery, binding.ContextID); err != nil {
+ t.Fatal(err)
+ }
+ return binding
+ }
+ binding, err := store.Ingest(owner.ID, request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return binding
+ }
+ assertActivity := func(id string, activity, captured int64, stale bool) {
+ t.Helper()
+ item, err := store.Context(owner.ID, id, time.Now())
+ if err != nil || item.ActivityAt != activity || item.Metadata == nil || item.Metadata.ActivityAt != captured || item.Stale != stale {
+ t.Fatalf("activity projection: %+v, %v; want activity %d, captured %d, stale %v", item, err, activity, captured, stale)
+ }
+ }
+ request := queuedRequest("original")
+ request.ContentHash = strings.Repeat("a", 64)
+ request.Metadata.CollectedAt, request.Metadata.ActivityAt = 100, 90
+ original := publish(request)
+ assertActivity(original.ContextID, 90, 90, false)
+ fresh := request
+ fresh.SubmissionID = "fresh"
+ fresh.Metadata.CollectedAt, fresh.Metadata.ActivityAt = 200, 190
+ reused := publish(fresh)
+ if reused.ContextID != original.ContextID {
+ t.Fatal("activity metadata changed content identity")
+ }
+ assertActivity(original.ContextID, 190, 90, false)
+ late := fresh
+ late.SubmissionID = "late"
+ late.Metadata.CollectedAt, late.Metadata.ActivityAt = 150, 999
+ publish(late)
+ assertActivity(original.ContextID, 190, 90, false)
+ if _, err := store.Ingest(owner.ID, request); err != nil {
+ t.Fatal(err)
+ }
+ assertActivity(original.ContextID, 190, 90, false)
+ // Equal collection time advances by arrival sequence, even when the
+ // activity estimate decreases. Activity never elects the stream head.
+ equal := fresh
+ equal.SubmissionID = "equal"
+ equal.Metadata.ActivityAt = 180
+ publish(equal)
+ assertActivity(original.ContextID, 180, 90, false)
+ legacy := equal
+ legacy.SubmissionID = "without-activity"
+ legacy.Metadata.CollectedAt, legacy.Metadata.ActivityAt = 300, 0
+ publish(legacy)
+ assertActivity(original.ContextID, 100, 90, false)
+ newer := fresh
+ newer.SubmissionID, newer.ContentHash = "different-content", strings.Repeat("b", 64)
+ newer.Metadata.CollectedAt, newer.Metadata.ActivityAt = 400, 390
+ latest := publish(newer)
+ assertActivity(original.ContextID, 90, 90, true)
+ assertActivity(latest.ContextID, 390, 390, false)
+ if _, err := store.Ingest(other.ID, request); err != nil {
+ t.Fatal(err)
+ }
+ assertActivity(latest.ContextID, 390, 390, false)
+ if _, err := store.Context(other.ID, latest.ContextID, time.Now()); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("activity leaked across owners: %v", err)
+ }
+ if err := store.Close(); err != nil {
+ t.Fatal(err)
+ }
+ store, err = Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertActivity(original.ContextID, 90, 90, true)
+ assertActivity(latest.ContextID, 390, 390, false)
+ })
+ }
+}
+
+func TestOptionalActivityPreservesLegacyPayloadAndRetryHash(t *testing.T) {
+ request := queuedRequest("legacy")
+ raw, hash, err := payloadBytes(request)
+ // Protocol-4 bytes captured before adding the optional activity field.
+ const legacyHash = "d5c453b0eef29e58e4b8401b793752afc15849a1008a6fd660fa79cd04750299"
+ if hash != legacyHash {
+ t.Fatalf("legacy retry hash changed: %s, want %s", hash, legacyHash)
+ }
+ if err != nil || bytes.Contains(raw, []byte("activityAt")) {
+ t.Fatalf("omitted activity changed wire shape: %s, %v", raw, err)
+ }
+ // A previously persisted request must hash identically after decoding.
+ var decoded ingestion.Request
+ if err := json.Unmarshal(raw, &decoded); err != nil {
+ t.Fatal(err)
+ }
+ _, replayHash, err := payloadBytes(decoded)
+ if err != nil || replayHash != hash {
+ t.Fatalf("legacy replay hash changed: %q, %v", replayHash, err)
+ }
+ decoded.Metadata.ActivityAt = 1
+ _, changedHash, err := payloadBytes(decoded)
+ if err != nil || changedHash == hash {
+ t.Fatal("activity mutation must conflict for exact retries")
+ }
+}
diff --git a/internal/reviewstore/catalog.go b/internal/reviewstore/catalog.go
index 5a837a27..ba041ce7 100644
--- a/internal/reviewstore/catalog.go
+++ b/internal/reviewstore/catalog.go
@@ -45,6 +45,8 @@ func putObservationContext(transaction *sql.Tx, ownerID, id, submittedFrom strin
var contextSelect = `SELECT c.id,c.kind,json_extract(o.metadata,'$.root'),NULL,o.repository_id,NULL,json_extract(o.metadata,'$.checkoutKey'),c.submitted_from,
c.created_at,c.last_submitted_at,d.expires_at,
+ CASE WHEN head.context_id=c.id THEN COALESCE(NULLIF(json_extract(head_submission.metadata,'$.activityAt'),0),json_extract(o.metadata,'$.collectedAt'),c.created_at)
+ ELSE COALESCE(NULLIF(json_extract(o.metadata,'$.activityAt'),0),json_extract(o.metadata,'$.collectedAt'),c.created_at) END,
(SELECT json_array_length(v.manifest, '$.files') FROM diff_versions v WHERE v.diff_id=c.anchor_diff_id LIMIT 1),
(SELECT json_extract(v.manifest, '$.source') FROM diff_versions v WHERE v.diff_id=c.anchor_diff_id LIMIT 1),o.metadata,
COALESCE(head.context_id<>c.id,0),
@@ -58,7 +60,10 @@ var contextSelect = `SELECT c.id,c.kind,json_extract(o.metadata,'$.root'),NULL,o
LEFT JOIN observation_stream_heads head ON head.owner_id=o.owner_id AND head.source_id=o.source_id
AND head.repository_key=json_extract(o.metadata,'$.repositoryKey') AND head.checkout_key=json_extract(o.metadata,'$.checkoutKey')
AND head.comparison_policy=` + comparisonPolicySQL("o.metadata") + `
- AND head.branch=COALESCE(NULLIF(json_extract(o.metadata,'$.branchId'),''),json_extract(o.metadata,'$.branch'))`
+ AND head.branch=COALESCE(NULLIF(json_extract(o.metadata,'$.branchId'),''),json_extract(o.metadata,'$.branch'))
+ LEFT JOIN ingestion_records head_record ON head_record.sequence=head.arrival_sequence AND head_record.owner_id=head.owner_id
+ LEFT JOIN observation_submissions head_submission ON head_submission.owner_id=head_record.owner_id AND head_submission.source_id=head_record.source_id
+ AND head_submission.submission_id=head_record.submission_id AND head_submission.context_id=head.context_id`
type scanner interface{ Scan(...any) error }
@@ -74,7 +79,7 @@ func scanContext(row scanner) (ContextInfo, error) {
var sessions string
var head [5]sql.NullString
err := row.Scan(&item.ID, &item.Kind, &item.Root, &item.LocationID, &item.RepositoryID, &item.CommonDir, &item.WorktreeKey,
- &item.SubmittedFrom, &item.CreatedAt, &item.LastSubmittedAt, &item.ExpiresAt, &item.ChangedFileCount, &item.Source, &metadata, &item.Stale, &sessions,
+ &item.SubmittedFrom, &item.CreatedAt, &item.LastSubmittedAt, &item.ExpiresAt, &item.ActivityAt, &item.ChangedFileCount, &item.Source, &metadata, &item.Stale, &sessions,
&head[0], &head[1], &head[2], &head[3], &head[4], &item.LatestContextID)
if errors.Is(err, sql.ErrNoRows) {
return ContextInfo{}, ErrNotFound
diff --git a/internal/reviewstore/queue_test.go b/internal/reviewstore/queue_test.go
index 4897f5cd..4b6dcc1d 100644
--- a/internal/reviewstore/queue_test.go
+++ b/internal/reviewstore/queue_test.go
@@ -28,6 +28,7 @@ func TestQueueRestartReplayAndLeaseFencing(t *testing.T) {
}
owner := testUser(t, store, "queue")
request := queuedRequest("first")
+ request.Metadata.ActivityAt = 98
job, err := store.Queue().Accept(t.Context(), owner.ID, request)
if err != nil {
t.Fatal(err)
@@ -65,7 +66,7 @@ func TestQueueRestartReplayAndLeaseFencing(t *testing.T) {
t.Fatalf("commit replay: %+v %v", replay, err)
}
after, err := store.Context(owner.ID, binding.ContextID, time.Now())
- if err != nil || *after.ExpiresAt != *before.ExpiresAt || after.LastSubmittedAt != before.LastSubmittedAt {
+ if err != nil || *after.ExpiresAt != *before.ExpiresAt || after.LastSubmittedAt != before.LastSubmittedAt || after.ActivityAt != 98 || recovered.Request.Metadata.ActivityAt != 98 {
t.Fatalf("retry extended retention: %v", err)
}
if err := store.Queue().Complete(t.Context(), original, binding.ContextID); !errors.Is(err, ingestionqueue.ErrLeaseLost) {
diff --git a/internal/serverapp/capabilities_test.go b/internal/serverapp/capabilities_test.go
index c666fe2a..f5b7150d 100644
--- a/internal/serverapp/capabilities_test.go
+++ b/internal/serverapp/capabilities_test.go
@@ -261,6 +261,14 @@ func TestCollectionIngestsEveryCheckoutAndMovesMCPDefault(t *testing.T) {
}
mu.Lock()
roots = []string{discovered, root}
+ // The first ingested checkout is more active than the last. Defaults must
+ // follow activity, regardless of collection or publication ordering.
+ latestActivity := time.Now().Add(time.Hour)
+ for path, timestamp := range map[string]time.Time{root: latestActivity.Add(-time.Minute), discovered: latestActivity} {
+ if err := os.Chtimes(filepath.Join(path, "file.txt"), timestamp, timestamp); err != nil {
+ t.Fatal(err)
+ }
+ }
failed = []serverapp.CollectionFailure{{Path: "/broken", Detail: "checkout changed during collection"}}
mu.Unlock()
events, unsubscribe := service.Subscribe()
@@ -277,10 +285,10 @@ func TestCollectionIngestsEveryCheckoutAndMovesMCPDefault(t *testing.T) {
case <-time.After(2 * time.Second):
t.Fatal("collection published no event")
}
- newest := collected.Contexts[1]
+ newest := collected.Contexts[0]
snapshot := decode[review.RepositoryDiff](t, request(t, d, "GET", "/api/v2/contexts/"+newest.ID+"/diffs/current?scope=all", nil))
if snapshot.ID == first.Snapshot.ID || mcpDiff().ID != snapshot.ID {
- t.Fatal("bare MCP default did not follow the newest changed collection")
+ t.Fatal("bare MCP default did not follow the most active changed checkout")
}
page := decode[contextservice.Page](t, request(t, d, "GET", "/api/v2/contexts", nil))
if len(page.Contexts) != 3 {
diff --git a/internal/serverapp/collection.go b/internal/serverapp/collection.go
index 23a5acb7..0af14b29 100644
--- a/internal/serverapp/collection.go
+++ b/internal/serverapp/collection.go
@@ -51,18 +51,31 @@ type Published struct {
Failures []CollectionFailure
}
-// Newest is the last published context with changes, else the last one. It
-// matches the catalog's newest changed head because ingestion is serial.
+// Newest prefers current contexts with changes, then current clean contexts.
+// A successful collection of older observations still has an available default.
func (published Published) Newest() (contextservice.Submission, bool) {
- for index := len(published.Submissions) - 1; index >= 0; index-- {
- if count := published.Submissions[index].Context.ChangedFileCount; count != nil && *count > 0 {
- return published.Submissions[index], true
+ var newest, fallback contextservice.Submission
+ found, available := false, false
+ changed := false
+ for _, submitted := range published.Submissions {
+ if submitted.Context.Availability != "available" {
+ continue
+ }
+ if !available || contextservice.CompareActivity(submitted.Context, fallback.Context) < 0 {
+ fallback, available = submitted, true
+ }
+ if submitted.Context.Stale {
+ continue
+ }
+ hasChanges := submitted.Context.ChangedFileCount != nil && *submitted.Context.ChangedFileCount > 0
+ if !found || hasChanges && !changed || hasChanges == changed && contextservice.CompareActivity(submitted.Context, newest.Context) < 0 {
+ newest, found, changed = submitted, true, hasChanges
}
}
- if len(published.Submissions) == 0 {
- return contextservice.Submission{}, false
+ if found {
+ return newest, true
}
- return published.Submissions[len(published.Submissions)-1], true
+ return fallback, available
}
// Publish selects each collected checkout into the composition's scope, then
diff --git a/internal/serverapp/collection_test.go b/internal/serverapp/collection_test.go
new file mode 100644
index 00000000..82d40aa1
--- /dev/null
+++ b/internal/serverapp/collection_test.go
@@ -0,0 +1,123 @@
+package serverapp
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/flexdinesh/diffx/internal/contextservice"
+ "github.com/flexdinesh/diffx/internal/ingestion"
+ "github.com/flexdinesh/diffx/internal/review"
+ "github.com/flexdinesh/diffx/internal/reviewstore"
+)
+
+func TestPublishedNewestUsesActivityThenStableStreamIdentity(t *testing.T) {
+ changed, clean := 1, 0
+ streamA, streamB := "stream-a", "stream-b"
+ contexts := []contextservice.Context{
+ {ID: "older", StreamID: &streamB, LastChangedAt: 100, ChangedFileCount: &changed, Availability: "available"},
+ {ID: "newest", StreamID: &streamA, LastChangedAt: 200, ChangedFileCount: &changed, Availability: "available"},
+ {ID: "tie", StreamID: &streamB, LastChangedAt: 200, ChangedFileCount: &changed, Availability: "available"},
+ {ID: "clean", LastChangedAt: 900, ChangedFileCount: &clean, Availability: "available"},
+ {ID: "unknown", LastChangedAt: 999, Availability: "available"},
+ {ID: "stale", LastChangedAt: 1000, ChangedFileCount: &changed, Availability: "available", Stale: true},
+ {ID: "unavailable", LastChangedAt: 1001, ChangedFileCount: &changed, Availability: "unavailable"},
+ }
+ for _, reverse := range []bool{false, true} {
+ published := Published{}
+ for index := range contexts {
+ if reverse {
+ index = len(contexts) - 1 - index
+ }
+ published.Submissions = append(published.Submissions, contextservice.Submission{Context: contexts[index]})
+ }
+ newest, ok := published.Newest()
+ if !ok || newest.Context.ID != "newest" {
+ t.Fatalf("activity selection (reverse %v): %+v, %v", reverse, newest, ok)
+ }
+ }
+}
+
+func TestOlderCollectionRemainsSuccessfulWithoutRegressingStreamHead(t *testing.T) {
+ store, err := reviewstore.Open(":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ owner, err := store.User("local", "Local")
+ if err != nil {
+ t.Fatal(err)
+ }
+ service := contextservice.New(store, owner)
+ defer service.Close()
+ request := ingestion.Request{
+ ProtocolVersion: ingestion.ProtocolVersion, SubmissionID: "later-clock", ContentHash: strings.Repeat("a", 64),
+ Metadata: ingestion.Metadata{SourceID: "source", RepositoryKey: "repository", CheckoutKey: "checkout", Branch: "main", Root: "/gone", Trigger: "manual", CollectedAt: 200, ActivityAt: 190},
+ Scopes: []ingestion.Scope{{Snapshot: review.RepositoryDiff{Source: "local", Root: "/gone", Branch: "main", Mode: review.DiffAll, Revision: "later", Files: []review.ChangedFile{}}, Patches: map[string]review.FilePatch{}}},
+ }
+ later, err := service.Ingest(t.Context(), request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ request.SubmissionID, request.ContentHash = "earlier-clock", strings.Repeat("b", 64)
+ request.Metadata.CollectedAt, request.Metadata.ActivityAt = 100, 90
+ published, err := Publish(t.Context(), service, Collection{Requests: []ingestion.Request{request}})
+ if err != nil || len(published.Submissions) != 1 || !published.Submissions[0].Context.Stale {
+ t.Fatalf("older publication: %+v, %v", published, err)
+ }
+ fallback, ok := published.Newest()
+ if !ok || fallback.Context.ID != published.Submissions[0].Context.ID {
+ t.Fatalf("successful older collection lost its default: %+v, %v", fallback, ok)
+ }
+ current := &defaultContext{}
+ current.set(later.Context.ID)
+ handler := collection{service: service, current: current, collect: func(ctx context.Context) (Collection, error) {
+ request.SubmissionID = "earlier-clock-again"
+ return Collection{Requests: []ingestion.Request{request}}, nil
+ }}
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/api/v2/collections", nil))
+ if response.Code != http.StatusOK || current.get() != fallback.Context.ID {
+ t.Fatalf("successful older collection response/default: %d %s %s", response.Code, response.Body.String(), current.get())
+ }
+ head, err := service.Get(t.Context(), later.Context.ID)
+ if err != nil || head.Stale || head.LatestContextID == nil || *head.LatestContextID != later.Context.ID {
+ t.Fatalf("activity default changed stream freshness: %+v, %v", head, err)
+ }
+}
+
+func TestPublishedNewestPrefersCurrentCleanBeforeStaleChanged(t *testing.T) {
+ changed, clean := 1, 0
+ published := Published{Submissions: []contextservice.Submission{
+ {Context: contextservice.Context{ID: "stale", Stale: true, Availability: "available", LastChangedAt: 900, ChangedFileCount: &changed}},
+ {Context: contextservice.Context{ID: "current", Availability: "available", LastChangedAt: 100, ChangedFileCount: &clean}},
+ }}
+ newest, ok := published.Newest()
+ if !ok || newest.Context.ID != "current" {
+ t.Fatalf("stale changed context displaced current clean default: %+v, %v", newest, ok)
+ }
+ published.Submissions = append(published.Submissions[:1], contextservice.Submission{Context: contextservice.Context{ID: "stale-more-active", Stale: true, Availability: "available", LastChangedAt: 1000}})
+ newest, ok = published.Newest()
+ if !ok || newest.Context.ID != "stale-more-active" {
+ t.Fatalf("all-stale fallback did not follow activity: %+v, %v", newest, ok)
+ }
+}
+
+func TestPublishedNewestKeepsCleanAndPipedFallbacks(t *testing.T) {
+ clean := 0
+ for _, source := range []string{"local", "stdin"} {
+ published := Published{Submissions: []contextservice.Submission{
+ {Context: contextservice.Context{ID: "first", Source: source, Observation: &ingestion.Metadata{CollectedAt: 200}, ChangedFileCount: &clean, Availability: "available"}},
+ {Context: contextservice.Context{ID: "last", Source: source, Observation: &ingestion.Metadata{CollectedAt: 100}, ChangedFileCount: &clean, Availability: "available"}},
+ }}
+ newest, ok := published.Newest()
+ if !ok || newest.Context.ID != "first" {
+ t.Fatalf("%s fallback: %+v, %v", source, newest, ok)
+ }
+ }
+ if _, ok := (Published{}).Newest(); ok {
+ t.Fatal("empty collection had a default")
+ }
+}
diff --git a/packages/api/openapi.yaml b/packages/api/openapi.yaml
index 625da1dd..c1068210 100644
--- a/packages/api/openapi.yaml
+++ b/packages/api/openapi.yaml
@@ -1095,6 +1095,11 @@ components:
type: integer
format: int64
description: Producer collection time in Unix milliseconds.
+ activityAt:
+ type: integer
+ format: int64
+ minimum: 0
+ description: Approximate code activity time in Unix milliseconds; omitted when unavailable. Advisory and independent of snapshot freshness.
collectorVersion:
type: string
comparison:
@@ -1233,7 +1238,7 @@ components:
lastChangedAt:
type: integer
format: int64
- description: Latest commit or working-tree change time in Unix milliseconds. Submission time for captures; zero when worktree change metadata is unknown.
+ description: Approximate code activity in Unix milliseconds. Current heads use the winning submission activity; historical snapshots retain captured activity. Missing activity falls back to the original collection time.
changedFileCount:
type: [integer, "null"]
minimum: 0
diff --git a/packages/api/src/schema.d.ts b/packages/api/src/schema.d.ts
index 8dfa0caf..eae36121 100644
--- a/packages/api/src/schema.d.ts
+++ b/packages/api/src/schema.d.ts
@@ -609,6 +609,11 @@ export interface components {
* @description Producer collection time in Unix milliseconds.
*/
collectedAt: number;
+ /**
+ * Format: int64
+ * @description Approximate code activity time in Unix milliseconds; omitted when unavailable. Advisory and independent of snapshot freshness.
+ */
+ activityAt?: number;
collectorVersion: string;
comparison?: components["schemas"]["Comparison"];
agentSession?: components["schemas"]["AgentSession"];
@@ -675,7 +680,7 @@ export interface components {
lastSubmittedAt: number;
/**
* Format: int64
- * @description Latest commit or working-tree change time in Unix milliseconds. Submission time for captures; zero when worktree change metadata is unknown.
+ * @description Approximate code activity in Unix milliseconds. Current heads use the winning submission activity; historical snapshots retain captured activity. Missing activity falls back to the original collection time.
*/
lastChangedAt: number;
/** @description Unique files with staged, unstaged, or untracked changes, independent of the selected scope. Captures use their snapshot file count. Null when change metadata is unknown. */